@navette-js/forms 1.0.0-beta.1 → 1.0.0-beta.3

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
@@ -1,4 +1,4 @@
1
- // @navette-js/forms — port de filament/forms : Field, TextInput, Checkbox, validation côté serveur.
1
+ // @navette-js/forms — form fields (Field, TextInput, Select, Checkbox…) and server-side validation.
2
2
  import { Attributes, HtmlString, e, evaluate, icon, js, views, configured } from '@navette-js/support';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { t } from '@navette-js/support/i18n';
@@ -21,7 +21,7 @@ views.namespace('filament-forms', fileURLToPath(new URL('../views', import.meta.
21
21
  let colorManager = null;
22
22
  export const useColorManager = (manager) => { colorManager = manager; };
23
23
  const asset = (name) => views.render('filament-forms::asset', { path: name }).trim();
24
- /** Nombre tel que PHP l'écrit : au-delà des entiers exacts, un flottant à 14 chiffres significatifs (« 1.844674407371E+19 »). */
24
+ /** Number for an HTML attribute: beyond exact integers, a float with 14 significant digits (`1.844674407371E+19`). */
25
25
  const phpNumber = (n) => {
26
26
  if (n === null || Number.isSafeInteger(n) || Math.abs(n) < 1e15)
27
27
  return n;
@@ -50,26 +50,25 @@ export class Field extends Component {
50
50
  disabled(condition = true) { this._disabled = condition; return this; }
51
51
  autofocus(condition = true) { this._autofocus = condition; return this; }
52
52
  default(value) { this._default = value; return this; }
53
- /** Texte d'aide affiché sous le champ. */
53
+ /** Helper text shown under the field. */
54
54
  helperText(text) { this._helperText = text; return this; }
55
- /** Identifiant du texte d'aide, à lier au contrôle par `aria-describedby`. */
55
+ /** Helper text id, linked to the control through `aria-describedby`. */
56
56
  getHelperTextId() { return this._helperText !== null ? `${this.getId()}-helper-text` : null; }
57
- /** Libellé réservé aux lecteurs d'écran. */
57
+ /** Keeps the label for screen readers only. */
58
58
  hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
59
- // --- indication à droite du libellé (port de Concerns\HasHint)
60
59
  _hint = null;
61
60
  _hintColor = null;
62
61
  _hintIcon = null;
63
62
  _hintIconTooltip = null;
64
63
  _hintActions = [];
65
64
  /**
66
- * Indication affichée à droite du libellé (un composant `Text`). Le texte est échappé ; une `HtmlString` de `@navette-js/support`
67
- * (`raw('<a href="…">…</a>')`) est insérée telle quelle : n'y placez que des valeurs échappées avec `e()`.
65
+ * Hint shown to the right of the label (a `Text` component). The text is escaped; an `HtmlString` from `@navette-js/support`
66
+ * (`raw('<a href="…">…</a>')`) is inserted as is, so only put values escaped with `e()` in it.
68
67
  */
69
68
  hint(hint) { this._hint = hint; return this; }
70
- /** Couleur de l'indication et de son icône (`gray` pour le texte, `primary` pour l'icône par défaut). */
69
+ /** Color of the hint and its icon (by default `gray` for the text, `primary` for the icon). */
71
70
  hintColor(color) { this._hintColor = color; return this; }
72
- /** Icône d'indication, avec une infobulle facultative (qui sert aussi de nom accessible). */
71
+ /** Hint icon, with an optional tooltip (also used as its accessible name). */
73
72
  hintIcon(name, tooltip) {
74
73
  this._hintIcon = name;
75
74
  if (arguments.length >= 2)
@@ -77,13 +76,13 @@ export class Field extends Component {
77
76
  return this;
78
77
  }
79
78
  hintIconTooltip(tooltip) { this._hintIconTooltip = tooltip; return this; }
80
- /** Action affichée après l'indication (en lien, petite taille), montée sur le champ. */
79
+ /** Action shown after the hint (small link), mounted on the field. */
81
80
  hintAction(action) { return this.hintActions([action]); }
82
81
  hintActions(actions) { this._hintActions = [...this._hintActions, ...actions]; return this; }
83
82
  getHintActions() { return this._hintActions; }
84
- /** Actions que le champ expose au montage (`schemaComponent` = clé du champ) : ici, ses actions d'indication. */
83
+ /** Actions the field exposes for mounting (`schemaComponent` = field key): its hint actions. */
85
84
  getActions() { return this.getHintActions(); }
86
- /** Rangée « après le libellé » : indication, icône d'indication et actions d'indication. */
85
+ /** Row after the label: hint, hint icon and hint actions. */
87
86
  async renderAfterLabel() {
88
87
  const ctx = this.context();
89
88
  const hint = await evaluate(this._hint, ctx);
@@ -97,22 +96,22 @@ export class Field extends Component {
97
96
  items.push(...this._hintActions);
98
97
  if (!items.length)
99
98
  return '';
100
- // Comme Filament : les composants de la rangée ont le chemin du champ et, pour conteneur, celui du schéma du champ.
99
+ // Row components take the field's path, and the field's schema path as their container path.
101
100
  return renderInlineItems(this, items, { alignment: 'end', scope: { path: this.getStatePath(), containerPath: this.container.getStatePath() }, mountKey: this.getKey(), actionView: 'link' });
102
101
  }
103
- /** Exécuté côté serveur quand l'état du champ change (champ `live()`). `set(nom, valeur)` modifie un autre champ du même formulaire. */
102
+ /** Runs on the server when the field state changes (`live()` field). `set(name, value)` changes another field of the same form. */
104
103
  afterStateUpdated(callback) { this._afterStateUpdated = callback; return this; }
105
104
  hasAfterStateUpdated() { return this._afterStateUpdated !== null; }
106
105
  async callAfterStateUpdated(set) { await this._afterStateUpdated?.({ ...this.context(), set }); }
107
- /** Force l'inclusion du champ dans les données envoyées, même désactivé (`->disabled()->dehydrated()`). */
106
+ /** Forces the field into the submitted data, even when disabled (`.disabled().dehydrated()`). */
108
107
  dehydrated(condition = true) { this._dehydrated = condition; return this; }
109
- /** Par défaut, un champ désactivé n'est ni validé ni enregistré. */
108
+ /** By default, a disabled field is neither validated nor saved. */
110
109
  async isDehydrated() { return this._dehydrated ?? !(await this.isDisabled()); }
111
110
  getDefaultState() { return typeof this._default === 'function' ? this._default() : this._default; }
112
- /** Mise en forme de la valeur d'un enregistrement quand elle entre dans le formulaire (hydratation). */
111
+ /** Shapes a record value as it enters the form (hydration). */
113
112
  hydrateState(value) { return value; }
114
113
  rule(rule) { this._rules.push(rule); return this; }
115
- /** Règle `unique` : la valeur ne doit pas exister dans la colonne du même nom ; `ignoreRecord` exclut l'enregistrement en cours d'édition. */
114
+ /** `unique` rule: the value must not exist in the column of the same name; `ignoreRecord` excludes the record being edited. */
116
115
  unique(options = {}) {
117
116
  return this.rule({
118
117
  name: 'unique',
@@ -120,7 +119,7 @@ export class Field extends Component {
120
119
  const host = ctx.livewire;
121
120
  const adapter = host.getDataAdapter?.();
122
121
  if (!adapter)
123
- throw new Error('unique() : le composant hôte ne fournit pas d\'adaptateur de données (getDataAdapter).');
122
+ throw new Error('unique(): the host component provides no data adapter (getDataAdapter).');
124
123
  const ignored = options.ignoreRecord ? (ctx.record ? adapter.getKey(ctx.record) : (host.getRecordKey?.() ?? null)) : null;
125
124
  return !(await adapter.exists(options.column ?? this._name, value, ignored));
126
125
  },
@@ -132,7 +131,7 @@ export class Field extends Component {
132
131
  isDisabled() { return evaluate(this._disabled, this.context()); }
133
132
  getErrors() { return this.livewire._errors[this.getStatePath()] ?? []; }
134
133
  hasError() { return this.getErrors().length > 0; }
135
- /** Règles de validation : `required` d'abord, puis celles du champ. Les messages sont ceux de Laravel (en). */
134
+ /** Validation rules: `required` first, then the field's own. */
136
135
  async getValidationRules() {
137
136
  const rules = [];
138
137
  if (await this.isRequired())
@@ -140,11 +139,11 @@ export class Field extends Component {
140
139
  return [...rules, ...this._rules];
141
140
  }
142
141
  async validate() {
143
- // Comme Filament : un champ non enregistré (`dehydrated(false)` : confirmation, mot de passe actuel) est quand même validé ;
144
- // seul un champ désactivé (et non forcé par `dehydrated()`) y échappe.
142
+ // A field that is not saved (`dehydrated(false)`: confirmation, current password) is still validated;
143
+ // only a disabled field (not forced by `dehydrated()`) is skipped.
145
144
  if ((await this.isDisabled()) && this._dehydrated !== true)
146
145
  return [];
147
- // Comme Laravel (ConvertEmptyStringsToNull) : une chaîne vide est traitée comme absente.
146
+ // An empty string is treated as missing.
148
147
  const value = this.context().state === '' ? null : this.context().state;
149
148
  const attribute = (await this.getLabel()).toLowerCase();
150
149
  for (const rule of await this.getValidationRules()) {
@@ -155,20 +154,19 @@ export class Field extends Component {
155
154
  }
156
155
  return [];
157
156
  }
158
- /** Port de Field::wrapEmbeddedHtml (enveloppe par défaut). */
159
- /** Valeur écrite dans les données à enregistrer (les champs composés n'y laissent qu'une ossature, remplie par leurs enfants). */
157
+ /** Value written to the data to save (composite fields only leave a skeleton, filled by their children). */
160
158
  dehydrateState(state) { return state; }
161
- /** Dernière mise en forme de la valeur enregistrée, une fois les champs enfants dé-hydratés (liste d'éléments d'un Builder). */
159
+ /** Final shaping of the saved value, once child fields are dehydrated (e.g. a Builder's item list). */
162
160
  finalizeDehydratedState(state) { return state; }
163
161
  async wrap(html, options = {}) {
164
162
  const errors = this.getErrors();
165
- // Champ d'un FusedGroup : enveloppe réduite à un libellé pour lecteurs d'écran (port de plain-field-wrapper).
163
+ // FusedGroup field: the wrapper is reduced to a screen-reader label.
166
164
  if (this.container.getFieldWrapperView?.() === 'filament-forms::plain-field-wrapper') {
167
165
  const label = await this.getLabel();
168
166
  const labelHtml = label === '' ? '' : options.labelAsDiv ? `<div id="${e(this.getId())}-label" class="fi-fo-field-label fi-sr-only">${e(label)}</div>` : `<label for="${e(this.getId())}" class="fi-fo-field-label fi-sr-only">${e(label)}</label>`;
169
167
  return `<div data-field-wrapper class="fi-fo-field">${labelHtml}${options.labelPrefix ?? ''}${html}${options.after ?? ''}</div>`;
170
168
  }
171
- // Texte d'aide : un petit schéma « en ligne » sous le contrôle (port de belowContent).
169
+ // Helper text: a small inline schema under the control.
172
170
  if (this._helperText !== null)
173
171
  html += `<div id="${e(this.getHelperTextId())}" class="fi-sc fi-inline fi-sc-has-gap fi-sc-dense"><div x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: this.getStatePath(), containerPath: this.container.getStatePath() }))})"><div class="fi-sc-component"><span class="fi-sc-text">${e(this._helperText)}</span></div></div></div>`;
174
172
  return views.render('filament-forms::components.field-wrapper', {
@@ -191,21 +189,21 @@ export class TextInput extends Field {
191
189
  _prefix = null;
192
190
  _live = null;
193
191
  static make(name) { return configured(new TextInput(name)); }
194
- /** Nombre décimal : `type="number"`, `step="any"`. */
192
+ /** Decimal number: `type="number"`, `step="any"`. */
195
193
  numeric() { this._numeric = 'decimal'; return this.rule({ name: 'numeric', passes: (v) => String(v).trim() !== '' && Number.isFinite(Number(v)), message: (a) => `The ${a} field must be a number.` }); }
196
- /** Nombre entier : `type="number"`, `step="1"`. */
194
+ /** Integer: `type="number"`, `step="1"`. */
197
195
  integer() { this._numeric = 'integer'; return this.rule({ name: 'integer', passes: (v) => /^-?\d+$/.test(String(v).trim()), message: (a) => `The ${a} field must be an integer.` }); }
198
196
  _step = null;
199
- /** Pas de saisie d'un champ numérique (`step`), à la place de celui déduit de `numeric()` ou `integer()`. */
197
+ /** Input `step` of a numeric field, overriding the one implied by `numeric()` or `integer()`. */
200
198
  step(step) { this._step = step; return this; }
201
199
  minValue(value) { this._minValue = value; return this.rule({ name: 'min', passes: (v) => Number(v) >= value, message: (a) => `The ${a} field must be at least ${value}.` }); }
202
200
  maxValue(value) { this._maxValue = value; return this.rule({ name: 'max', passes: (v) => Number(v) <= value, message: (a) => `The ${a} field must not be greater than ${value}.` }); }
203
- /** Libellé affiché avant la zone de saisie (ex. `$`). */
201
+ /** Label shown before the input (e.g. `$`). */
204
202
  prefix(label) { this._prefix = label; return this; }
205
203
  _suffix = null;
206
- /** Libellé affiché après la saisie (« hours », « kg »…). */
204
+ /** Label shown after the input ("hours", "kg"…). */
207
205
  suffix(label) { this._suffix = label; return this; }
208
- /** L'état est envoyé au serveur à chaque frappe, ou à la perte de focus avec `onBlur`. */
206
+ /** Sends the state to the server on every keystroke, or on blur with `onBlur`. */
209
207
  live(options = {}) { this._live = options.onBlur ? 'blur' : 'live'; return this; }
210
208
  type(type) { this._type = type; return this; }
211
209
  email(condition = true) {
@@ -217,16 +215,16 @@ export class TextInput extends Field {
217
215
  }
218
216
  password(condition = true) { this._isPassword = condition; return this; }
219
217
  revealable(condition = true) { this._isRevealable = condition; return this; }
220
- /** `true` → "on", `false` → "off", chaîne → telle quelle (comme CanBeAutocompleted). */
218
+ /** `true` → "on", `false` → "off", a string as is. */
221
219
  autocomplete(value = true) { this._autocomplete = value === true ? 'on' : value === false ? 'off' : value; return this; }
222
220
  placeholder(text) { this._placeholder = text; return this; }
223
- /** Adresse web : champ de type `url`, valeur http(s) valide exigée. */
224
221
  _mask = null;
225
- /** Masque de saisie Alpine (`(999) 999-9999`) : guide la frappe dans le navigateur, sans valeur de validation. */
222
+ /** Alpine input mask (`(999) 999-9999`): guides typing in the browser, with no validation value. */
226
223
  mask(pattern) { this._mask = pattern; return this; }
227
- /** Numéro de téléphone : chiffres, espaces et ponctuation usuelle. */
224
+ /** Phone number: digits, spaces and common punctuation. */
228
225
  tel() { this._type = 'tel'; return this.rule({ name: 'tel', passes: (v) => /^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\.\/0-9]*$/.test(String(v)), message: (a) => `The ${a} field format is invalid.` }); }
229
226
  email() { this._type = 'email'; return this.rule({ name: 'email', passes: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)), message: (a) => `The ${a} field must be a valid email address.` }); }
227
+ /** Web address: `url` input type, a valid http(s) value is required. */
230
228
  url() { this._type = 'url'; return this.rule({ name: 'url', passes: (v) => { try {
231
229
  return ['http:', 'https:'].includes(new URL(String(v)).protocol);
232
230
  }
@@ -235,7 +233,6 @@ export class TextInput extends Field {
235
233
  } }, message: (a) => `The ${a} field must be a valid URL.` }); }
236
234
  maxLength(length) { this._maxLength = length; return this.rule({ name: 'max', passes: (v) => String(v).length <= length, message: (a) => `The ${a} field must not be greater than ${length} characters.` }); }
237
235
  minLength(length) { this._minLength = length; return this.rule({ name: 'min', passes: (v) => String(v).length >= length, message: (a) => `The ${a} field must be at least ${length} characters.` }); }
238
- /** Port de TextInput::toEmbeddedHtml (sans affixes libres, masque ni datalist pour l'instant). */
239
236
  async toHtml() {
240
237
  const isDisabled = await this.isDisabled();
241
238
  const revealable = this._isPassword && this._isRevealable;
@@ -250,7 +247,7 @@ export class TextInput extends Field {
250
247
  max: phpNumber(this._maxValue), min: phpNumber(this._minValue),
251
248
  maxlength: this._maxLength, minlength: this._minLength, placeholder: this._placeholder,
252
249
  required: (await this.isRequired()) && !isDisabled, step: this._step ?? (this._numeric === 'decimal' ? 'any' : this._numeric === 'integer' ? 1 : null), type,
253
- // Comme Filament : les modificateurs (`.live`, `.blur`) s'ajoutent aussi à `wire:model.fill`.
250
+ // Modifiers (`.live`, `.blur`) also apply to `wire:model.fill`.
254
251
  [revealable ? model.replace('wire:model', 'wire:model.fill') : model]: this.getStatePath(),
255
252
  'x-bind:type': revealable ? "isPasswordRevealed ? 'text' : 'password'" : null,
256
253
  'x-mask': this._mask,
@@ -271,22 +268,22 @@ export class TextInput extends Field {
271
268
  }));
272
269
  }
273
270
  }
274
- /** Sélecteur de date natif (`<input type="date">`). Le sélecteur JavaScript de Filament n'est pas encore porté. */
271
+ /** Native date picker (`<input type="date">`). */
275
272
  export class DatePicker extends Field {
276
273
  _maxDate = null;
277
274
  _minDate = null;
278
275
  _live = false;
279
276
  static make(name) { return configured(new DatePicker(name)); }
280
277
  _datePlaceholder = null;
281
- /** Texte indicatif (les navigateurs ne l'affichent pas sur un champ `date` natif ; l'attribut est posé comme dans Filament). */
278
+ /** Placeholder attribute (browsers do not display it on a native `date` input). */
282
279
  placeholder(text) { this._datePlaceholder = text; return this; }
283
- /** Envoie l'état au serveur dès qu'il change (`wire:model.live`). */
280
+ /** Sends the state to the server as soon as it changes (`wire:model.live`). */
284
281
  live(condition = true) { this._live = condition; return this; }
285
- /** Comme Filament : l'état d'un DatePicker est une date `AAAA-MM-JJ`, quelle que soit la précision stockée. */
282
+ /** The state is a `YYYY-MM-DD` date, whatever the stored precision. */
286
283
  hydrateState(value) { return typeof value === 'string' && value.length > 10 ? value.slice(0, 10) : value; }
287
284
  static resolve(date) { return date === 'today' ? new Date().toISOString().slice(0, 10) : date; }
288
285
  async bound(date) { const value = await evaluate(date, this.context()); return value ? DatePicker.resolve(value) : null; }
289
- /** Date la plus tardive acceptée : `'today'`, une date `AAAA-MM-JJ`, ou une fonction du contexte (`({ get }) => get('endDate') || 'today'`). */
286
+ /** Latest accepted date: `'today'`, a `YYYY-MM-DD` date, or a context function (`({ get }) => get('endDate') || 'today'`). */
290
287
  maxDate(date) {
291
288
  this._maxDate = date;
292
289
  return this.rule({ name: 'before_or_equal', passes: async (v) => { const max = await this.bound(date); return max === null || String(v).slice(0, 10) <= max; }, message: (a) => `The ${a} field must be a date before or equal to ${typeof date === 'string' ? date : 'the maximum date'}.` });
@@ -311,8 +308,8 @@ export class DatePicker extends Field {
311
308
  }
312
309
  }
313
310
  /**
314
- * Liste déroulante. Native (`<select>`) par défaut ; `searchable()` active le sélecteur JavaScript de Filament,
315
- * qui interroge le serveur par les méthodes exposées (`getSearchResultsForJs`…).
311
+ * Dropdown. Native (`<select>`) by default; `searchable()` switches to the JavaScript select,
312
+ * which queries the server through the exposed methods (`getSearchResultsForJs`…).
316
313
  */
317
314
  export class Select extends Field {
318
315
  _options = [];
@@ -328,34 +325,33 @@ export class Select extends Field {
328
325
  _searchResultsUsing = null;
329
326
  _optionLabelUsing = null;
330
327
  static make(name) { return configured(new Select(name)); }
331
- /** Résultats de recherche calculés par le serveur à chaque frappe (sans liste d'options préchargée). */
328
+ /** Search results computed by the server on every keystroke (no preloaded option list). */
332
329
  getSearchResultsUsing(resolver) { this._searchResultsUsing = resolver; this._searchable = true; return this; }
333
- /** Libellé d'une valeur ; sert aussi à valider qu'une valeur choisie par recherche existe bien. */
330
+ /** Label of a value; also used to validate that a searched value exists. */
334
331
  getOptionLabelUsing(resolver) { this._optionLabelUsing = resolver; return this; }
335
- /** Options fixes, ou fonction (éventuellement asynchrone) évaluée à chaque requête. */
336
- /** `dynamic: false` : options fournies par une fonction mais fixes pour la durée de la page (annoncées comme statiques au navigateur). */
337
332
  /**
338
- * `dynamic: false` : options fournies par une fonction mais fixes pour la durée de la page.
339
- * `searchOnServer: false` : la recherche se fait dans le navigateur, parmi les options déjà envoyées (cas d'une liste complète).
333
+ * Fixed options, or a (possibly async) function evaluated on every request.
334
+ * `dynamic: false`: options come from a function but stay fixed for the page's lifetime (announced as static to the browser).
335
+ * `searchOnServer: false`: search runs in the browser, among the options already sent (for a complete list).
340
336
  */
341
337
  options(options, config = {}) { this._options = options; this._dynamic = config.dynamic ?? null; this._searchOnServer = config.searchOnServer ?? null; return this; }
342
- /** Option non sélectionnable selon le contexte (par exemple déjà choisie dans une autre ligne). */
338
+ /** Disables an option depending on the context (e.g. already picked in another row). */
343
339
  disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
344
- /** L'état est envoyé au serveur dès qu'il change. */
340
+ /** Sends the state to the server as soon as it changes. */
345
341
  live(condition = true) { this._live = condition; return this; }
346
342
  placeholder(label) { this._placeholder = label; return this; }
347
343
  _selectablePlaceholder = true;
348
- /** `false` : le sélecteur natif n'a pas d'option vide (port de `selectablePlaceholder()`). */
344
+ /** `false`: the native select has no empty option. */
349
345
  selectablePlaceholder(condition = true) { this._selectablePlaceholder = condition; return this; }
350
346
  searchable(condition = true) { this._searchable = condition; return this; }
351
- /** Sélection multiple : l'état est une liste de valeurs ; le sélecteur JavaScript est toujours utilisé. */
347
+ /** Multiple selection: the state is a list of values; always uses the JavaScript select. */
352
348
  multiple(condition = true) { this._multiple = condition; if (condition)
353
349
  this._searchable = true; return this; }
354
350
  isMultiple() { return this._multiple; }
355
- /** Libellés de plusieurs valeurs d'un coup (sélection multiple avec recherche serveur) ; sert aussi à valider les valeurs. */
351
+ /** Labels of several values at once (multiple selection with server search); also used to validate the values. */
356
352
  getOptionLabelsUsing(resolver) { this._optionLabelsUsing = resolver; return this; }
357
353
  getDefaultState() { return this._default ?? (this._multiple ? [] : null); }
358
- /** Valeurs sélectionnées, nettoyées : des chaînes, sans doublon. */
354
+ /** Sanitized selected values: unique strings. */
359
355
  selectedValues() { const state = this.context().state; return [...new Set((Array.isArray(state) ? state : []).filter((v) => typeof v === 'string' || typeof v === 'number').map(String))]; }
360
356
  async labelsOf(values) {
361
357
  if (!values.length)
@@ -364,7 +360,7 @@ export class Select extends Field {
364
360
  const map = new Map((Array.isArray(known) ? known : Object.entries(known)).map(([value, label]) => [String(value), String(label)]));
365
361
  return values.filter((value) => map.has(value)).map((value) => [value, map.get(value)]);
366
362
  }
367
- /** Sans effet ici : les options sont toujours chargées avec le formulaire. */
363
+ /** No-op: options are always loaded with the form. */
368
364
  preload() { return this; }
369
365
  hydrateState(value) {
370
366
  if (this._multiple)
@@ -377,13 +373,13 @@ export class Select extends Field {
377
373
  return (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
378
374
  }
379
375
  async getValidationRules() {
380
- // Avec une recherche serveur, la valeur est valide si le serveur sait lui donner un libellé ; sinon elle doit figurer dans les options.
376
+ // With server search, a value is valid if the server can label it; otherwise it must be among the options.
381
377
  if (this._multiple)
382
378
  return [...(await super.getValidationRules()), { name: 'in', passes: async (v) => { const values = Array.isArray(v) ? v.map(String) : []; return (await this.labelsOf([...new Set(values)])).length === new Set(values).size; }, message: (a) => `The selected ${a} is invalid.` }];
383
379
  const passes = async (v) => (this._searchResultsUsing ? (await this._optionLabelUsing?.(String(v), this.context())) != null : (await this.resolveOptions()).some(([value]) => value === String(v)));
384
380
  return [...(await super.getValidationRules()), { name: 'in', passes, message: (a) => `The selected ${a} is invalid.` }];
385
381
  }
386
- // --- méthodes exposées au JavaScript du sélecteur (appelées par callSchemaComponentMethod)
382
+ // Methods exposed to the select's JavaScript (called through callSchemaComponentMethod).
387
383
  getExposedMethods() { return ['getOptionLabel', 'getSearchResultsForJs', 'getOptionsForJs', 'getOptionLabelsForJs']; }
388
384
  static forJs(options) { return options.map(([value, label]) => ({ label, value, isDisabled: false })); }
389
385
  async getOptionsForJs() { return this.withDisabled(Select.forJs(await this.resolveOptions())); }
@@ -410,7 +406,6 @@ export class Select extends Field {
410
406
  return (await this.resolveOptions()).find(([value]) => value === String(state))?.[1] ?? null;
411
407
  }
412
408
  async getOptionLabelsForJs() { return this._multiple ? (await this.labelsOf(this.selectedValues())).map(([value, label]) => ({ label, value })) : []; }
413
- // --- création et modification d'une option dans une modale (port de createOptionForm / editOptionForm)
414
409
  _createOptionForm = null;
415
410
  _createOptionUsing = null;
416
411
  _createOptionModalHeading = null;
@@ -421,27 +416,27 @@ export class Select extends Field {
421
416
  _editOptionModalHeading = null;
422
417
  _modifyEditOptionAction = null;
423
418
  /**
424
- * Formulaire de la modale « Create » ouverte par le bouton « + » du champ. Sa validation est faite côté serveur ;
425
- * `createOptionUsing()` enregistre l'option et rend sa valeur, qui devient l'état du champ (ajoutée à la liste pour un `multiple()`).
426
- * Les options doivent être fournies par une fonction (`options(() => …)`, ou une recherche serveur) pour que la nouvelle valeur y figure.
419
+ * Form of the "Create" modal opened by the field's "+" button, validated on the server.
420
+ * `createOptionUsing()` saves the option and returns its value, which becomes the field state (appended for `multiple()`).
421
+ * Options must come from a function (`options(() => …)`, or server search) for the new value to appear.
427
422
  */
428
423
  createOptionForm(schema) { this._createOptionForm = schema; return this; }
429
- /** Enregistre l'option à partir des données validées de la modale ; rend la valeur (clé) de l'option créée. */
424
+ /** Saves the option from the validated modal data; returns the value (key) of the created option. */
430
425
  createOptionUsing(callback) { this._createOptionUsing = callback; return this; }
431
426
  createOptionModalHeading(heading) { this._createOptionModalHeading = heading; return this; }
432
- /** Personnalise l'action de création (libellé, icône, largeur de modale…). */
427
+ /** Customizes the create action (label, icon, modal width…). */
433
428
  createOptionAction(modify) { this._modifyCreateOptionAction = modify; return this; }
434
429
  /**
435
- * Formulaire de la modale « Edit » de l'option sélectionnée (sélection simple seulement). Le champ devient `live()` pour que le bouton
436
- * apparaisse dès qu'une option est choisie. Préremplissage par `fillEditOptionActionFormUsing()`, enregistrement par `updateOptionUsing()`.
430
+ * Form of the "Edit" modal for the selected option (single selection only). The field becomes `live()` so the button
431
+ * appears as soon as an option is picked. Filled by `fillEditOptionActionFormUsing()`, saved by `updateOptionUsing()`.
437
432
  */
438
433
  editOptionForm(schema) { this._editOptionForm = schema; this._live = true; return this; }
439
434
  updateOptionUsing(callback) { this._updateOptionUsing = callback; return this; }
440
- /** Données initiales de la modale « Edit » (le contexte donne l'état courant du champ dans `state`). */
435
+ /** Initial data of the "Edit" modal (the context carries the current field state in `state`). */
441
436
  fillEditOptionActionFormUsing(callback) { this._fillEditOptionForm = callback; return this; }
442
437
  editOptionModalHeading(heading) { this._editOptionModalHeading = heading; return this; }
443
438
  editOptionAction(modify) { this._modifyEditOptionAction = modify; return this; }
444
- /** Même formulaire pour la création et la modification. */
439
+ /** Same form for creating and editing. */
445
440
  manageOptionForm(schema) { return this.createOptionForm(schema).editOptionForm(schema); }
446
441
  setState(value) { writePath(this.livewire, this.getStatePath().split('.'), value); }
447
442
  getCreateOptionAction() {
@@ -455,14 +450,14 @@ export class Select extends Field {
455
450
  .schema(this._createOptionForm)
456
451
  .action(async ({ data }) => {
457
452
  if (!this._createOptionUsing)
458
- throw new Error(`Select [${this.getStatePath()}] : createOptionUsing() est requis avec createOptionForm().`);
453
+ throw new Error(`Select [${this.getStatePath()}]: createOptionUsing() is required with createOptionForm().`);
459
454
  const created = await this._createOptionUsing(data, this.context());
460
455
  if (created === null || created === undefined || created === '')
461
456
  return;
462
457
  this.setState(this._multiple ? [...new Set([...this.selectedValues(), String(created)])] : String(created));
463
458
  await this.callAfterStateUpdated((name, value) => writePath(this.livewire, [...this.container.getStatePath().split('.').filter(Boolean), ...name.split('.')], value));
464
459
  });
465
- // Sélection multiple : « Create & create another » garde la modale ouverte sur un formulaire vide.
460
+ // Multiple selection: "Create & create another" keeps the modal open on an empty form.
466
461
  if (this._multiple)
467
462
  action.extraModalFooterActions([Action.make('createAnother').label(tr('modal.actions.create_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)]);
468
463
  return this._modifyCreateOptionAction?.(action) ?? action;
@@ -480,14 +475,14 @@ export class Select extends Field {
480
475
  .fillForm(async () => (await this._fillEditOptionForm?.(this.context())) ?? {})
481
476
  .action(async ({ data }) => {
482
477
  if (!this._updateOptionUsing)
483
- throw new Error(`Select [${this.getStatePath()}] : updateOptionUsing() est requis avec editOptionForm().`);
478
+ throw new Error(`Select [${this.getStatePath()}]: updateOptionUsing() is required with editOptionForm().`);
484
479
  await this._updateOptionUsing(data, this.context());
485
480
  });
486
481
  return this._modifyEditOptionAction?.(action) ?? action;
487
482
  }
488
- /** Actions montées sur le champ : indication, création et modification d'option. */
483
+ /** Actions mounted on the field: hint, create option and edit option. */
489
484
  getActions() { return [...this.getHintActions(), ...[this.getCreateOptionAction(), this.getEditOptionAction()].filter((action) => action !== null)]; }
490
- /** Boutons « + » et « modifier » à droite du champ (port des suffixActions de Select). */
485
+ /** "+" and "edit" buttons on the right of the field. */
491
486
  async suffixHtml() {
492
487
  const ctx = this.context();
493
488
  const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.getKey() };
@@ -514,17 +509,17 @@ export class Select extends Field {
514
509
  const options = [this._selectablePlaceholder ? `<option value="">${isDisabled ? '' : e(this._placeholder ?? t('filament-forms::components.select.placeholder'))}</option>` : '', ...resolved.map(([value, label]) => `<option value="${e(value)}">${e(label)}</option>`)].join('');
515
510
  return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><select ${select.toHtml()}>${options}</select></div>${suffix}</div>`, { wrapperClass: 'fi-fo-select-wrp' });
516
511
  }
517
- /** Port du rendu JavaScript de Select::toEmbeddedHtml (sélection simple). */
512
+ /** JavaScript select rendering. */
518
513
  async toJsHtml(options, isDisabled, suffix = '') {
519
514
  const state = this.context().state;
520
515
  const hasState = !this._multiple && state !== null && state !== undefined && state !== '';
521
516
  const selected = this._multiple ? await this.labelsOf(this.selectedValues()) : [];
522
517
  const isRequired = (await this.isRequired()) && !isDisabled;
523
- // Champ obligatoire : une consigne pour lecteurs d'écran (« Select an option. ») est liée au sélecteur.
518
+ // Required field: a screen-reader instruction ("Select an option.") is linked to the select.
524
519
  const describedBy = [isRequired ? `${this.getId()}-required` : null, this.hasError() ? `${this.getId()}-error` : null].filter(Boolean).join(' ') || null;
525
520
  const accessibility = JSON.stringify({ 'aria-describedby': describedBy, 'aria-invalid': this.hasError() ? 'true' : null, 'aria-required': isRequired ? 'true' : null });
526
521
  const forJs = this.withDisabled(Select.forJs(options));
527
- // Options « dynamiques » : fournies par une fonction, donc susceptibles de changer d'une requête à l'autre.
522
+ // Dynamic options come from a function, so they may change between requests.
528
523
  const hasDynamicOptions = this._dynamic ?? typeof this._options === 'function';
529
524
  const tr = (key, replace = {}) => t(`filament-forms::components.select.${key}`, replace);
530
525
  const config = `selectFormComponent({
@@ -580,7 +575,7 @@ export class Select extends Field {
580
575
  }"></div><div x-load x-load-src="${e(asset('select'))}" x-data="${e(config)}" wire:ignore wire:key="${e(key)}" x-on:set-select-property="$event.detail.isDisabled ? select.disable() : select.enable()" class="fi-select-input"><div x-ref="select"></div></div></div>${suffix}</div>`;
581
576
  }
582
577
  }
583
- /** Colonne d'un Repeater en table : intitulé et largeur. */
578
+ /** Column of a table Repeater: label and width. */
584
579
  export class TableColumn {
585
580
  label;
586
581
  _width = null;
@@ -592,9 +587,9 @@ export class TableColumn {
592
587
  const ITEM_KEY = /^[A-Za-z0-9_-]{1,64}$/;
593
588
  const isItemKey = (key) => ITEM_KEY.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
594
589
  /**
595
- * Liste d'éléments répétés, chacun avec les mêmes champs (port de Repeater, affichage en table).
596
- * L'état est une table `clé d'élément → champs` ; l'ordre des clés est l'ordre d'affichage.
597
- * Ajout, suppression et déplacement sont des actions du composant, montées avec `schemaComponent`.
590
+ * List of repeated items sharing the same fields, displayed as a table.
591
+ * The state is a map `item key → fields`; key order is display order.
592
+ * Add, delete and move are component actions, mounted with `schemaComponent`.
598
593
  */
599
594
  export class Repeater extends Field {
600
595
  _schema = () => [];
@@ -603,19 +598,18 @@ export class Repeater extends Field {
603
598
  _maxItems = 500;
604
599
  _itemLinks = [];
605
600
  static make(name) { return configured(new Repeater(name)); }
606
- /** Champs d'un élément : une fonction, appelée pour chaque élément. */
601
+ /** Item fields: a function called for each item. */
607
602
  schema(factory) { this._schema = factory; return this; }
608
- /** Affichage en table, une colonne par champ. */
603
+ /** Table display, one column per field. */
609
604
  table(columns) { this._columns = columns; return this; }
610
605
  defaultItems(count) { this._defaultItems = count; return this; }
611
606
  maxItems(count) { this._maxItems = count; return this; }
612
607
  _reorderable = true;
613
- /** Faux : les éléments gardent leur ordre (cas d'une relation sans colonne d'ordre dans Filament). */
608
+ /** `false`: items keep their order (e.g. a relationship without an order column). */
614
609
  reorderable(condition = true) { this._reorderable = condition; return this; }
615
- /** Lien affiché sur chaque ligne (équivalent d'une action d'élément à URL). */
610
+ /** Link shown on each row (an item action pointing to a URL). */
616
611
  extraItemLink(resolver) { this._itemLinks.push(resolver); return this; }
617
- // --- état
618
- /** Éléments bien formés : les clés viennent du navigateur, seules les clés simples sont retenues. */
612
+ /** Well-formed items only: keys come from the browser, so only simple keys are kept. */
619
613
  getItems() {
620
614
  const state = this.context().state;
621
615
  if (!state || typeof state !== 'object' || Array.isArray(state))
@@ -625,7 +619,7 @@ export class Repeater extends Field {
625
619
  setItems(items) { writePath(this.livewire, this.getStatePath().split('.'), items); }
626
620
  itemDefaults() { return Object.fromEntries(this._schema().map((field) => [field.getName(), field.getDefaultState()])); }
627
621
  getDefaultState() { return Object.fromEntries(Array.from({ length: this._defaultItems }, () => [randomUUID(), this.itemDefaults()])); }
628
- /** Enregistrements d'une relation → éléments `record-<id>`, réduits aux champs déclarés. */
622
+ /** Relationship records → `record-<id>` items, limited to the declared fields. */
629
623
  hydrateState(value) {
630
624
  if (!Array.isArray(value))
631
625
  return value && typeof value === 'object' ? value : {};
@@ -643,7 +637,6 @@ export class Repeater extends Field {
643
637
  rules.push({ name: 'required', passes: () => Object.keys(this.getItems()).length > 0, message: (a) => `The ${a} field is required.` });
644
638
  return [...rules, ...this._rules];
645
639
  }
646
- // --- actions du composant
647
640
  getActions() {
648
641
  const move = (offset) => ({ arguments: args }) => {
649
642
  const items = this.getItems();
@@ -663,7 +656,7 @@ export class Repeater extends Field {
663
656
  return; delete items[key]; this.setItems(items); }),
664
657
  Action.make('moveUp').action(move(-1)),
665
658
  Action.make('moveDown').action(move(1)),
666
- // Glisser-déposer : le navigateur envoie le nouvel ordre, accepté seulement si c'est une permutation des clés existantes.
659
+ // Drag and drop: the browser sends the new order, only accepted if it is a permutation of the existing keys.
667
660
  Action.make('reorder').action(({ arguments: args }) => {
668
661
  const items = this.getItems();
669
662
  const keys = Object.keys(items);
@@ -676,7 +669,7 @@ export class Repeater extends Field {
676
669
  }
677
670
  async toHtml() {
678
671
  if (!colorManager)
679
- throw new Error('Aucun gestionnaire de couleurs : appelez useColorManager().');
672
+ throw new Error('No color manager: call useColorManager().');
680
673
  const id = this.getId();
681
674
  const key = this.getKey();
682
675
  const livewireKey = this.getLivewireKey() ?? '';
@@ -692,7 +685,7 @@ export class Repeater extends Field {
692
685
  const itemLabel = tr('reordering.item');
693
686
  const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
694
687
  const rows = [];
695
- // Comme Filament : pas de colonne de réordonnancement tant qu'il n'y a qu'une ligne.
688
+ // No reorder column while there is only one row.
696
689
  const hasReorderColumn = this._reorderable && keys.length > 1;
697
690
  for (const [index, itemKey] of keys.entries()) {
698
691
  const moveButton = (action, label, iconName, direction, disabled) => {
@@ -720,8 +713,7 @@ export class Repeater extends Field {
720
713
  return this.wrap(table, { labelAsDiv: true });
721
714
  }
722
715
  }
723
- /** Zone de texte multiligne (port de Textarea, sans redimensionnement automatique). */
724
- /** Heure (port de TimePicker, saisie native) : l'état est `HH:MM` avec `seconds(false)`, `HH:MM:SS` sinon. */
716
+ /** Native time input: the state is `HH:MM` with `seconds(false)`, `HH:MM:SS` otherwise. */
725
717
  export class TimePicker extends Field {
726
718
  _seconds = true;
727
719
  static make(name) { return configured(new TimePicker(name)); }
@@ -741,7 +733,7 @@ export class TimePicker extends Field {
741
733
  return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
742
734
  }
743
735
  }
744
- /** Date et heure (port de DateTimePicker, saisie native `datetime-local`) : l'état enregistré est `AAAA-MM-JJ HH:MM:SS`. */
736
+ /** Native `datetime-local` input: the saved state is `YYYY-MM-DD HH:MM:SS`. */
745
737
  export class DateTimePicker extends Field {
746
738
  _seconds = true;
747
739
  static make(name) { return configured(new DateTimePicker(name)); }
@@ -766,7 +758,7 @@ export class DateTimePicker extends Field {
766
758
  return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
767
759
  }
768
760
  }
769
- /** Choix unique en boutons radio (port de Radio). */
761
+ /** Single choice as radio buttons. */
770
762
  export class Radio extends Field {
771
763
  _options = [];
772
764
  _descriptions = {};
@@ -774,9 +766,9 @@ export class Radio extends Field {
774
766
  _live = false;
775
767
  static make(name) { return configured(new Radio(name)); }
776
768
  options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
777
- /** Texte d'aide par valeur, sous le libellé de l'option. */
769
+ /** Helper text per value, under the option label. */
778
770
  descriptions(descriptions) { this._descriptions = descriptions; return this; }
779
- /** Options côte à côte. */
771
+ /** Options side by side. */
780
772
  inline(condition = true) { this._inline = condition; return this; }
781
773
  live(condition = true) { this._live = condition; return this; }
782
774
  async getValidationRules() {
@@ -807,7 +799,7 @@ export class Textarea extends Field {
807
799
  maxLength(length) { this._maxLength = length; return this.rule({ name: 'max', passes: (v) => String(v).length <= length, message: (a) => `The ${a} field must not be greater than ${length} characters.` }); }
808
800
  async toHtml() {
809
801
  const isDisabled = await this.isDisabled();
810
- // Hauteur initiale : 1,5rem par ligne plus la marge intérieure (comme Filament).
802
+ // Initial height: 1.5rem per row plus padding.
811
803
  const height = (this._rows ?? 2) * 1.5 + 0.75;
812
804
  const textarea = new Attributes().merge({
813
805
  'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(), maxlength: this._maxLength,
@@ -821,16 +813,16 @@ export class Textarea extends Field {
821
813
  })" x-model="state" ${textarea.toHtml()}></textarea></div></div></div>`, { wrapperClass: 'fi-fo-textarea-wrp' });
822
814
  }
823
815
  }
824
- /** Saisie d'une liste de mots-clés (port de TagsInput) : l'état est un tableau de chaînes. */
816
+ /** Tag list input: the state is an array of strings. */
825
817
  export class TagsInput extends Field {
826
818
  _placeholder = null;
827
819
  _suggestions = [];
828
820
  _splitKeys = [];
829
821
  static make(name) { return configured(new TagsInput(name)); }
830
822
  placeholder(text) { this._placeholder = text; return this; }
831
- /** Valeurs proposées à la saisie. */
823
+ /** Values suggested while typing. */
832
824
  suggestions(suggestions) { this._suggestions = suggestions.map(String).slice(0, 1000); return this; }
833
- /** Touches qui valident le mot en cours, en plus d'Entrée (`['Tab', ',']`). */
825
+ /** Keys that confirm the current tag, besides Enter (`['Tab', ',']`). */
834
826
  splitKeys(keys) { this._splitKeys = keys; return this; }
835
827
  getDefaultState() { return this._default ?? []; }
836
828
  hydrateState(value) {
@@ -844,11 +836,11 @@ export class TagsInput extends Field {
844
836
  }
845
837
  return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map(String) : [];
846
838
  }
847
- /** Seules des chaînes non vides sont enregistrées, quelle que soit la forme reçue du navigateur. */
839
+ /** Only non-empty strings are saved, whatever shape the browser sends. */
848
840
  dehydrateState(state) { return (Array.isArray(state) ? state : []).filter((tag) => typeof tag === 'string' || typeof tag === 'number').map((tag) => String(tag).trim()).filter((tag) => tag !== '').slice(0, 1000); }
849
841
  async toHtml() {
850
842
  if (!colorManager)
851
- throw new Error('Aucun gestionnaire de couleurs : appelez useColorManager().');
843
+ throw new Error('No color manager: call useColorManager().');
852
844
  const id = this.getId();
853
845
  const isDisabled = await this.isDisabled();
854
846
  const label = await this.getLabel();
@@ -868,14 +860,14 @@ export class TagsInput extends Field {
868
860
  })"><input ${input.toHtml()} /><div x-ref="liveRegion" aria-live="polite" class="fi-sr-only"></div><datalist id="${e(id)}-suggestions">${this._suggestions.map((s) => `<template x-bind:key="${e(js(s))}" x-if="! (state?.includes(${e(js(s))}) ?? true)"><option value="${e(s)}" /></template>`).join('')}</datalist><div wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.${createHash('md5').update(JSON.stringify(this.context().state ?? [])).digest('hex')}"><template x-cloak x-if="state?.length"><div class="fi-fo-tags-input-tags-ctn"><template x-for="(tag, index) in state" x-bind:key="\`\${tag}-\${index}\`"><span class="${badge}"><span class="fi-badge-label-ctn"><span class="fi-badge-label"><span x-text="tag"></span></span></span>${deleteButton}</span></template></div></template></div></div></div></div>`, { wrapperClass: 'fi-fo-tags-input-wrp' });
869
861
  }
870
862
  }
871
- /** Interrupteur (port de Toggle). */
863
+ /** On/off switch. */
872
864
  export class Toggle extends Field {
873
865
  static make(name) { return configured(new Toggle(name)); }
874
866
  getDefaultState() { return this._default ?? false; }
875
867
  hydrateState(value) { return value === true || value === 1 || value === '1'; }
876
868
  async toHtml() {
877
869
  if (!colorManager)
878
- throw new Error('Aucun gestionnaire de couleurs : appelez useColorManager().');
870
+ throw new Error('No color manager: call useColorManager().');
879
871
  const on = this.context().state === true;
880
872
  const colors = colorManager.getComponentClasses('toggle', 'primary').join(' ');
881
873
  const button = new Attributes().merge({ 'aria-describedby': this.getHelperTextId(), 'aria-checked': on ? 'true' : 'false', disabled: await this.isDisabled(), id: this.getId(), 'wire:target': this.getStatePath() }).class(['fi-fo-toggle', 'fi-toggle']);
@@ -883,7 +875,7 @@ export class Toggle extends Field {
883
875
  return this.wrap('', { labelPrefix: `<button x-data="{ state: $wire.$entangle(${e(js(this.getStatePath()))}, false) }" x-bind:aria-checked="state ? 'true' : 'false'" x-on:click="state = ! state" x-bind:class="state ? 'fi-toggle-on ${colors}' : 'fi-toggle-off'"${on ? ' x-cloak' : ''} role="switch" type="button" ${button.toHtml()}>${knob}</button>${on ? `<div x-cloak="inline-flex" wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.placeholder" class="fi-toggle fi-toggle-on fi-hidden ${colors}">${knob}</div>` : ''}` });
884
876
  }
885
877
  }
886
- /** Choix unique présenté en boutons (port de ToggleButtons, sélection simple). */
878
+ /** Single choice shown as buttons. */
887
879
  export class ToggleButtons extends Field {
888
880
  _options = [];
889
881
  _inline = false;
@@ -892,23 +884,23 @@ export class ToggleButtons extends Field {
892
884
  _icons = {};
893
885
  static make(name) { return configured(new ToggleButtons(name)); }
894
886
  _live = false;
895
- /** L'état est envoyé au serveur dès qu'il change. */
887
+ /** Sends the state to the server as soon as it changes. */
896
888
  live(condition = true) { this._live = condition; return this; }
897
- /** Couleur par valeur (ex. celle d'une énumération). */
889
+ /** Color per value (e.g. from an enum). */
898
890
  colors(colors) { this._colors = colors; return this; }
899
- /** Icône par valeur. */
891
+ /** Icon per value. */
900
892
  icons(icons) { this._icons = icons; return this; }
901
893
  options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
902
- /** Boutons côte à côte. */
894
+ /** Buttons side by side. */
903
895
  inline(condition = true) { this._inline = condition; return this; }
904
- /** Boutons accolés en un seul groupe. */
896
+ /** Buttons joined into a single group. */
905
897
  grouped(condition = true) { this._grouped = condition; return this; }
906
898
  async getValidationRules() {
907
899
  return [...(await super.getValidationRules()), { name: 'in', passes: (v) => this._options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
908
900
  }
909
901
  async toHtml() {
910
902
  if (!colorManager)
911
- throw new Error('Aucun gestionnaire de couleurs : appelez useColorManager().');
903
+ throw new Error('No color manager: call useColorManager().');
912
904
  const id = this.getId();
913
905
  const isDisabled = await this.isDisabled();
914
906
  const isRequired = (await this.isRequired()) && !isDisabled;
@@ -927,17 +919,17 @@ export class ToggleButtons extends Field {
927
919
  return this.wrap(`<div ${container.toHtml()}>${items.join('')}</div>`, { wrapperClass: 'fi-fo-toggle-buttons-wrp', labelAsDiv: true, focusable: true, after });
928
920
  }
929
921
  }
930
- // Schéma TipTap du serveur : les nœuds et marques que l'éditeur de Filament sait produire avec sa barre d'outils par défaut.
931
- // Hors périmètre pour l'instant : grilles, blocs personnalisés, mentions, balises de fusion, couleur de texte, « lead » et « small ».
922
+ // Server-side TipTap schema: the nodes and marks the rich editor's default toolbar can produce.
923
+ // Not supported yet: grids, custom blocks, mentions, merge tags, text color, "lead" and "small".
932
924
  const RICH_EXTENSIONS = [StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'start' }), Subscript, Superscript, Highlight, Image, TableKit];
933
925
  const isRichDocument = (value) => !!value && typeof value === 'object' && value.type === 'doc';
934
926
  const richText = (node) => (typeof node?.text === 'string' ? node.text : '') + (Array.isArray(node?.content) ? node.content.map(richText).join('') : '');
935
927
  const hasRichContent = (node) => node?.type === 'image' || node?.type === 'horizontalRule' || (Array.isArray(node?.content) && node.content.some(hasRichContent));
936
- /** HTML enregistré → document TipTap. L'analyse écarte ce que le schéma ne connaît pas (scripts, attributs d'événement, liens `javascript:`). */
928
+ /** Stored HTML → TipTap document. Parsing drops anything the schema does not know (scripts, event attributes, `javascript:` links). */
937
929
  export function richHtmlToDocument(html) { return generateJSON(html ?? '', RICH_EXTENSIONS); }
938
930
  /**
939
- * Document TipTap → HTML à enregistrer, ou null s'il est vide ou invalide. Le document vient du navigateur :
940
- * il est rendu puis ré-analysé, pour que seul ce que le schéma accepte soit conservé.
931
+ * TipTap document → HTML to save, or null if empty or invalid. The document comes from the browser,
932
+ * so it is rendered then re-parsed to keep only what the schema accepts.
941
933
  */
942
934
  export function richDocumentToHtml(document) {
943
935
  if (!isRichDocument(document))
@@ -951,8 +943,8 @@ export function richDocumentToHtml(document) {
951
943
  }
952
944
  }
953
945
  /**
954
- * Éditeur de texte riche (port de RichEditor) : le navigateur travaille sur un document TipTap, la base reçoit du HTML.
955
- * Le balisage est celui de Filament, repris de l'oracle (vue générée). Non portés : pièces jointes, blocs personnalisés, mentions.
946
+ * Rich text editor: the browser works on a TipTap document, the database receives HTML.
947
+ * Not supported yet: attachments, custom blocks, mentions.
956
948
  */
957
949
  export class RichEditor extends Field {
958
950
  static make(name) { return configured(new RichEditor(name)); }
@@ -971,7 +963,7 @@ export class RichEditor extends Field {
971
963
  getCustomBlockPreviewsForJs() { return []; }
972
964
  getUploadedFileAttachmentTemporaryUrl() { return null; }
973
965
  saveUploadedFileAttachmentAndGetUrl() { return null; }
974
- /** Action « lien » de la barre d'outils : une modale (URL, nouvel onglet), puis des commandes renvoyées à l'éditeur du navigateur. */
966
+ /** Toolbar "link" action: a modal (URL, new tab), then commands sent back to the browser editor. */
975
967
  getActions() {
976
968
  const tr = (name) => t(`filament-forms::components.rich_editor.actions.link.${name}`);
977
969
  const key = this.getKey();
@@ -981,7 +973,7 @@ export class RichEditor extends Field {
981
973
  .fillForm(({ arguments: args }) => ({ url: typeof args.url === 'string' ? args.url : null, shouldOpenInNewTab: args.shouldOpenInNewTab === true }))
982
974
  .schema(() => [
983
975
  TextInput.make('url').label(tr('modal.form.url.label'))
984
- // Seules des adresses sans code exécutable sont acceptées.
976
+ // Only URLs that cannot execute code are accepted.
985
977
  .rule({ name: 'url', passes: (value) => /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(String(value)), message: (a) => `The ${a} field must be a valid URL.` }),
986
978
  Checkbox.make('shouldOpenInNewTab').label(tr('modal.form.should_open_in_new_tab.label')),
987
979
  ])
@@ -1001,10 +993,10 @@ export class RichEditor extends Field {
1001
993
  return this.wrap(views.render('filament-forms::components.rich-editor', { livewireId: this.livewire.id, key: this.getKey(), statePath: this.getStatePath(), label: await this.getLabel(), js }), { labelAsDiv: true });
1002
994
  }
1003
995
  }
1004
- /** Sélecteur de couleur (port de ColorPicker, format hexadécimal). */
996
+ /** Color picker (hex format). */
1005
997
  export class ColorPicker extends Field {
1006
998
  static make(name) { return configured(new ColorPicker(name)); }
1007
- /** Format hexadécimal (le seul pris en charge ; présent pour la compatibilité d'API). */
999
+ /** Hex format: the only one supported, kept for API compatibility. */
1008
1000
  hex() { return this; }
1009
1001
  constructor(name) {
1010
1002
  super(name);
@@ -1015,7 +1007,7 @@ export class ColorPicker extends Field {
1015
1007
  const state = this.context().state;
1016
1008
  const id = this.getId();
1017
1009
  const key = this.getLivewireKey() ?? '';
1018
- // Même empreinte que Filament : md5(serialize([$isDisabled])).
1010
+ // Key hash over the serialized `[isDisabled]` options.
1019
1011
  const hash = createHash('md5').update(`a:1:{i:0;b:${isDisabled ? 1 : 0};}`).digest('hex');
1020
1012
  const input = new Attributes().merge({
1021
1013
  'aria-controls': `${id}-panel`, 'aria-expanded': 'false', 'aria-haspopup': 'dialog', 'aria-invalid': this.hasError() ? 'true' : null, autocomplete: 'off',
@@ -1045,13 +1037,13 @@ export class Checkbox extends Field {
1045
1037
  return this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` });
1046
1038
  }
1047
1039
  }
1048
- /** Plusieurs cases à cocher pour une liste de valeurs (port de CheckboxList, sans recherche ni « tout cocher »). */
1040
+ /** Checkboxes for a list of values (no search or "select all" yet). */
1049
1041
  export class CheckboxList extends Field {
1050
1042
  _options = [];
1051
1043
  _optionColumns = null;
1052
1044
  static make(name) { return configured(new CheckboxList(name)); }
1053
1045
  options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
1054
- /** Nombre de colonnes sur lesquelles les options se répartissent (à partir de `lg`). */
1046
+ /** Number of columns the options are spread over (from `lg`). */
1055
1047
  columns(columns) { this._optionColumns = columns; return this; }
1056
1048
  getDefaultState() { return this._default ?? []; }
1057
1049
  hydrateState(value) {
@@ -1066,7 +1058,7 @@ export class CheckboxList extends Field {
1066
1058
  }
1067
1059
  return (Array.isArray(list) ? list : []).map(String);
1068
1060
  }
1069
- /** Seules les valeurs proposées sont retenues, sans doublon, dans l'ordre des options. */
1061
+ /** Only offered values are kept, without duplicates, in option order. */
1070
1062
  dehydrateState(state) {
1071
1063
  const chosen = new Set((Array.isArray(state) ? state : []).map(String));
1072
1064
  return this._options.map(([value]) => value).filter((value) => chosen.has(value));
@@ -1089,7 +1081,7 @@ export class CheckboxList extends Field {
1089
1081
  })" aria-labelledby="${e(this.getId())}-label" class="fi-fo-checkbox-list"><div wire:key="${e(key)}.options" ${grid}>${options.join('')}</div></div>`, { labelAsDiv: true });
1090
1082
  }
1091
1083
  }
1092
- /** Paires clé / valeur libres, enregistrées comme un objet (port de KeyValue). */
1084
+ /** Free-form key/value pairs, saved as an object. */
1093
1085
  export class KeyValue extends Field {
1094
1086
  _keyLabel = null;
1095
1087
  _valueLabel = null;
@@ -1098,8 +1090,8 @@ export class KeyValue extends Field {
1098
1090
  keyLabel(label) { this._keyLabel = label; return this; }
1099
1091
  valueLabel(label) { this._valueLabel = label; return this; }
1100
1092
  reorderable(condition = true) { this._reorderable = condition; return this; }
1101
- // Dans le formulaire, l'état est une liste de paires `{ key, value }` (ce qu'attend le composant du navigateur, et qui garde l'ordre) ;
1102
- // à l'enregistrement il redevient un objet.
1093
+ // In the form, the state is a list of `{ key, value }` pairs (what the browser component expects, and it keeps order);
1094
+ // it becomes an object again when saved.
1103
1095
  getDefaultState() { return KeyValue.toPairs(this._default); }
1104
1096
  static toPairs(value) {
1105
1097
  let object = value;
@@ -1114,7 +1106,7 @@ export class KeyValue extends Field {
1114
1106
  return object && typeof object === 'object' && !Array.isArray(object) ? Object.entries(object).map(([key, v]) => ({ key, value: v })) : [];
1115
1107
  }
1116
1108
  hydrateState(value) { return KeyValue.toPairs(value); }
1117
- /** Objet plat de chaînes : clés vides, objets imbriqués et clés dangereuses sont écartés ; 200 paires au plus. */
1109
+ /** Flat object of strings: empty keys, nested objects and dangerous keys are dropped; 200 pairs at most. */
1118
1110
  dehydrateState(state) {
1119
1111
  const out = {};
1120
1112
  if (!Array.isArray(state))
@@ -1145,27 +1137,27 @@ export class KeyValue extends Field {
1145
1137
  })" wire:ignore wire:key="${e(key)}" class="fi-fo-key-value-table-ctn"><table aria-labelledby="${e(id)}-label" id="${e(id)}" class="fi-fo-key-value-table"><thead><tr>${this._reorderable ? `<th scope="col" x-show="rows.length" class="fi-has-action"><span class="fi-sr-only">${e(tr('columns.reorder.label'))}</span></th>` : ''}<th scope="col">${e(keyLabel)}</th><th scope="col">${e(valueLabel)}</th><th scope="col" x-show="rows.length" class="fi-has-action"><span class="fi-sr-only">${e(tr('columns.actions.label'))}</span></th></tr></thead><tbody${this._reorderable ? ' x-on:end.stop="reorderRows($event)" x-sortable data-sortable-animation-duration="300"' : ''}><template x-bind:key="row.id" x-for="(row, index) in rows"><tr${this._reorderable ? ' x-bind:x-sortable-item="row.id"' : ''}>${reorderCell}<td><input aria-label="${e(keyLabel)}" placeholder="" type="text" x-model="row.key" x-on:input.debounce.500ms="updateState" class="fi-input" /></td><td><input aria-label="${e(valueLabel)}" placeholder="" type="text" x-model="row.value" x-on:input.debounce.500ms="updateState" class="fi-input" /></td><td class="fi-has-action"><div x-on:click="deleteRow(index)"><button title="${e(tr('actions.delete.label'))}" aria-label="${e(tr('actions.delete.label'))}" ${iconButton} class="${dangerClasses}">${icon('m-trash')}</button></div></td></tr></template></tbody></table><span x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span><div x-on:click="addRow" class="fi-fo-key-value-add-action-ctn"><button ${iconButton} class="fi-ac-link-action fi-link fi-size-md"><span class="fi-link-label">${e(tr('actions.add.label'))}</span></button></div></div></div></div>`, { labelAsDiv: true, wrapperClass: 'fi-fo-key-value-wrp' });
1146
1138
  }
1147
1139
  }
1148
- /** Expression JavaScript écrite par le développeur (formateur d'infobulle d'un Slider…), insérée telle quelle : jamais de donnée utilisateur. */
1140
+ /** Developer-written JavaScript expression (e.g. a Slider tooltip formatter), inserted as is: never put user data in it. */
1149
1141
  export class RawJs {
1150
1142
  js;
1151
1143
  constructor(js) { this.js = js; }
1152
1144
  static make(js) { return new RawJs(js); }
1153
1145
  toString() { return this.js; }
1154
1146
  }
1155
- /** Longueur CSS simple (`10rem`, `300px`, `50%`) ; toute autre valeur est refusée (elle finirait dans un attribut `style`). */
1147
+ /** Simple CSS length (`10rem`, `300px`, `50%`); anything else is rejected, since it ends up in a `style` attribute. */
1156
1148
  const cssLength = (value) => (value !== null && /^\d+(\.\d+)?(px|rem|em|%|vh|vw)$/.test(value.trim()) ? value.trim() : null);
1157
- /** Attributs d'accessibilité d'un champ (port de Field::getAccessibilityAttributes) : texte d'aide et message d'erreur. */
1149
+ /** Field accessibility attributes: helper text and error message. */
1158
1150
  function accessibilityOf(field, options = {}) {
1159
1151
  const ids = [field.getHelperTextId(), field.hasError() ? `${field.getId()}-error` : null].filter(Boolean);
1160
1152
  return { 'aria-describedby': ids.length ? ids.join(' ') : null, 'aria-invalid': options.withInvalid !== false && field.hasError() ? 'true' : null };
1161
1153
  }
1162
- /** Second argument de `$entangle()` : vrai seulement pour un champ `live()` simple (ni `onBlur`, ni `debounce`), comme Filament. */
1154
+ /** Second `$entangle()` argument: true only for a plain `live()` field (no `onBlur`, no `debounce`). */
1163
1155
  const entangleLive = (live) => (live !== null && !live.onBlur && live.debounce === null ? 'true' : 'false');
1164
1156
  const MARKDOWN_TOOLBAR = [['bold', 'italic', 'strike', 'link'], ['heading'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table'], ['undo', 'redo']];
1165
1157
  /**
1166
- * Éditeur Markdown (port de MarkdownEditor, éditeur EasyMDE de Filament). L'état est le texte Markdown brut, à nettoyer avant tout
1167
- * affichage en HTML. Non portés : pièces jointes (le bouton `attachFiles` est absent par défaut) ; en lecture seule (`disabled()`),
1168
- * le texte est affiché échappé, sans conversion Markdown.
1158
+ * Markdown editor (EasyMDE). The state is raw Markdown text, to sanitize before any HTML display.
1159
+ * Attachments are not supported (no `attachFiles` button by default); when read-only (`disabled()`),
1160
+ * the text is shown escaped, without Markdown conversion.
1169
1161
  */
1170
1162
  export class MarkdownEditor extends Field {
1171
1163
  _toolbarButtons = MARKDOWN_TOOLBAR;
@@ -1174,24 +1166,24 @@ export class MarkdownEditor extends Field {
1174
1166
  _placeholder = null;
1175
1167
  _live = null;
1176
1168
  static make(name) { return configured(new MarkdownEditor(name)); }
1177
- /** Boutons de la barre d'outils, en groupes (`[['bold', 'italic'], ['undo', 'redo']]`) ou à plat. */
1169
+ /** Toolbar buttons, grouped (`[['bold', 'italic'], ['undo', 'redo']]`) or flat. */
1178
1170
  toolbarButtons(buttons) { this._toolbarButtons = buttons; return this; }
1179
- /** Retire des boutons ; sans argument, les retire tous. */
1171
+ /** Removes buttons; with no argument, removes them all. */
1180
1172
  disableToolbarButtons(names = []) {
1181
1173
  this._toolbarButtons = names.length ? this._toolbarButtons.map((group) => (Array.isArray(group) ? group.filter((name) => !names.includes(name)) : group)).filter((group) => (Array.isArray(group) ? group.length > 0 : !names.includes(group))) : [];
1182
1174
  return this;
1183
1175
  }
1184
1176
  disableAllToolbarButtons(condition = true) { if (condition)
1185
1177
  this._toolbarButtons = []; return this; }
1186
- /** Ajoute des boutons (dans un groupe à part). */
1178
+ /** Adds buttons (in a separate group). */
1187
1179
  enableToolbarButtons(names) { this._toolbarButtons = [...this._toolbarButtons, names]; return this; }
1188
1180
  getToolbarButtons() { return this._toolbarButtons; }
1189
1181
  hasToolbarButton(name) { return this._toolbarButtons.some((group) => (Array.isArray(group) ? group.includes(name) : group === name)); }
1190
- /** Hauteur minimale de la zone d'édition (`10rem` par défaut). */
1182
+ /** Minimum height of the editing area (`10rem` by default). */
1191
1183
  minHeight(height) { this._minHeight = cssLength(height); return this; }
1192
1184
  maxHeight(height) { this._maxHeight = cssLength(height); return this; }
1193
1185
  placeholder(text) { this._placeholder = text; return this; }
1194
- /** L'état est envoyé au serveur au fil de la frappe (`debounce` en millisecondes), ou à la perte de focus avec `onBlur`. */
1186
+ /** Sends the state to the server while typing (`debounce` in milliseconds), or on blur with `onBlur`. */
1195
1187
  live(options = {}) { this._live = { onBlur: options.onBlur === true, debounce: options.debounce ?? null }; return this; }
1196
1188
  maxLength(length) { return this.rule({ name: 'max', passes: (v) => String(v).length <= length, message: (a) => `The ${a} field must not be greater than ${length} characters.` }); }
1197
1189
  minLength(length) { return this.rule({ name: 'min', passes: (v) => String(v).length >= length, message: (a) => `The ${a} field must be at least ${length} characters.` }); }
@@ -1242,23 +1234,23 @@ export class MarkdownEditor extends Field {
1242
1234
  }
1243
1235
  }
1244
1236
  const CODE_LANGUAGES = ['cpp', 'css', 'go', 'html', 'java', 'javascript', 'json', 'markdown', 'php', 'python', 'sql', 'xml', 'yaml'];
1245
- /** Éditeur de code (port de CodeEditor, CodeMirror de Filament) : coloration selon `language()`, retour à la ligne avec `wrap()`. */
1237
+ /** Code editor (CodeMirror): highlighting from `language()`, line wrapping with `wrap()`. */
1246
1238
  export class CodeEditor extends Field {
1247
1239
  _language = null;
1248
1240
  _canWrap = false;
1249
1241
  _live = null;
1250
1242
  static make(name) { return configured(new CodeEditor(name)); }
1251
- /** Langage : `cpp`, `css`, `go`, `html`, `java`, `javascript`, `json`, `markdown`, `php`, `python`, `sql`, `xml`, `yaml`. */
1243
+ /** Language: `cpp`, `css`, `go`, `html`, `java`, `javascript`, `json`, `markdown`, `php`, `python`, `sql`, `xml`, `yaml`. */
1252
1244
  language(language) {
1253
1245
  if (language !== null && !CODE_LANGUAGES.includes(language))
1254
- throw new Error(`CodeEditor : langage inconnu « ${language} ».`);
1246
+ throw new Error(`CodeEditor: unknown language "${language}".`);
1255
1247
  this._language = language;
1256
1248
  return this;
1257
1249
  }
1258
1250
  getLanguage() { return this._language; }
1259
1251
  /**
1260
- * Retour à la ligne des lignes longues (port de `wrap()`). Appelée avec du HTML, c'est l'enveloppe de champ héritée,
1261
- * que le rendu du composant utilise.
1252
+ * Wraps long lines. Called with an HTML string, it is the inherited field wrapper,
1253
+ * which the component's rendering uses.
1262
1254
  */
1263
1255
  wrap(condition, options) {
1264
1256
  if (typeof condition === 'string')
@@ -1286,15 +1278,15 @@ export class CodeEditor extends Field {
1286
1278
  language: ${js(this._language)},
1287
1279
  state: $wire.$entangle(${js(this.getStatePath())}, ${entangleLive(this._live)}),
1288
1280
  })`;
1289
- // Même empreinte que Filament : md5(serialize([$isDisabled, $language])).
1281
+ // Key hash over the serialized `[isDisabled, language]` options.
1290
1282
  const hash = createHash('md5').update(`a:2:{i:0;b:${isDisabled ? 1 : 0};i:1;${this._language === null ? 'N;' : `s:${this._language.length}:"${this._language}";`}}`).digest('hex');
1291
1283
  const wrapper = new Attributes().class(['fi-fo-code-editor', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
1292
1284
  return super.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div hidden data-field-accessibility ${new Attributes().merge(accessibilityOf(this)).toHtml()}></div><div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" x-load x-load-src="${e(asset('code-editor'))}" x-data="${e(config)}" wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.${hash}"><div x-ref="editor" x-cloak></div></div></div></div>`, { labelAsDiv: true });
1293
1285
  }
1294
1286
  }
1295
1287
  /**
1296
- * Curseur (port de Slider, noUiSlider de Filament) : une valeur, ou plusieurs poignées quand l'état est une liste (`default([20, 70])`).
1297
- * Obligatoire par défaut, comme dans Filament ; les bornes, le pas et le type numérique sont vérifiés côté serveur.
1288
+ * Slider (noUiSlider): one value, or several handles when the state is a list (`default([20, 70])`).
1289
+ * Required by default; bounds, step and numeric type are checked on the server.
1298
1290
  */
1299
1291
  export class Slider extends Field {
1300
1292
  _minValue = 0;
@@ -1322,35 +1314,35 @@ export class Slider extends Field {
1322
1314
  range(minValue, maxValue) { this._minValue = minValue; this._maxValue = maxValue; return this; }
1323
1315
  minValue(value) { this._minValue = value; return this; }
1324
1316
  maxValue(value) { this._maxValue = value; return this; }
1325
- /** Pas entre deux valeurs ; `1` impose un entier, sinon la valeur doit être un multiple du pas. */
1317
+ /** Step between values; `1` requires an integer, otherwise the value must be a multiple of the step. */
1326
1318
  step(step) { this._step = step; return this; }
1327
- /** Écart minimal / maximal entre deux poignées. */
1319
+ /** Minimum / maximum gap between two handles. */
1328
1320
  minDifference(difference) { this._minDifference = difference; return this; }
1329
1321
  maxDifference(difference) { this._maxDifference = difference; return this; }
1330
- /** Marge interdite aux extrémités : un nombre, ou `[début, fin]`. */
1322
+ /** Forbidden margin at the ends: a number, or `[start, end]`. */
1331
1323
  rangePadding(padding) { this._rangePadding = padding; return this; }
1332
- /** Segments colorés de la piste, un booléen par segment (`[true, false]` par défaut). */
1324
+ /** Colored track segments, one boolean per segment (`[true, false]` by default). */
1333
1325
  fillTrack(fill = [true, false]) { this._fillTrack = fill; return this; }
1334
1326
  vertical(condition = true) { this._vertical = condition; return this; }
1335
- /** De droite à gauche (de haut en bas pour un curseur vertical, ce qui est le réglage par défaut en vertical). */
1327
+ /** Right to left (top to bottom for a vertical slider, the default when vertical). */
1336
1328
  rtl(condition = true) { this._rtl = condition; return this; }
1337
- /** Comportement de la poignée (`tap`, `drag`, `fixed`, `snap`, `unconstrained`…) ; `null` : aucun. */
1329
+ /** Handle behavior (`tap`, `drag`, `fixed`, `snap`, `unconstrained`…); `null`: none. */
1338
1330
  behavior(behavior = null) { this._behavior = behavior; return this; }
1339
- /** Infobulles sur les poignées : booléen, un booléen par poignée, ou `RawJs` qui formate `$value`. */
1331
+ /** Handle tooltips: a boolean, one boolean per handle, or a `RawJs` formatting `$value`. */
1340
1332
  tooltips(tooltips = true) { this._tooltips = tooltips; return this; }
1341
- /** Graduations : `range` (par défaut), `steps`, `positions`, `count`, `values`. */
1333
+ /** Pips: `range` (default), `steps`, `positions`, `count`, `values`. */
1342
1334
  pips(mode = 'range', density = null) { this._pipsMode = mode; this._pipsDensity = density; return this; }
1343
1335
  pipsMode(mode) { this._pipsMode = mode; return this; }
1344
1336
  pipsDensity(density) { this._pipsDensity = density; return this; }
1345
- /** Formatage des graduations (`RawJs` qui reçoit `$value`). */
1337
+ /** Pip formatter (a `RawJs` receiving `$value`). */
1346
1338
  pipsFormatter(formatter) { this._pipsFormatter = formatter; return this; }
1347
1339
  pipsValues(values) { this._pipsValues = values; return this; }
1348
1340
  steppedPips(condition = true) { this._steppedPips = condition; return this; }
1349
- /** Filtre des graduations (`RawJs` qui reçoit `$value`). */
1341
+ /** Pip filter (a `RawJs` receiving `$value`). */
1350
1342
  pipsFilter(filter) { this._pipsFilter = filter; return this; }
1351
- /** Échelle non linéaire (`{ '10%': 500, '50%': 4000 }`). */
1343
+ /** Non-linear scale (`{ '10%': 500, '50%': 4000 }`). */
1352
1344
  nonLinearPoints(points) { this._nonLinearPoints = points; return this; }
1353
- /** Nombre de décimales de la valeur enregistrée. */
1345
+ /** Decimal places of the saved value. */
1354
1346
  decimalPlaces(places) { this._decimalPlaces = places; return this; }
1355
1347
  live(options = {}) { this._live = { onBlur: options.onBlur === true, debounce: options.debounce ?? null }; return this; }
1356
1348
  getMinValueWithPadding() { const padding = this._rangePadding; return this._minValue + (typeof padding === 'number' ? padding : Array.isArray(padding) ? (padding[0] ?? 0) : 0); }
@@ -1378,7 +1370,7 @@ export class Slider extends Field {
1378
1370
  { name: step === 1 ? 'integer' : 'multiple_of', passes: each(onStep), message: (a) => (step === 1 ? `The ${a} field must be an integer.` : `The ${a} field must be a multiple of ${step}.`) },
1379
1371
  ];
1380
1372
  }
1381
- /** Port de convertRawJsExpressionsToFormatterObjects : un `RawJs` devient `{ to: ($value) => … }`. */
1373
+ /** A `RawJs` becomes a `{ to: ($value) => … }` formatter object. */
1382
1374
  static formatters(value) {
1383
1375
  if (value instanceof RawJs)
1384
1376
  return `{ to: ($value) => ${value.js} }`;
@@ -1417,12 +1409,12 @@ export class Slider extends Field {
1417
1409
  const accessibility = accessibilityOf(this, { withInvalid: !this.isMultiple() });
1418
1410
  const attributes = new Attributes().merge({ 'aria-labelledby': `${id}-label`, id, role: 'group', ...this._extraAttributes })
1419
1411
  .class(['fi-fo-slider', { 'fi-fo-slider-has-pips': this._pipsMode !== null, 'fi-fo-slider-has-tooltips': hasTooltips, 'fi-fo-slider-vertical': this._vertical }]);
1420
- // Même empreinte que Filament : md5(serialize([$isDisabled])).
1412
+ // Key hash over the serialized `[isDisabled]` options.
1421
1413
  const hash = createHash('md5').update(`a:1:{i:0;b:${isDisabled ? 1 : 0};}`).digest('hex');
1422
1414
  return this.wrap(`<div hidden data-field-accessibility ${new Attributes().merge(accessibility).toHtml()}></div><div x-load x-load-src="${e(asset('slider'))}" x-data="${e(config)}" wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.${hash}" ${attributes.toHtml()}></div>`, { labelAsDiv: true });
1423
1415
  }
1424
1416
  }
1425
- /** Champ caché (port de Hidden) : un `<input type="hidden">` lié à l'état, sans libellé ni place dans la grille. */
1417
+ /** Hidden field: an `<input type="hidden">` bound to the state, with no label or grid slot. */
1426
1418
  export class Hidden extends Field {
1427
1419
  constructor(name) { super(name); this._columnSpan = { default: 'hidden' }; }
1428
1420
  static make(name) { return configured(new Hidden(name)); }
@@ -1431,22 +1423,22 @@ export class Hidden extends Field {
1431
1423
  return `<input ${attributes.toHtml()} />`;
1432
1424
  }
1433
1425
  }
1434
- /** Code à usage unique (port de OneTimeCodeInput) : une case par chiffre ; l'état est la chaîne de chiffres, de longueur `length()` exactement. */
1426
+ /** One-time code: one box per digit; the state is a digit string of exactly `length()` characters. */
1435
1427
  export class OneTimeCodeInput extends Field {
1436
1428
  _length = 6;
1437
1429
  _submitOnCompletion = false;
1438
1430
  _readOnly = false;
1439
1431
  constructor(name) {
1440
1432
  super(name);
1441
- // Comme Filament : `numeric` (et non `integer`, qui refuserait les zéros de tête), puis `digits:<longueur>`.
1433
+ // `numeric` rather than `integer`, which would reject leading zeros; then `digits:<length>`.
1442
1434
  this.rule({ name: 'numeric', passes: (v) => /^\d+$/.test(String(v)), message: (a) => `The ${a} field must be a number.` });
1443
1435
  this.rule({ name: 'digits', passes: (v) => new RegExp(`^\\d{${this._length}}$`).test(String(v)), message: (a) => `The ${a} field must be ${this._length} digits.` });
1444
1436
  }
1445
1437
  static make(name) { return configured(new OneTimeCodeInput(name)); }
1446
- /** Nombre de chiffres (6 par défaut). */
1438
+ /** Number of digits (6 by default). */
1447
1439
  length(length) { this._length = Math.max(1, Math.min(32, Math.floor(length))); return this; }
1448
1440
  getLength() { return this._length; }
1449
- /** Soumet le formulaire dès que le dernier chiffre est saisi. */
1441
+ /** Submits the form as soon as the last digit is typed. */
1450
1442
  submitOnCompletion(condition = true) { this._submitOnCompletion = condition; return this; }
1451
1443
  readOnly(condition = true) { this._readOnly = condition; return this; }
1452
1444
  hydrateState(value) { return value === null || value === undefined ? null : String(value); }
@@ -1464,8 +1456,7 @@ export class OneTimeCodeInput extends Field {
1464
1456
  }
1465
1457
  }
1466
1458
  /**
1467
- * Texte en lecture seule dans un formulaire (port de Placeholder, déprécié dans Filament au profit de TextEntry avec `state()`).
1468
- * Rien n'est enregistré. Une `HtmlString` est insérée telle quelle.
1459
+ * Read-only text in a form. Nothing is saved. An `HtmlString` is inserted as is.
1469
1460
  */
1470
1461
  export class Placeholder extends Component {
1471
1462
  _label = null;
@@ -1486,7 +1477,7 @@ export class Placeholder extends Component {
1486
1477
  return `<div class="fi-in-entry">${labelCol}<div class="fi-in-entry-content-col"><div class="fi-in-entry-content-ctn" role="definition"><div class="fi-in-entry-content"><div class="fi-in-text-item fi-size-sm fi-in-text fi-wrapped">${html}</div></div></div></div></div>`;
1487
1478
  }
1488
1479
  }
1489
- // --- état imbriqué : un champ placé dans un groupe à état (ex. `address.city`) se lit et s'écrit à son chemin relatif au schéma
1480
+ // Nested state: a field inside a stateful group (e.g. `address.city`) is read and written at its path relative to the schema.
1490
1481
  function relativePath(schema, field) {
1491
1482
  const base = schema.getStatePath();
1492
1483
  const full = field.getStatePath();
@@ -1502,13 +1493,13 @@ function writePath(target, segments, value) {
1502
1493
  }
1503
1494
  node[segments[segments.length - 1]] = value;
1504
1495
  }
1505
- /** Remplit l'état par défaut de tous les champs d'un schéma (équivalent de `$this->form->fill()`). */
1496
+ /** Fills the default state of every field in a schema. */
1506
1497
  export function fillSchema(schema, target) {
1507
1498
  for (const c of schema.getFlatComponents())
1508
1499
  if (c instanceof Field)
1509
1500
  writePath(target, relativePath(schema, c), c.getDefaultState());
1510
1501
  }
1511
- /** Remplit l'état du formulaire à partir d'un enregistrement, champ par champ (équivalent de `$this->form->fill($record->attributesToArray())`). */
1502
+ /** Fills the form state from a record, field by field. */
1512
1503
  export function fillSchemaFromRecord(schema, target, record) {
1513
1504
  for (const c of schema.getFlatComponents()) {
1514
1505
  if (!(c instanceof Field))
@@ -1517,7 +1508,7 @@ export function fillSchemaFromRecord(schema, target, record) {
1517
1508
  writePath(target, segments, c.hydrateState(readPath(record, segments) ?? c.getDefaultState()));
1518
1509
  }
1519
1510
  }
1520
- /** Valide tous les champs visibles d'un schéma ; retourne les erreurs par chemin d'état. */
1511
+ /** Validates every visible field of a schema; returns errors by state path. */
1521
1512
  export async function validateSchema(schema) {
1522
1513
  const errors = {};
1523
1514
  for (const c of await schema.getVisibleFlatComponents()) {
@@ -1529,7 +1520,7 @@ export async function validateSchema(schema) {
1529
1520
  }
1530
1521
  return errors;
1531
1522
  }
1532
- /** État à enregistrer : seulement les champs déclarés, visibles et « déshydratés » ; une chaîne vide vaut `null` (comme Laravel). */
1523
+ /** State to save: only declared, visible, dehydrated fields; an empty string becomes `null`. */
1533
1524
  export async function getSchemaState(schema) {
1534
1525
  const data = {};
1535
1526
  const fields = [];
@@ -1540,7 +1531,7 @@ export async function getSchemaState(schema) {
1540
1531
  writePath(data, relativePath(schema, c), state === '' || state === undefined ? null : state);
1541
1532
  fields.push(c);
1542
1533
  }
1543
- // Du plus profond au moins profond : un conteneur voit ses enfants déjà mis en forme.
1534
+ // Deepest first: a container sees its children already shaped.
1544
1535
  for (const c of fields.reverse()) {
1545
1536
  const path = relativePath(schema, c);
1546
1537
  const current = readPath(data, path);
@@ -1550,11 +1541,11 @@ export async function getSchemaState(schema) {
1550
1541
  }
1551
1542
  return data;
1552
1543
  }
1553
- /** Lancée depuis `beforeValidation` / `afterValidation` d'une étape pour rester sur l'étape courante (équivalent de l'exception `Halt` de Filament). */
1544
+ /** Throw from a step's `beforeValidation` / `afterValidation` to stay on the current step. */
1554
1545
  export class Halt extends Error {
1555
1546
  constructor() { super('Halt'); }
1556
1547
  }
1557
- /** Une étape d'un assistant : un titre, une description et une icône facultatives, son propre schéma (port de Wizard\Step). */
1548
+ /** A wizard step: a label, an optional description and icon, and its own schema. */
1558
1549
  export class Step extends Component {
1559
1550
  children = [];
1560
1551
  _columns = {};
@@ -1571,9 +1562,9 @@ export class Step extends Component {
1571
1562
  description(description) { this._description = description; return this; }
1572
1563
  icon(name) { this._icon = name; return this; }
1573
1564
  completedIcon(name) { this._completedIcon = name; return this; }
1574
- /** Avant la validation de l'étape, au clic sur « Next » ; lever `Halt` pour rester sur l'étape. */
1565
+ /** Runs before the step is validated, on "Next"; throw `Halt` to stay on the step. */
1575
1566
  beforeValidation(callback) { this._beforeValidation = callback; return this; }
1576
- /** Après une validation réussie, avant de passer à l'étape suivante ; lever `Halt` pour rester sur l'étape. */
1567
+ /** Runs after a successful validation, before moving to the next step; throw `Halt` to stay on the step. */
1577
1568
  afterValidation(callback) { this._afterValidation = callback; return this; }
1578
1569
  getLabel() { return this.label; }
1579
1570
  getDescription() { return this._description; }
@@ -1583,7 +1574,7 @@ export class Step extends Component {
1583
1574
  async callAfterValidation() { await this._afterValidation?.(this.context()); }
1584
1575
  getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
1585
1576
  async toHtml() { return this.getChildSchemas()[0].toHtml(); }
1586
- /** Panneau de l'étape : un `<form>` quand l'assistant sait quoi soumettre (Entrée passe à l'étape suivante, ou enregistre à la dernière). */
1577
+ /** Step panel: a `<form>` when the wizard has a submit handler (Enter goes to the next step, or saves on the last one). */
1587
1578
  async toStepHtml(submitHandler, hasHeader) {
1588
1579
  const key = this.getKey();
1589
1580
  const tag = submitHandler ? 'form' : 'div';
@@ -1605,8 +1596,8 @@ export class Step extends Component {
1605
1596
  }
1606
1597
  }
1607
1598
  /**
1608
- * Assistant par étapes (port de Wizard) : toutes les étapes sont rendues, le navigateur n'affiche que l'étape courante.
1609
- * « Next » valide l'étape courante côté serveur avant d'avancer, sauf si l'assistant est `skippable()`.
1599
+ * Multi-step wizard: every step is rendered, the browser only shows the current one.
1600
+ * "Next" validates the current step on the server before moving on, unless the wizard is `skippable()`.
1610
1601
  */
1611
1602
  export class Wizard extends Component {
1612
1603
  steps;
@@ -1625,20 +1616,20 @@ export class Wizard extends Component {
1625
1616
  static make(steps = []) { return configured(new Wizard(steps)); }
1626
1617
  schema(steps) { this.steps = steps; return this; }
1627
1618
  label(label) { this._label = label; return this; }
1628
- /** Étapes accessibles dans n'importe quel ordre, sans validation au passage. */
1619
+ /** Steps can be visited in any order, without validation. */
1629
1620
  skippable(condition = true) { this._skippable = condition; return this; }
1630
- /** Étape affichée à l'ouverture (à partir de 1). */
1621
+ /** Step shown on open (1-based). */
1631
1622
  startOnStep(step) { this._startStep = step; return this; }
1632
- /** Faux : sans carte autour de l'assistant (c'est le cas sur une page de resource). */
1623
+ /** `false`: no card around the wizard (as on a resource page). */
1633
1624
  contained(condition = true) { this._contained = condition; return this; }
1634
1625
  hiddenHeader(condition = true) { this._headerHidden = condition; return this; }
1635
- /** Action affichée à la place de « Back » sur la première étape. */
1626
+ /** Action shown instead of "Back" on the first step. */
1636
1627
  cancelAction(action) { this._cancelAction = action; return this; }
1637
- /** Action affichée à la place de « Next » sur la dernière étape. */
1628
+ /** Action shown instead of "Next" on the last step. */
1638
1629
  submitAction(action) { this._submitAction = action; return this; }
1639
- /** Expression Alpine exécutée quand la dernière étape est soumise au clavier (ex. `$wire.create()`). */
1630
+ /** Alpine expression run when the last step is submitted from the keyboard (e.g. `$wire.create()`). */
1640
1631
  alpineSubmitHandler(handler) { this._alpineSubmitHandler = handler; return this; }
1641
- /** Garde l'étape courante dans l'adresse (`?step=…`). */
1632
+ /** Keeps the current step in the URL (`?step=…`). */
1642
1633
  persistStepInQueryString(key = 'step') { this._stepQueryStringKey = key; return this; }
1643
1634
  nextAction(modify) { this._modifyNext = modify; return this; }
1644
1635
  previousAction(modify) { this._modifyPrevious = modify; return this; }
@@ -1648,11 +1639,11 @@ export class Wizard extends Component {
1648
1639
  return `${this.container.getKey()}.${this._label ? `${slug(this._label)}::${own}` : own}`;
1649
1640
  }
1650
1641
  getExposedMethods() { return ['nextStep', 'previousStep']; }
1651
- /** « Next » redessine le composant : les erreurs de validation de l'étape doivent apparaître. */
1642
+ /** "Next" re-renders the component so the step's validation errors show up. */
1652
1643
  isRenderlessMethod() { return false; }
1653
1644
  getChildSchemas() { return [childSchema(this, this.steps)]; }
1654
1645
  getSteps() { this.getChildSchemas(); return this.steps; }
1655
- /** Appelée par le navigateur au clic sur « Next » : valide l'étape courante, puis annonce l'étape suivante. */
1646
+ /** Called by the browser on "Next": validates the current step, then announces the next one. */
1656
1647
  async nextStep(currentStepIndex = null, currentStepKey = null) {
1657
1648
  const steps = this.getSteps();
1658
1649
  if (!Number.isInteger(currentStepIndex) || currentStepIndex < 0 || currentStepIndex >= steps.length - 1)
@@ -1680,7 +1671,7 @@ export class Wizard extends Component {
1680
1671
  }
1681
1672
  livewire.dispatch('next-wizard-step', { key: this.getKey(), step: steps[currentStepIndex + 1].getKey(), currentStep: current.getKey() });
1682
1673
  }
1683
- /** Le retour en arrière se fait dans le navigateur ; la méthode existe pour le contrat de Filament. */
1674
+ /** Going back happens in the browser; this exposed method is a no-op the front end still calls. */
1684
1675
  previousStep() { }
1685
1676
  async toHtml() {
1686
1677
  const steps = this.getSteps();
@@ -1724,7 +1715,7 @@ export class Wizard extends Component {
1724
1715
  })" x-on:next-wizard-step.window="${e(`if ($event.detail.key === ${js(key)}) goToNextStep($event.detail.step, $event.detail.currentStep)`)}" x-on:go-to-wizard-step.window="${e(`$event.detail.key === ${js(key)} && goToStep($event.detail.step)`)}" wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${e(JSON.stringify(visibleKeys))}" x-ref="stepsData" />${this._headerHidden ? '' : `<ol${this._label ? ` aria-label="${e(this._label)}"` : ''} role="list" x-cloak x-ref="header" class="fi-sc-wizard-header">${header.join('')}</ol>`}${panels.join('')}<div x-cloak class="fi-sc-wizard-footer"><div x-cloak${previousDisabled ? '' : ' x-on:click="goToPreviousStep"'} x-show="! isFirstStep()">${await previousAction.toHtml(ctx)}</div><div x-show="isFirstStep()">${this._cancelAction ? await this._cancelAction.toHtml(ctx) : ''}</div><div x-cloak${nextDisabled ? '' : ` x-on:click.capture="if ($el.hasAttribute('aria-disabled')) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-on:click="if (! $el.hasAttribute('aria-disabled')) requestNextStep()"`} x-bind:class="{ 'fi-hidden': isLastStep() }" wire:loading.attr="aria-disabled">${await nextAction.toHtml(ctx)}</div><div x-bind:class="{ 'fi-hidden': ! isLastStep() }">${this._submitAction ? await this._submitAction.toHtml(ctx) : ''}</div></div></div>`;
1725
1716
  }
1726
1717
  }
1727
- /** Stockage sur le disque local, servi sous une adresse publique. Le nom du fichier est aléatoire, son extension vient du type détecté. */
1718
+ /** Local disk storage, served under a public URL. File names are random; the extension comes from the detected type. */
1728
1719
  export class LocalFileStorage {
1729
1720
  root;
1730
1721
  baseUrl;
@@ -1738,7 +1729,7 @@ export class LocalFileStorage {
1738
1729
  const key = [directory, `${randomUUID()}.${file.extension}`].filter(Boolean).join('/');
1739
1730
  const target = this.resolve(key);
1740
1731
  if (!target)
1741
- throw new Error('Dossier de téléversement invalide.');
1732
+ throw new Error('Invalid upload directory.');
1742
1733
  mkdirSync(nodePath.dirname(target), { recursive: true });
1743
1734
  copyFileSync(file.path, target);
1744
1735
  writeFileSync(`${target}.meta.json`, JSON.stringify({ name: file.name, type: file.type }));
@@ -1752,17 +1743,17 @@ export class LocalFileStorage {
1752
1743
  try {
1753
1744
  meta = JSON.parse(readFileSync(`${target}.meta.json`, 'utf8'));
1754
1745
  }
1755
- catch { /* fichier déposé sans métadonnées */ }
1746
+ catch { /* file dropped in without metadata */ }
1756
1747
  return { name: meta.name ?? nodePath.basename(key), size: statSync(target).size, type: meta.type ?? 'application/octet-stream', url: `${this.baseUrl}/${key.split('/').map(encodeURIComponent).join('/')}` };
1757
1748
  }
1758
1749
  }
1759
1750
  /**
1760
- * Téléversement de fichiers (port de FileUpload, rendu FilePond de Filament).
1761
- * État : `{ <clé de fichier>: <clé enregistrée> | 'livewire-file:<id temporaire>' }`. Une clé enregistrée n'est acceptée du navigateur
1762
- * que si elle figure déjà dans l'enregistrement en cours d'édition : on ne peut pas s'attribuer le fichier d'un autre.
1751
+ * File upload (FilePond).
1752
+ * State: `{ <file key>: <stored key> | 'livewire-file:<temporary id>' }`. A stored key from the browser is only accepted
1753
+ * if the record being edited already has it, so nobody can claim someone else's file.
1763
1754
  */
1764
1755
  export class FileUpload extends Field {
1765
- /** Stockage par défaut de l'application. */
1756
+ /** Application-wide default storage. */
1766
1757
  static storage = null;
1767
1758
  _multiple = false;
1768
1759
  _maxFiles = null;
@@ -1776,17 +1767,17 @@ export class FileUpload extends Field {
1776
1767
  static make(name) { return configured(new FileUpload(name)); }
1777
1768
  multiple(condition = true) { this._multiple = condition; return this; }
1778
1769
  maxFiles(count) { this._maxFiles = count; return this; }
1779
- /** Taille maximale d'un fichier, en kilo-octets. */
1770
+ /** Maximum file size, in kilobytes. */
1780
1771
  maxSize(kilobytes) { this._maxSize = kilobytes; return this; }
1781
1772
  reorderable(condition = true) { this._reorderable = condition; return this; }
1782
- /** Types MIME acceptés (`image/jpeg`, `image/*`…), vérifiés sur le contenu du fichier reçu. */
1773
+ /** Accepted MIME types (`image/jpeg`, `image/*`…), checked against the received file's content. */
1783
1774
  acceptedFileTypes(types) { this._acceptedFileTypes = types; return this; }
1784
1775
  image() { return this.acceptedFileTypes(['image/*']); }
1785
1776
  directory(directory) { this._directory = directory; return this; }
1786
1777
  storage(storage) { this._storage = storage; return this; }
1787
- /** `false` : le champ n'enregistre rien lui-même ; les données reçoivent des `FileUploadEntry`, à traiter par l'adaptateur (médias). */
1778
+ /** `false`: the field stores nothing itself; the data receives `FileUploadEntry` items for the adapter to process (media). */
1788
1779
  storeFiles(condition = true) { this._storeFiles = condition; return this; }
1789
- /** Informations d'un fichier déjà enregistré (nom, taille, type, adresse), quand elles ne viennent pas du stockage. */
1780
+ /** Info about an already saved file (name, size, type, URL), when it does not come from the storage. */
1790
1781
  getUploadedFileUsing(resolver) { this._getUploadedFileUsing = resolver; return this; }
1791
1782
  getDefaultState() { return {}; }
1792
1783
  hydrateState(value) {
@@ -1795,20 +1786,19 @@ export class FileUpload extends Field {
1795
1786
  }
1796
1787
  get uploads() { return this.livewire._runtime?.uploads ?? null; }
1797
1788
  getStorage() { return this._storage ?? FileUpload.storage; }
1798
- /** Entrées de l'état, nettoyées : clé de fichier sûre, valeur chaîne. */
1789
+ /** Sanitized state entries: safe file key, string value. */
1799
1790
  entries() {
1800
1791
  const state = this.context().state;
1801
1792
  if (!state || typeof state !== 'object' || Array.isArray(state))
1802
1793
  return [];
1803
1794
  return Object.entries(state).filter(([key, value]) => /^[A-Za-z0-9-]{1,64}$/.test(key) && typeof value === 'string').map(([key, value]) => [key, value]).slice(0, 100);
1804
1795
  }
1805
- /** Clés enregistrées que l'enregistrement en cours possède déjà : les seules qu'on accepte de relire depuis le navigateur. */
1796
+ /** Stored keys the current record already owns: the only ones accepted back from the browser. */
1806
1797
  originalKeys() {
1807
1798
  const record = this.container.getRecord();
1808
1799
  return new Set(Object.values(this.hydrateState(record?.[this.getName()])));
1809
1800
  }
1810
1801
  isTemporary(value) { return value.startsWith('livewire-file:'); }
1811
- // --- méthodes exposées au JavaScript du champ
1812
1802
  getExposedMethods() { return ['deleteUploadedFile', 'removeUploadedFile', 'reorderUploadedFiles', 'getUploadedFiles']; }
1813
1803
  async getUploadedFiles() {
1814
1804
  const original = this.originalKeys();
@@ -1819,13 +1809,13 @@ export class FileUpload extends Field {
1819
1809
  continue;
1820
1810
  }
1821
1811
  const info = this._getUploadedFileUsing ? await this._getUploadedFileUsing(value, this.context()) : await this.getStorage()?.info(value);
1822
- // Seules des adresses locales ou http(s) partent vers le navigateur.
1812
+ // Only local or http(s) URLs are sent to the browser.
1823
1813
  files[fileKey] = info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info : null;
1824
1814
  }
1825
1815
  return files;
1826
1816
  }
1827
1817
  writeState(entries) { writePath(this.livewire, this.getStatePath().split('.'), Object.fromEntries(entries)); }
1828
- /** Retire un fichier du champ. Le fichier enregistré n'est supprimé qu'à l'enregistrement du formulaire, par l'application. */
1818
+ /** Removes a file from the field. A stored file is only deleted by the application when the form is saved. */
1829
1819
  removeUploadedFile(fileKey) {
1830
1820
  const removed = this.entries().find(([key]) => key === fileKey);
1831
1821
  if (removed && this.isTemporary(removed[1]))
@@ -1858,7 +1848,7 @@ export class FileUpload extends Field {
1858
1848
  rules.push({ name: 'max', passes: () => temporary().every((file) => file !== null && file.size <= this._maxSize * 1024), message: (a) => `The ${a} field must not be greater than ${this._maxSize} kilobytes.` });
1859
1849
  return rules;
1860
1850
  }
1861
- /** La validation porte sur l'état entier, même vide (un champ obligatoire sans fichier doit échouer). */
1851
+ /** Validates the whole state, even when empty (a required field without files must fail). */
1862
1852
  async validate() {
1863
1853
  if (!(await this.isDehydrated()))
1864
1854
  return [];
@@ -1868,7 +1858,7 @@ export class FileUpload extends Field {
1868
1858
  return [rule.message(attribute)];
1869
1859
  return [];
1870
1860
  }
1871
- /** À l'enregistrement : les fichiers temporaires sont stockés, les clés existantes non reconnues sont écartées. */
1861
+ /** On save: temporary files are stored, unrecognized existing keys are dropped. */
1872
1862
  async dehydrateState() {
1873
1863
  const original = this.originalKeys();
1874
1864
  const out = [];
@@ -1883,7 +1873,7 @@ export class FileUpload extends Field {
1883
1873
  }
1884
1874
  const storage = this.getStorage();
1885
1875
  if (!storage)
1886
- throw new Error('FileUpload : aucun stockage configuré (FileUpload.storage).');
1876
+ throw new Error('FileUpload: no storage configured (FileUpload.storage).');
1887
1877
  out.push(await storage.store(file, this._directory));
1888
1878
  this.uploads?.remove(file.id);
1889
1879
  }