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

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,8 +1,8 @@
1
1
  // @navette-js/forms — form fields (Field, TextInput, Select, Checkbox…) and server-side validation.
2
- import { Attributes, HtmlString, e, evaluate, icon, js, views, configured } from '@navette-js/support';
2
+ import { Attributes, HtmlString, cls, e, evaluate, icon, js, sanitizeHtml, views, configured } from '@navette-js/support';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { t } from '@navette-js/support/i18n';
5
- import { Component, Icon as SchemaIcon, Schema, Text, childSchema, renderInlineItems, slug } from '@navette-js/schemas';
5
+ import { Component, Icon as SchemaIcon, Schema, Text, childSchema, renderInlineItems, renderSlot, slug } from '@navette-js/schemas';
6
6
  import { Action } from '@navette-js/actions';
7
7
  import { loadingIndicator } from '@navette-js/support';
8
8
  import { randomUUID } from 'node:crypto';
@@ -12,11 +12,16 @@ import TextAlign from '@tiptap/extension-text-align';
12
12
  import Subscript from '@tiptap/extension-subscript';
13
13
  import Superscript from '@tiptap/extension-superscript';
14
14
  import Highlight from '@tiptap/extension-highlight';
15
- import Image from '@tiptap/extension-image';
15
+ import { RICH_CONTENT_EXTENSIONS, SizedImage, customRichContentExtensions, registerTextColors, sanitizeCssColor, TextColor, textColorsForJs } from "./rich-content.js";
16
+ export { CustomBlockNode, GridColumnNode, GridNode, LeadNode, MentionNode, MentionProvider, MergeTagNode, registerRichContentExtensions, RichContentCustomBlock, RichContentRenderer, sanitizeCssColor, SizedImage, SmallMark, TextColor, TextColorMark } from "./rich-content.js";
17
+ import { RichEditorTool } from "./rich-editor-tool.js";
18
+ export { RichEditorTool } from "./rich-editor-tool.js";
19
+ import { RICH_EDITOR_TOOL_DEFINITIONS } from "./rich-editor-tools.js";
20
+ export { RICH_EDITOR_TOOL_DEFINITIONS } from "./rich-editor-tools.js";
16
21
  import { TableKit } from '@tiptap/extension-table';
17
22
  import { fileURLToPath } from 'node:url';
18
23
  import nodePath from 'node:path';
19
- import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
24
+ import { constants as fsConstants, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
20
25
  views.namespace('filament-forms', fileURLToPath(new URL('../views', import.meta.url)));
21
26
  let colorManager = null;
22
27
  export const useColorManager = (manager) => { colorManager = manager; };
@@ -28,7 +33,7 @@ const phpNumber = (n) => {
28
33
  const [mantissa, exponent] = n.toPrecision(14).split('e');
29
34
  return `${mantissa.replace(/\.?0+$/, '')}E${exponent}`;
30
35
  };
31
- const blank = (v) => v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
36
+ import { isBlank as blank, stringRule, parseRegex } from "./rules.js";
32
37
  const headline = (s) => s.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()).replace(/ (.)/g, (_, c) => ' ' + c.toLowerCase());
33
38
  export class Field extends Component {
34
39
  _label = null;
@@ -41,7 +46,16 @@ export class Field extends Component {
41
46
  _dehydrated = null;
42
47
  _hiddenLabel = false;
43
48
  _helperText = null;
49
+ _liveMode = null;
50
+ _validationAttribute = null;
51
+ _validationMessages = {};
52
+ _markedAsRequired = null;
44
53
  constructor(name) { super(); this._name = name; }
54
+ /** Sends the state to the server as soon as it changes (`onBlur`: when the field loses focus). `false` turns it off. */
55
+ live(condition = true) { this._liveMode = condition === false ? null : typeof condition === 'object' && condition.onBlur ? 'blur' : 'live'; return this; }
56
+ isLive() { return this._liveMode !== null; }
57
+ /** `wire:model` directive of the field, with its live modifiers. */
58
+ wireModel() { return `wire:model${this._liveMode ? '.live' : ''}${this._liveMode === 'blur' ? '.blur' : ''}`; }
45
59
  hasStatePath() { return true; }
46
60
  getName() { return this._name; }
47
61
  getId() { return this.getKey(); }
@@ -52,6 +66,9 @@ export class Field extends Component {
52
66
  default(value) { this._default = value; return this; }
53
67
  /** Helper text shown under the field. */
54
68
  helperText(text) { this._helperText = text; return this; }
69
+ _belowContent = null;
70
+ /** Text, components or actions under the control (after the helper text). Strings are escaped. */
71
+ belowContent(content) { this._belowContent = content; return this; }
55
72
  /** Helper text id, linked to the control through `aria-describedby`. */
56
73
  getHelperTextId() { return this._helperText !== null ? `${this.getId()}-helper-text` : null; }
57
74
  /** Keeps the label for screen readers only. */
@@ -110,7 +127,125 @@ export class Field extends Component {
110
127
  getDefaultState() { return typeof this._default === 'function' ? this._default() : this._default; }
111
128
  /** Shapes a record value as it enters the form (hydration). */
112
129
  hydrateState(value) { return value; }
113
- rule(rule) { this._rules.push(rule); return this; }
130
+ /** Adds a rule: an object `{ name, passes, message }`, or a Laravel-style string (`'max:255'`, `'regex:/^[a-z]+$/'`). */
131
+ rule(rule, condition = true) {
132
+ if (typeof rule === 'string') {
133
+ const spec = rule;
134
+ // Built now, so that an unknown or malformed rule fails when the form is declared.
135
+ const probe = stringRule(spec, { numeric: false, name: this._name ?? '' });
136
+ if (!probe)
137
+ return this;
138
+ const build = () => stringRule(spec, { numeric: this.hasNumericValidation(), name: this._name ?? '' });
139
+ rule = { name: probe.name, implicit: probe.implicit, passes: (value, ctx) => build().passes(value, ctx), message: (attribute) => build().message(attribute) };
140
+ }
141
+ if (condition === false)
142
+ return this;
143
+ if (condition === true) {
144
+ this._rules.push(rule);
145
+ return this;
146
+ }
147
+ const inner = rule;
148
+ this._rules.push({ ...inner, passes: async (value, ctx) => !(await condition(ctx)) || inner.passes(value, ctx) });
149
+ return this;
150
+ }
151
+ /** Several rules at once: `rules(['required', 'max:255'])` or `rules('required|max:255')`. */
152
+ rules(rules, condition = true) {
153
+ for (const rule of typeof rules === 'string' ? rules.split('|').filter((item) => item.trim() !== '') : rules)
154
+ this.rule(rule, condition);
155
+ return this;
156
+ }
157
+ /** Value checked by the rules (a subclass may trim it, for instance). */
158
+ stateForValidation(state) { return state; }
159
+ /** Sizes (`min`, `max`, `size`, `between`) compare the number, not the text length. */
160
+ hasNumericValidation() { return false; }
161
+ /** Name of the field in validation messages (the lowercased label by default). */
162
+ validationAttribute(name) { this._validationAttribute = name; return this; }
163
+ /** Custom messages by rule name (`{ required: 'Tell us your name.', max: 'Too long.' }`); `:attribute` is replaced by the field name. */
164
+ validationMessages(messages) { this._validationMessages = { ...this._validationMessages, ...messages }; return this; }
165
+ _loadStateFromRelationships = null;
166
+ _saveRelationships = null;
167
+ /** Fills the field from the record's relationships (after the record's own values): `(record, ctx) => state`. */
168
+ loadStateFromRelationshipsUsing(callback) { this._loadStateFromRelationships = callback; return this; }
169
+ /** Saves the field into the record's relationships, once the record is created or updated. Such a field is not in the saved data. */
170
+ saveRelationshipsUsing(callback) { this._saveRelationships = callback; return this; }
171
+ /** Whether the field saves itself through `saveRelationshipsUsing()` (and is then left out of the saved data). */
172
+ async savesRelationships() { return this._saveRelationships !== null; }
173
+ async loadStateFromRelationships(record) { return this._loadStateFromRelationships ? { state: await this._loadStateFromRelationships(record, this.context()) } : null; }
174
+ async saveRelationships(record) { if (this._saveRelationships)
175
+ await this._saveRelationships({ record, state: await this.dehydrateState(this.context().state), ctx: this.context() }); }
176
+ /** Shows (or hides, with `false`) the required asterisk, regardless of `required()`. */
177
+ markAsRequired(condition = true) { this._markedAsRequired = condition; return this; }
178
+ async isMarkedAsRequired() { return this._markedAsRequired ?? (await this.isRequired()); }
179
+ stringRuleWith(name, parameters = []) { return this.rule(parameters.length ? `${name}:${parameters.map(String).join(',')}` : name); }
180
+ /** The value must match the pattern (a RegExp, or a `/pattern/flags` string). */
181
+ regex(pattern) { const re = typeof pattern === 'string' ? parseRegex(pattern) : pattern; return this.rule({ name: 'regex', passes: (v) => { re.lastIndex = 0; return re.test(String(v)); }, message: (a) => `The ${a} field format is invalid.` }); }
182
+ notRegex(pattern) { const re = typeof pattern === 'string' ? parseRegex(pattern) : pattern; return this.rule({ name: 'not_regex', passes: (v) => { re.lastIndex = 0; return !re.test(String(v)); }, message: (a) => `The ${a} field format is invalid.` }); }
183
+ alpha() { return this.stringRuleWith('alpha'); }
184
+ alphaDash() { return this.stringRuleWith('alpha_dash'); }
185
+ alphaNum() { return this.stringRuleWith('alpha_num'); }
186
+ ascii() { return this.stringRuleWith('ascii'); }
187
+ uuid() { return this.stringRuleWith('uuid'); }
188
+ ulid() { return this.stringRuleWith('ulid'); }
189
+ json() { return this.stringRuleWith('json'); }
190
+ ip() { return this.stringRuleWith('ip'); }
191
+ ipv4() { return this.stringRuleWith('ipv4'); }
192
+ ipv6() { return this.stringRuleWith('ipv6'); }
193
+ macAddress() { return this.stringRuleWith('mac_address'); }
194
+ hexColor() { return this.stringRuleWith('hex_color'); }
195
+ multipleOf(value) { return this.stringRuleWith('multiple_of', [value]); }
196
+ /** The value must be one of these (a list, or the values of an enum-like object). */
197
+ in(values) { const allowed = (Array.isArray(values) ? values : Object.values(values)).map(String); return this.rule({ name: 'in', passes: (v) => (Array.isArray(v) ? v : [v]).every((item) => allowed.includes(String(item))), message: (a) => `The selected ${a} is invalid.` }); }
198
+ notIn(values) { const denied = values.map(String); return this.rule({ name: 'not_in', passes: (v) => (Array.isArray(v) ? v : [v]).every((item) => !denied.includes(String(item))), message: (a) => `The selected ${a} is invalid.` }); }
199
+ /** `in()` with the values of an enum-like object. */
200
+ enum(values) { return this.in(values); }
201
+ startsWith(prefixes) { return this.stringRuleWith('starts_with', [prefixes].flat()); }
202
+ endsWith(suffixes) { return this.stringRuleWith('ends_with', [suffixes].flat()); }
203
+ doesntStartWith(prefixes) { return this.stringRuleWith('doesnt_start_with', [prefixes].flat()); }
204
+ doesntEndWith(suffixes) { return this.stringRuleWith('doesnt_end_with', [suffixes].flat()); }
205
+ /** The field must equal `<name>_confirmation` (or the given field) of the same form. */
206
+ confirmed(field = null) { return this.rule(field ? `confirmed:${field}` : 'confirmed'); }
207
+ same(field) { return this.stringRuleWith('same', [field]); }
208
+ different(field) { return this.stringRuleWith('different', [field]); }
209
+ /** Compares with a number or another field of the same form. */
210
+ gt(fieldOrValue) { return this.stringRuleWith('gt', [fieldOrValue]); }
211
+ gte(fieldOrValue) { return this.stringRuleWith('gte', [fieldOrValue]); }
212
+ lt(fieldOrValue) { return this.stringRuleWith('lt', [fieldOrValue]); }
213
+ lte(fieldOrValue) { return this.stringRuleWith('lte', [fieldOrValue]); }
214
+ /** No duplicates in a list value (tags, multiple select, checkbox list). */
215
+ distinct() { return this.stringRuleWith('distinct'); }
216
+ /** Required when another field of the form has one of these values (`requiredIf('type', 'company')`). */
217
+ requiredIf(field, values) { return this.stringRuleWith('required_if', [field, ...[values].flat()]); }
218
+ requiredUnless(field, values) { return this.stringRuleWith('required_unless', [field, ...[values].flat()]); }
219
+ requiredIfAccepted(field) { return this.stringRuleWith('required_if_accepted', [field]); }
220
+ requiredWith(fields) { return this.stringRuleWith('required_with', [fields].flat()); }
221
+ requiredWithAll(fields) { return this.stringRuleWith('required_with_all', [fields].flat()); }
222
+ requiredWithout(fields) { return this.stringRuleWith('required_without', [fields].flat()); }
223
+ requiredWithoutAll(fields) { return this.stringRuleWith('required_without_all', [fields].flat()); }
224
+ prohibited(condition = true) { return condition ? this.stringRuleWith('prohibited') : this; }
225
+ prohibitedIf(field, values) { return this.stringRuleWith('prohibited_if', [field, ...[values].flat()]); }
226
+ prohibitedUnless(field, values) { return this.stringRuleWith('prohibited_unless', [field, ...[values].flat()]); }
227
+ prohibits(fields) { return this.stringRuleWith('prohibits', [fields].flat()); }
228
+ /**
229
+ * The value must exist: in a column of the host's data adapter (`exists()`, `exists({ column })`), in another adapter
230
+ * (`exists({ adapter: new ObjectionAdapter(Brand), column: 'id' })`), or according to a function.
231
+ */
232
+ exists(check = {}) {
233
+ return this.rule({
234
+ name: 'exists',
235
+ passes: async (value, ctx) => {
236
+ if (typeof check === 'function')
237
+ return check(value, ctx);
238
+ const adapter = check.adapter ?? ctx.livewire.getDataAdapter?.();
239
+ if (!adapter)
240
+ throw new Error('exists(): no data adapter (pass one with { adapter }).');
241
+ for (const item of Array.isArray(value) ? value : [value])
242
+ if (!(await adapter.exists(check.column ?? this._name, item, null)))
243
+ return false;
244
+ return true;
245
+ },
246
+ message: (a) => `The selected ${a} is invalid.`,
247
+ });
248
+ }
114
249
  /** `unique` rule: the value must not exist in the column of the same name; `ignoreRecord` excludes the record being edited. */
115
250
  unique(options = {}) {
116
251
  return this.rule({
@@ -144,13 +279,16 @@ export class Field extends Component {
144
279
  if ((await this.isDisabled()) && this._dehydrated !== true)
145
280
  return [];
146
281
  // An empty string is treated as missing.
147
- const value = this.context().state === '' ? null : this.context().state;
148
- const attribute = (await this.getLabel()).toLowerCase();
282
+ const raw = this.stateForValidation(this.context().state);
283
+ const value = raw === '' ? null : raw;
284
+ const attribute = this._validationAttribute ?? (await this.getLabel()).toLowerCase();
149
285
  for (const rule of await this.getValidationRules()) {
150
- if (rule.name !== 'required' && blank(value))
286
+ if (rule.name !== 'required' && !rule.implicit && blank(value))
151
287
  continue;
152
- if (!(await rule.passes(value, this.context())))
153
- return [rule.message(attribute)];
288
+ if (!(await rule.passes(value, this.context()))) {
289
+ const custom = this._validationMessages[rule.name];
290
+ return [custom === undefined ? rule.message(attribute) : typeof custom === 'function' ? custom(attribute) : custom.replace(/:attribute/g, attribute)];
291
+ }
154
292
  }
155
293
  return [];
156
294
  }
@@ -169,8 +307,10 @@ export class Field extends Component {
169
307
  // Helper text: a small inline schema under the control.
170
308
  if (this._helperText !== null)
171
309
  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>`;
310
+ if (this._belowContent !== null)
311
+ html += await renderSlot(this, this._belowContent, this.getKey());
172
312
  return views.render('filament-forms::components.field-wrapper', {
173
- id: this.getId(), label: await this.getLabel(), required: (await this.isRequired()) && !(await this.isDisabled()),
313
+ id: this.getId(), label: await this.getLabel(), required: (await this.isMarkedAsRequired()) && !(await this.isDisabled()),
174
314
  labelPrefix: options.labelPrefix ?? '', hiddenLabel: this._hiddenLabel, after: options.after ?? '', afterLabel: this._hiddenLabel ? '' : await this.renderAfterLabel(), labelAsDiv: options.labelAsDiv === true, focusable: options.focusable === true, html, errorMessage: errors[0] ?? null, wrapperClass: options.wrapperClass ?? null,
175
315
  });
176
316
  }
@@ -189,6 +329,7 @@ export class TextInput extends Field {
189
329
  _prefix = null;
190
330
  _live = null;
191
331
  static make(name) { return configured(new TextInput(name)); }
332
+ hasNumericValidation() { return this._numeric !== null; }
192
333
  /** Decimal number: `type="number"`, `step="any"`. */
193
334
  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.` }); }
194
335
  /** Integer: `type="number"`, `step="1"`. */
@@ -198,21 +339,60 @@ export class TextInput extends Field {
198
339
  step(step) { this._step = step; return this; }
199
340
  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}.` }); }
200
341
  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}.` }); }
201
- /** Label shown before the input (e.g. `$`). */
202
- prefix(label) { this._prefix = label; return this; }
342
+ /** Label shown before the input (e.g. `$`); `isInline`: inside the input instead of a separate box. */
343
+ prefix(label, isInline = false) { this._prefix = label; this._prefixInline = isInline; return this; }
203
344
  _suffix = null;
204
345
  /** Label shown after the input ("hours", "kg"…). */
205
- suffix(label) { this._suffix = label; return this; }
346
+ suffix(label, isInline = false) { this._suffix = label; this._suffixInline = isInline; return this; }
347
+ _prefixInline = false;
348
+ _suffixInline = false;
349
+ _prefixIcon = null;
350
+ _suffixIcon = null;
351
+ _prefixIconColor = null;
352
+ _suffixIconColor = null;
353
+ _prefixActions = [];
354
+ _suffixActions = [];
355
+ prefixIcon(icon, isInline = false) { this._prefixIcon = icon; this._prefixInline = isInline; return this; }
356
+ suffixIcon(icon, isInline = false) { this._suffixIcon = icon; this._suffixInline = isInline; return this; }
357
+ prefixIconColor(color) { this._prefixIconColor = color; return this; }
358
+ suffixIconColor(color) { this._suffixIconColor = color; return this; }
359
+ /**
360
+ * Icon button before the input, running an action. The action gets the field's `state`, and `set(name, value)` to change a
361
+ * field of the same form.
362
+ */
363
+ prefixAction(action, isInline = false) { this._prefixActions = [...this._prefixActions, action]; this._prefixInline = isInline; return this; }
364
+ prefixActions(actions) { this._prefixActions = actions; return this; }
365
+ suffixAction(action, isInline = false) { this._suffixActions = [...this._suffixActions, action]; this._suffixInline = isInline; return this; }
366
+ suffixActions(actions) { this._suffixActions = actions; return this; }
367
+ /** Affix actions, mounted through the schema component. */
368
+ getActions() { return [...this.getHintActions(), ...this._prefixActions, ...this._suffixActions]; }
369
+ /** Exact length: `minlength` and `maxlength`, and the matching rule. */
370
+ length(length) { this.minLength(length); this.maxLength(length); return this; }
371
+ _datalist = [];
372
+ /** Suggestions offered by the browser while typing (`<datalist>`). */
373
+ datalist(options) { this._datalist = options.map(String); return this; }
374
+ _readOnly = false;
375
+ /** The value is shown and sent, but cannot be changed (`readonly`). */
376
+ readOnly(condition = true) { this._readOnly = condition; return this; }
377
+ _copyable = null;
378
+ /** A button after the input copies its value (the browser only allows it on HTTPS or `localhost`). */
379
+ copyable(condition = true, copyMessage = null, copyMessageDuration = 2000) { this._copyable = condition ? { message: copyMessage, duration: copyMessageDuration } : null; return this; }
380
+ _trim = false;
381
+ /** Removes the spaces around the value before it is validated and saved. */
382
+ trim(condition = true) { this._trim = condition; return this; }
383
+ hydrateState(value) { return this._trim && typeof value === 'string' ? value.trim() : value; }
384
+ /** `trim()`: the submitted value is trimmed before it is validated and saved. */
385
+ stateForValidation(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
386
+ dehydrateState(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
387
+ _inputMode = null;
388
+ _autocapitalize = null;
389
+ /** Virtual keyboard: `none`, `text`, `decimal`, `numeric`, `tel`, `search`, `email` or `url`. */
390
+ inputMode(mode) { this._inputMode = mode; return this; }
391
+ /** `none`, `sentences`, `words` or `characters` (`true` / `false` for `on` / `off`). */
392
+ autocapitalize(mode = true) { this._autocapitalize = mode === true ? 'on' : mode === false ? 'off' : mode; return this; }
206
393
  /** Sends the state to the server on every keystroke, or on blur with `onBlur`. */
207
394
  live(options = {}) { this._live = options.onBlur ? 'blur' : 'live'; return this; }
208
395
  type(type) { this._type = type; return this; }
209
- email(condition = true) {
210
- if (condition) {
211
- this._type = 'email';
212
- this.rule({ name: 'email', passes: (v) => /^[^\s@]+@[^\s@]+$/.test(String(v)), message: (a) => `The ${a} field must be a valid email address.` });
213
- }
214
- return this;
215
- }
216
396
  password(condition = true) { this._isPassword = condition; return this; }
217
397
  revealable(condition = true) { this._isRevealable = condition; return this; }
218
398
  /** `true` → "on", `false` → "off", a string as is. */
@@ -223,7 +403,16 @@ export class TextInput extends Field {
223
403
  mask(pattern) { this._mask = pattern; return this; }
224
404
  /** Phone number: digits, spaces and common punctuation. */
225
405
  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.` }); }
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.` }); }
406
+ email(condition = true) {
407
+ if (!condition) {
408
+ if (this._type === 'email')
409
+ this._type = null;
410
+ this._rules = this._rules.filter((rule) => rule.name !== 'email');
411
+ return this;
412
+ }
413
+ this._type = 'email';
414
+ return this.rule({ name: 'email', passes: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)), message: (a) => `The ${a} field must be a valid email address.` });
415
+ }
227
416
  /** Web address: `url` input type, a valid http(s) value is required. */
228
417
  url() { this._type = 'url'; return this.rule({ name: 'url', passes: (v) => { try {
229
418
  return ['http:', 'https:'].includes(new URL(String(v)).protocol);
@@ -236,6 +425,18 @@ export class TextInput extends Field {
236
425
  async toHtml() {
237
426
  const isDisabled = await this.isDisabled();
238
427
  const revealable = this._isPassword && this._isRevealable;
428
+ // Affixes: label, icon and actions (only visible actions, mounted through this component).
429
+ const ctx = this.context();
430
+ const key = this.getKey();
431
+ const visibleActions = async (actions) => { const html = []; for (const action of actions)
432
+ if (await action.isVisible(ctx))
433
+ html.push(await action.iconButton().size('sm').toHtml(ctx, { schemaComponent: key })); return html; };
434
+ const prefixActions = await visibleActions(this._prefixActions);
435
+ const copyLabel = t('filament-forms::components.text_input.actions.copy.label');
436
+ const copyMessage = this._copyable?.message ?? t('filament-forms::components.text_input.actions.copy.message');
437
+ const suffixActions = [...(await visibleActions(this._suffixActions)), ...(this._copyable ? [`<button title="${e(copyLabel)}" aria-label="${e(copyLabel)}" type="button" class="fi-ac-icon-btn-action fi-icon-btn fi-size-sm" x-on:click="${e(`window.navigator.clipboard.writeText($el.closest('.fi-input-wrp').querySelector('input').value)\n$tooltip(${js(copyMessage)}, { theme: $store.theme, timeout: ${this._copyable.duration} })`)}">${icon('m-clipboard-document-list')}</button>`] : [])];
438
+ const hasPrefix = this._prefix !== null || this._prefixIcon !== null || prefixActions.length > 0;
439
+ const hasSuffix = this._suffix !== null || this._suffixIcon !== null || suffixActions.length > 0;
239
440
  const type = revealable ? 'password' : this._mask !== null ? 'text' : (this._type ?? (this._isPassword ? 'password' : this._numeric ? 'number' : 'text'));
240
441
  const model = `wire:model${this._live ? '.live' : ''}${this._live === 'blur' ? '.blur' : ''}`;
241
442
  const input = new Attributes()
@@ -243,16 +444,19 @@ export class TextInput extends Field {
243
444
  'aria-invalid': this.hasError() ? 'true' : null,
244
445
  'aria-describedby': this.getHelperTextId(),
245
446
  autocomplete: this._autocomplete, autofocus: this._autofocus, disabled: isDisabled, id: this.getId(),
246
- inputmode: this._numeric === 'decimal' ? 'decimal' : this._numeric === 'integer' ? 'numeric' : null,
447
+ autocapitalize: this._autocapitalize,
448
+ inputmode: this._inputMode ?? (this._numeric === 'decimal' ? 'decimal' : this._numeric === 'integer' ? 'numeric' : null),
449
+ list: this._datalist.length && !isDisabled && !this._readOnly ? `${this.getId()}-list` : null,
450
+ readonly: this._readOnly,
247
451
  max: phpNumber(this._maxValue), min: phpNumber(this._minValue),
248
452
  maxlength: this._maxLength, minlength: this._minLength, placeholder: this._placeholder,
249
- required: (await this.isRequired()) && !isDisabled, step: this._step ?? (this._numeric === 'decimal' ? 'any' : this._numeric === 'integer' ? 1 : null), type,
453
+ required: (await this.isRequired()) && !isDisabled && !this._readOnly, step: this._step ?? (this._numeric === 'decimal' ? 'any' : this._numeric === 'integer' ? 1 : null), type,
250
454
  // Modifiers (`.live`, `.blur`) also apply to `wire:model.fill`.
251
455
  [revealable ? model.replace('wire:model', 'wire:model.fill') : model]: this.getStatePath(),
252
456
  'x-bind:type': revealable ? "isPasswordRevealed ? 'text' : 'password'" : null,
253
457
  'x-mask': this._mask,
254
458
  })
255
- .class(['fi-input', { 'fi-revealable': revealable }]);
459
+ .class(['fi-input', { 'fi-input-has-inline-prefix': this._prefixInline && hasPrefix, 'fi-input-has-inline-suffix': this._suffixInline && hasSuffix, 'fi-revealable': revealable }]);
256
460
  const wrapper = new Attributes()
257
461
  .merge({
258
462
  'x-data': revealable ? 'passwordRevealFormComponent()' : this._mask !== null ? '{}' : null,
@@ -263,49 +467,58 @@ export class TextInput extends Field {
263
467
  .class(['fi-fo-text-input', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
264
468
  return this.wrap(views.render('filament-forms::components.text-input', {
265
469
  input: input.toHtml(), wrapper: wrapper.toHtml(), revealable, prefix: this._prefix, suffix: this._suffix,
470
+ prefixIcon: this._prefixIcon ? icon(this._prefixIcon, { attributes: new Attributes().class(this._prefixIconColor && this._prefixIconColor !== 'gray' ? ['fi-color', `fi-color-${this._prefixIconColor}`] : []) }) : '',
471
+ suffixIcon: this._suffixIcon ? icon(this._suffixIcon, { attributes: new Attributes().class(this._suffixIconColor && this._suffixIconColor !== 'gray' ? ['fi-color', `fi-color-${this._suffixIconColor}`] : []) }) : '',
472
+ prefixActions, suffixActions, hasPrefix, hasSuffix, prefixInline: this._prefixInline, suffixInline: this._suffixInline,
473
+ datalist: this._datalist.length && !isDisabled && !this._readOnly ? { id: `${this.getId()}-list`, options: this._datalist } : null,
266
474
  showLabel: t('filament-forms::components.text_input.actions.show_password.label'),
267
475
  hideLabel: t('filament-forms::components.text_input.actions.hide_password.label'),
268
476
  }));
269
477
  }
270
478
  }
271
- /** Native date picker (`<input type="date">`). */
272
- export class DatePicker extends Field {
273
- _maxDate = null;
274
- _minDate = null;
275
- _live = false;
276
- static make(name) { return configured(new DatePicker(name)); }
277
- _datePlaceholder = null;
278
- /** Placeholder attribute (browsers do not display it on a native `date` input). */
279
- placeholder(text) { this._datePlaceholder = text; return this; }
280
- /** Sends the state to the server as soon as it changes (`wire:model.live`). */
281
- live(condition = true) { this._live = condition; return this; }
282
- /** The state is a `YYYY-MM-DD` date, whatever the stored precision. */
283
- hydrateState(value) { return typeof value === 'string' && value.length > 10 ? value.slice(0, 10) : value; }
284
- static resolve(date) { return date === 'today' ? new Date().toISOString().slice(0, 10) : date; }
285
- async bound(date) { const value = await evaluate(date, this.context()); return value ? DatePicker.resolve(value) : null; }
286
- /** Latest accepted date: `'today'`, a `YYYY-MM-DD` date, or a context function (`({ get }) => get('endDate') || 'today'`). */
287
- maxDate(date) {
288
- this._maxDate = date;
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'}.` });
290
- }
291
- minDate(date) {
292
- this._minDate = date;
293
- return this.rule({ name: 'after_or_equal', passes: async (v) => { const min = await this.bound(date); return min === null || String(v).slice(0, 10) >= min; }, message: (a) => `The ${a} field must be a date after or equal to ${typeof date === 'string' ? date : 'the minimum date'}.` });
294
- }
295
- async toHtml() {
296
- const isDisabled = await this.isDisabled();
297
- const input = new Attributes()
298
- .merge({
299
- 'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
300
- max: await this.bound(this._maxDate), min: await this.bound(this._minDate), placeholder: this._datePlaceholder,
301
- required: (await this.isRequired()) && !isDisabled, type: 'date', [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath(),
302
- })
303
- .class(['fi-input']);
304
- const wrapper = new Attributes()
305
- .merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
306
- .class(['fi-fo-date-time-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
307
- return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
308
- }
479
+ const flattenOptions = (groups) => groups.flatMap((item) => (Array.isArray(item) ? [item] : item.options));
480
+ function normalizeOptions(options) {
481
+ const entries = Array.isArray(options) ? options : Object.entries(options);
482
+ return entries.map(([key, value]) => {
483
+ if (value !== null && typeof value === 'object')
484
+ return { label: String(key), options: (Array.isArray(value) ? value : Object.entries(value)).map(([v, l]) => [String(v), String(l)]) };
485
+ return [String(key), String(value)];
486
+ });
487
+ }
488
+ /** Objection model of the form (the host's data adapter), for relationships. */
489
+ function modelOfForm(ctx, explicit) {
490
+ if (explicit)
491
+ return explicit;
492
+ const host = ctx.livewire;
493
+ const adapter = host.getDataAdapter?.() ?? host.getDataAdapterForTable?.() ?? host.resource?.adapter ?? null;
494
+ const model = adapter?.model;
495
+ if (typeof model !== 'function' || typeof model.getRelation !== 'function')
496
+ throw new Error('relationship(): the form has no Objection model; pass it with relationship(name, title, modify, { model }).');
497
+ return model;
498
+ }
499
+ /**
500
+ * Copy of a component and of the components it holds (layout children, item schemas), so that each repeated item gets its own
501
+ * instances: components keep a reference to their container.
502
+ */
503
+ export function cloneComponentTree(component) {
504
+ const copy = Object.assign(Object.create(Object.getPrototypeOf(component)), component);
505
+ for (const [property, value] of Object.entries(copy)) {
506
+ if (value instanceof Component)
507
+ copy[property] = cloneComponentTree(value);
508
+ else if (Array.isArray(value) && value.length && value.every((item) => item instanceof Component))
509
+ copy[property] = value.map((item) => cloneComponentTree(item));
510
+ }
511
+ return copy;
512
+ }
513
+ /** Prefix or suffix of an input wrapper: actions, icon and label (escaped). */
514
+ function inputAffixHtml(side, options) {
515
+ if (options.label === null && !options.icon && !options.actions.length)
516
+ return '';
517
+ const label = options.label !== null ? `<span class="fi-input-wrp-label">${e(options.label)}</span>` : '';
518
+ const actions = options.actions.length ? `<div class="fi-input-wrp-actions">${options.actions.join('')}</div>` : '';
519
+ const focus = options.label !== null || options.icon ? ` x-on:click="if (! $event.target.closest('.fi-input-wrp-actions')) $dispatch('focus-input')"` : '';
520
+ const classes = cls([`fi-input-wrp-${side}`, side === 'prefix' ? 'fi-input-wrp-prefix-has-content' : null, { 'fi-inline': options.inline, [`fi-input-wrp-${side}-has-label`]: options.label !== null }]);
521
+ return `<div${focus} class="${classes}">${side === 'prefix' ? actions + options.icon + label : label + options.icon + actions}</div>`;
309
522
  }
310
523
  /**
311
524
  * Dropdown. Native (`<select>`) by default; `searchable()` switches to the JavaScript select,
@@ -315,6 +528,15 @@ export class Select extends Field {
315
528
  _options = [];
316
529
  _placeholder = null;
317
530
  _searchable = false;
531
+ _native = true;
532
+ _allowHtml = false;
533
+ _maxItems = null;
534
+ _minItems = null;
535
+ _optionsLimit = 50;
536
+ _searchDebounce = 1000;
537
+ _messages = {};
538
+ _relationship = null;
539
+ _affixes = { prefix: null, suffix: null, prefixIcon: null, suffixIcon: null, prefixIconColor: null, suffixIconColor: null, prefixActions: [], suffixActions: [], prefixInline: false, suffixInline: false };
318
540
  _dynamic = null;
319
541
  _searchOnServer = null;
320
542
  _live = false;
@@ -335,6 +557,97 @@ export class Select extends Field {
335
557
  * `searchOnServer: false`: search runs in the browser, among the options already sent (for a complete list).
336
558
  */
337
559
  options(options, config = {}) { this._options = options; this._dynamic = config.dynamic ?? null; this._searchOnServer = config.searchOnServer ?? null; return this; }
560
+ /** "Yes" / "No" options, saved as `1` / `0`. */
561
+ boolean(trueLabel = null, falseLabel = null, placeholder = null) {
562
+ this._options = [['1', trueLabel ?? t('filament-forms::components.select.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.select.boolean.false')]];
563
+ if (placeholder !== null)
564
+ this._placeholder = placeholder;
565
+ return this;
566
+ }
567
+ /** `false`: the JavaScript select, even without search. */
568
+ native(condition = true) { this._native = condition; return this; }
569
+ /** Option labels are HTML (sanitized on the server; JavaScript select only). */
570
+ allowHtml(condition = true) { this._allowHtml = condition; return this; }
571
+ /** Maximum (and minimum) number of selected items of a multiple select, checked on the server. */
572
+ maxItems(count) { this._maxItems = count; return this; }
573
+ minItems(count) { this._minItems = count; return this; }
574
+ /** Number of options shown at once in the JavaScript select (50 by default). */
575
+ optionsLimit(limit) { this._optionsLimit = Math.max(1, Math.floor(limit)); return this; }
576
+ searchDebounce(ms) { this._searchDebounce = Math.max(0, Math.floor(ms)); return this; }
577
+ loadingMessage(message) { this._messages.loading = message; return this; }
578
+ noSearchResultsMessage(message) { this._messages.noSearchResults = message; return this; }
579
+ searchPrompt(message) { this._messages.searchPrompt = message; return this; }
580
+ searchingMessage(message) { this._messages.searching = message; return this; }
581
+ noOptionsMessage(message) { this._messages.noOptions = message; return this; }
582
+ prefix(label, isInline = false) { this._affixes.prefix = label; this._affixes.prefixInline = isInline; return this; }
583
+ suffix(label, isInline = false) { this._affixes.suffix = label; this._affixes.suffixInline = isInline; return this; }
584
+ prefixIcon(name, isInline = false) { this._affixes.prefixIcon = name; this._affixes.prefixInline = isInline; return this; }
585
+ suffixIcon(name, isInline = false) { this._affixes.suffixIcon = name; this._affixes.suffixInline = isInline; return this; }
586
+ prefixIconColor(color) { this._affixes.prefixIconColor = color; return this; }
587
+ suffixIconColor(color) { this._affixes.suffixIconColor = color; return this; }
588
+ prefixAction(action, isInline = false) { this._affixes.prefixActions = [...this._affixes.prefixActions, action]; this._affixes.prefixInline = isInline; return this; }
589
+ suffixAction(action, isInline = false) { this._affixes.suffixActions = [...this._affixes.suffixActions, action]; this._affixes.suffixInline = isInline; return this; }
590
+ prefixActions(actions) { this._affixes.prefixActions = actions; return this; }
591
+ suffixActions(actions) { this._affixes.suffixActions = actions; return this; }
592
+ /**
593
+ * Options from a relationship of the form's model (Objection `relationMappings`): a belongs-to relationship (the field is the
594
+ * foreign key, `Select.make('author_id').relationship('author', 'name')`), or a many-to-many one with `multiple()` (the field is
595
+ * named after the relationship, and saves the links once the record is saved). `modifyQueryUsing(query, search)` scopes the
596
+ * options; picked keys outside that scope are rejected. With `searchable()`, the options are searched on the server.
597
+ */
598
+ relationship(name, titleAttribute = null, modifyQueryUsing = null, options = {}) {
599
+ this._relationship = { name, title: titleAttribute, modify: modifyQueryUsing, model: options.model ?? null };
600
+ return this;
601
+ }
602
+ relationInfo() {
603
+ const ctx = this.context();
604
+ const model = modelOfForm(ctx, this._relationship.model);
605
+ const relation = model.getRelation(this._relationship.name);
606
+ const related = relation.relatedModelClass;
607
+ const id = `${related.tableName}.${related.idColumn}`;
608
+ const title = `${related.tableName}.${this._relationship.title ?? related.idColumn}`;
609
+ const isMany = /ManyToMany/.test(relation.constructor?.name ?? '') || typeof relation.joinTable === 'string';
610
+ return { model, relation, related, id, title, isMany };
611
+ }
612
+ async relationshipOptions(search, keys, limit = 1000) {
613
+ const { related, id, title } = this.relationInfo();
614
+ const query = related.query();
615
+ this._relationship.modify?.(query, search ?? undefined);
616
+ if (keys !== null) {
617
+ if (!keys.length)
618
+ return [];
619
+ query.whereIn(id, keys.slice(0, 1000));
620
+ }
621
+ if (search)
622
+ query.where(title, 'like', `%${search.slice(0, 255)}%`);
623
+ const rows = await query.clearSelect().select({ __key: id, __title: title }).orderBy(title).limit(limit);
624
+ return rows.map((row) => [String(row.__key), String(row.__title ?? row.__key)]);
625
+ }
626
+ isManyRelationship() { return this._relationship !== null && this.relationInfo().isMany; }
627
+ async savesRelationships() { return (await super.savesRelationships()) || this.isManyRelationship(); }
628
+ async loadStateFromRelationships(record) {
629
+ if (!this.isManyRelationship())
630
+ return super.loadStateFromRelationships(record);
631
+ const { model, related } = this.relationInfo();
632
+ const rows = await model.relatedQuery(this._relationship.name).for(record[model.idColumn]).clearSelect().select(`${related.tableName}.${related.idColumn} as __key`);
633
+ return { state: rows.map((row) => String(row.__key)) };
634
+ }
635
+ /** Many-to-many: links the picked records and unlinks the others (only keys allowed by the relationship's query). */
636
+ async saveRelationships(record) {
637
+ if (!this.isManyRelationship())
638
+ return super.saveRelationships(record);
639
+ const { model, related } = this.relationInfo();
640
+ const key = record[model.idColumn];
641
+ const desired = new Set((await this.relationshipOptions(null, this.selectedValues())).map(([value]) => value));
642
+ const current = (await this.loadStateFromRelationships(record)).state;
643
+ const removed = current.filter((value) => !desired.has(value));
644
+ if (removed.length)
645
+ await model.relatedQuery(this._relationship.name).for(key).unrelate().whereIn(`${related.tableName}.${related.idColumn}`, removed);
646
+ for (const value of desired)
647
+ if (!current.includes(value))
648
+ await model.relatedQuery(this._relationship.name).for(key).relate(related.idColumn === 'id' && /^\d+$/.test(value) ? Number(value) : value);
649
+ await super.saveRelationships(record);
650
+ }
338
651
  /** Disables an option depending on the context (e.g. already picked in another row). */
339
652
  disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
340
653
  /** Sends the state to the server as soon as it changes. */
@@ -356,33 +669,52 @@ export class Select extends Field {
356
669
  async labelsOf(values) {
357
670
  if (!values.length)
358
671
  return [];
359
- const known = this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
672
+ const known = this._relationship ? await this.relationshipOptions(null, values) : this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
360
673
  const map = new Map((Array.isArray(known) ? known : Object.entries(known)).map(([value, label]) => [String(value), String(label)]));
361
674
  return values.filter((value) => map.has(value)).map((value) => [value, map.get(value)]);
362
675
  }
363
- /** No-op: options are always loaded with the form. */
364
- preload() { return this; }
676
+ _preload = false;
677
+ /** Relationship with `searchable()`: loads the options with the form, instead of on search only. Other options are always loaded. */
678
+ preload(condition = true) { this._preload = condition; return this; }
365
679
  hydrateState(value) {
366
680
  if (this._multiple)
367
681
  return (Array.isArray(value) ? value : []).map(String);
368
682
  return value === null || value === undefined || value === '' ? null : String(value);
369
683
  }
370
684
  dehydrateState(state) { return this._multiple ? this.selectedValues() : state; }
371
- async resolveOptions() {
685
+ /** Options with their groups (labels sanitized when `allowHtml()`). */
686
+ async resolveGroupedOptions() {
687
+ if (this._relationship)
688
+ return this._searchable && !this._preload ? [] : this.relationshipOptions(null, null);
372
689
  const options = typeof this._options === 'function' ? await this._options(this.context()) : this._options;
373
- return (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
690
+ const groups = normalizeOptions(options);
691
+ if (!this._allowHtml)
692
+ return groups;
693
+ const clean = ([value, label]) => [value, sanitizeHtml(label)];
694
+ return groups.map((item) => (Array.isArray(item) ? clean(item) : { label: item.label, options: item.options.map(clean) }));
695
+ }
696
+ async resolveOptions() { return flattenOptions(await this.resolveGroupedOptions()); }
697
+ itemCountRules() {
698
+ const count = (v) => (Array.isArray(v) ? new Set(v.map(String)).size : 0);
699
+ return [
700
+ ...(this._minItems !== null ? [{ name: 'min', passes: (v) => count(v) >= this._minItems, message: (a) => `The ${a} field must have at least ${this._minItems} items.` }] : []),
701
+ ...(this._maxItems !== null ? [{ name: 'max', passes: (v) => count(v) <= this._maxItems, message: (a) => `The ${a} field must not have more than ${this._maxItems} items.` }] : []),
702
+ ];
374
703
  }
375
704
  async getValidationRules() {
376
705
  // With server search, a value is valid if the server can label it; otherwise it must be among the options.
377
706
  if (this._multiple)
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.` }];
379
- const passes = async (v) => (this._searchResultsUsing ? (await this._optionLabelUsing?.(String(v), this.context())) != null : (await this.resolveOptions()).some(([value]) => value === String(v)));
707
+ return [...(await super.getValidationRules()), ...this.itemCountRules(), { 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.` }];
708
+ const passes = async (v) => (this._relationship ? (await this.relationshipOptions(null, [String(v)])).length === 1 : this._searchResultsUsing ? (await this._optionLabelUsing?.(String(v), this.context())) != null : (await this.resolveOptions()).some(([value]) => value === String(v)));
380
709
  return [...(await super.getValidationRules()), { name: 'in', passes, message: (a) => `The selected ${a} is invalid.` }];
381
710
  }
382
711
  // Methods exposed to the select's JavaScript (called through callSchemaComponentMethod).
383
712
  getExposedMethods() { return ['getOptionLabel', 'getSearchResultsForJs', 'getOptionsForJs', 'getOptionLabelsForJs']; }
384
713
  static forJs(options) { return options.map(([value, label]) => ({ label, value, isDisabled: false })); }
385
- async getOptionsForJs() { return this.withDisabled(Select.forJs(await this.resolveOptions())); }
714
+ groupsForJs(groups) {
715
+ return groups.map((item) => (Array.isArray(item) ? this.withDisabled(Select.forJs([item]))[0] : { label: item.label, options: this.withDisabled(Select.forJs(item.options)) }));
716
+ }
717
+ async getOptionsForJs() { return this.groupsForJs(await this.resolveGroupedOptions()); }
386
718
  withDisabled(options) {
387
719
  if (!this._disableOptionWhen)
388
720
  return options;
@@ -390,17 +722,21 @@ export class Select extends Field {
390
722
  return options.map((option) => ({ ...option, isDisabled: this._disableOptionWhen(option.value, ctx) }));
391
723
  }
392
724
  async getSearchResultsForJs(search = '') {
725
+ if (this._relationship)
726
+ return this.withDisabled(Select.forJs(await this.relationshipOptions(String(search ?? '').slice(0, 1000), null, this._optionsLimit)));
393
727
  if (this._searchResultsUsing) {
394
728
  const found = await this._searchResultsUsing(String(search ?? '').slice(0, 1000), this.context());
395
729
  return Select.forJs((Array.isArray(found) ? found : Object.entries(found)).map(([value, label]) => [String(value), String(label)]));
396
730
  }
397
731
  const needle = String(search ?? '').toLowerCase();
398
- return Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle)));
732
+ return this.withDisabled(Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle))));
399
733
  }
400
734
  async getOptionLabel() {
401
735
  const state = this.context().state;
402
736
  if (state === null || state === undefined || state === '')
403
737
  return null;
738
+ if (this._relationship)
739
+ return (await this.relationshipOptions(null, [String(state)]))[0]?.[1] ?? null;
404
740
  if (this._optionLabelUsing)
405
741
  return this._optionLabelUsing(String(state), this.context());
406
742
  return (await this.resolveOptions()).find(([value]) => value === String(state))?.[1] ?? null;
@@ -449,9 +785,16 @@ export class Select extends Field {
449
785
  .modalSubmitActionLabel(tr('modal.actions.create.label'))
450
786
  .schema(this._createOptionForm)
451
787
  .action(async ({ data }) => {
452
- if (!this._createOptionUsing)
788
+ let created;
789
+ if (this._createOptionUsing)
790
+ created = await this._createOptionUsing(data, this.context());
791
+ else if (this._relationship) {
792
+ const { related } = this.relationInfo();
793
+ const record = await related.query().insert(data);
794
+ created = record[related.idColumn];
795
+ }
796
+ else
453
797
  throw new Error(`Select [${this.getStatePath()}]: createOptionUsing() is required with createOptionForm().`);
454
- const created = await this._createOptionUsing(data, this.context());
455
798
  if (created === null || created === undefined || created === '')
456
799
  return;
457
800
  this.setState(this._multiple ? [...new Set([...this.selectedValues(), String(created)])] : String(created));
@@ -481,36 +824,53 @@ export class Select extends Field {
481
824
  return this._modifyEditOptionAction?.(action) ?? action;
482
825
  }
483
826
  /** Actions mounted on the field: hint, create option and edit option. */
484
- getActions() { return [...this.getHintActions(), ...[this.getCreateOptionAction(), this.getEditOptionAction()].filter((action) => action !== null)]; }
485
- /** "+" and "edit" buttons on the right of the field. */
827
+ getActions() { return [...this.getHintActions(), ...this._affixes.prefixActions, ...this._affixes.suffixActions, ...[this.getCreateOptionAction(), this.getEditOptionAction()].filter((action) => action !== null)]; }
828
+ affixIcon(name, color) { return name ? icon(name, { attributes: new Attributes().class(color && color !== 'gray' ? ['fi-color', `fi-color-${color}`] : []) }) : ''; }
829
+ async affixActionsHtml(actions) {
830
+ const ctx = this.context();
831
+ const html = [];
832
+ for (const action of actions)
833
+ if (await action.isVisible(ctx))
834
+ html.push(await action.iconButton().size('sm').toHtml(ctx, { schemaComponent: this.getKey() }));
835
+ return html;
836
+ }
837
+ async prefixHtml() {
838
+ const a = this._affixes;
839
+ return inputAffixHtml('prefix', { label: a.prefix, icon: this.affixIcon(a.prefixIcon, a.prefixIconColor), actions: await this.affixActionsHtml(a.prefixActions), inline: a.prefixInline });
840
+ }
841
+ /** Suffix: label, icon, actions, then the "+" and "edit" buttons. */
486
842
  async suffixHtml() {
487
843
  const ctx = this.context();
488
844
  const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.getKey() };
489
- const buttons = [];
845
+ const buttons = await this.affixActionsHtml(this._affixes.suffixActions);
490
846
  for (const action of [this.getCreateOptionAction(), this.getEditOptionAction()]) {
491
847
  if (action && (await action.isVisible(ctx)))
492
848
  buttons.push((await action.toLinkHtml(ctx, context, '')).replace(' wire:key=""', ''));
493
849
  }
494
- return buttons.length ? `<div class="fi-input-wrp-suffix"><div class="fi-input-wrp-actions">${buttons.join('')}</div></div>` : '';
850
+ const a = this._affixes;
851
+ return inputAffixHtml('suffix', { label: a.suffix, icon: this.affixIcon(a.suffixIcon, a.suffixIconColor), actions: buttons, inline: a.suffixInline });
495
852
  }
496
853
  async toHtml() {
497
854
  const isDisabled = await this.isDisabled();
498
- const resolved = await this.resolveOptions();
855
+ const grouped = await this.resolveGroupedOptions();
856
+ const resolved = flattenOptions(grouped);
499
857
  const suffix = await this.suffixHtml();
500
- if (this._searchable) {
858
+ const prefix = await this.prefixHtml();
859
+ if (this._searchable || this._multiple || !this._native || this._allowHtml) {
501
860
  const isRequired = (await this.isRequired()) && !isDisabled;
502
861
  const after = isRequired ? `<span id="${e(this.getId())}-required" class="fi-sr-only">${e(t('filament-forms::components.select.required_description'))}</span>` : '';
503
- return this.wrap(await this.toJsHtml(resolved, isDisabled, suffix), { wrapperClass: 'fi-fo-select-wrp', after });
862
+ return this.wrap(await this.toJsHtml(grouped, isDisabled, suffix, prefix), { wrapperClass: 'fi-fo-select-wrp', after });
504
863
  }
505
864
  const select = new Attributes()
506
865
  .merge({ 'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(), required: (await this.isRequired()) && !isDisabled, 'wire:key': typeof this._options === 'function' ? `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify(resolved)).digest('hex')}` : null, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath() })
507
866
  .class(['fi-select-input']);
508
867
  const wrapper = new Attributes().merge({ 'x-on:focus-input.stop': "$el.querySelector('select')?.focus()" }).class(['fi-fo-select', 'fi-fo-select-native', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
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('');
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' });
868
+ const option = ([value, label]) => `<option value="${e(value)}"${this._disableOptionWhen?.(value, this.context()) ? ' disabled' : ''}>${e(label)}</option>`;
869
+ const options = [this._selectablePlaceholder ? `<option value="">${isDisabled ? '' : e(this._placeholder ?? t('filament-forms::components.select.placeholder'))}</option>` : '', ...grouped.map((item) => (Array.isArray(item) ? option(item) : `<optgroup label="${e(item.label)}">${item.options.map(option).join('')}</optgroup>`))].join('');
870
+ return this.wrap(`<div ${wrapper.toHtml()}>${prefix}<div class="fi-input-wrp-content-ctn"><select ${select.toHtml()}>${options}</select></div>${suffix}</div>`, { wrapperClass: 'fi-fo-select-wrp' });
511
871
  }
512
872
  /** JavaScript select rendering. */
513
- async toJsHtml(options, isDisabled, suffix = '') {
873
+ async toJsHtml(grouped, isDisabled, suffix = '', prefix = '') {
514
874
  const state = this.context().state;
515
875
  const hasState = !this._multiple && state !== null && state !== undefined && state !== '';
516
876
  const selected = this._multiple ? await this.labelsOf(this.selectedValues()) : [];
@@ -518,7 +878,7 @@ export class Select extends Field {
518
878
  // Required field: a screen-reader instruction ("Select an option.") is linked to the select.
519
879
  const describedBy = [isRequired ? `${this.getId()}-required` : null, this.hasError() ? `${this.getId()}-error` : null].filter(Boolean).join(' ') || null;
520
880
  const accessibility = JSON.stringify({ 'aria-describedby': describedBy, 'aria-invalid': this.hasError() ? 'true' : null, 'aria-required': isRequired ? 'true' : null });
521
- const forJs = this.withDisabled(Select.forJs(options));
881
+ const forJs = this.groupsForJs(grouped);
522
882
  // Dynamic options come from a function, so they may change between requests.
523
883
  const hasDynamicOptions = this._dynamic ?? typeof this._options === 'function';
524
884
  const tr = (key, replace = {}) => t(`filament-forms::components.select.${key}`, replace);
@@ -528,41 +888,41 @@ export class Select extends Field {
528
888
  clearButtonLabel: ${js(tr('actions.clear.label'))},
529
889
  errorMessage: ${js(tr('error_message'))},
530
890
  hasDynamicOptions: ${hasDynamicOptions ? 'true' : 'false'},
531
- hasDynamicSearchResults: ${this._searchResultsUsing || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
532
- hasInitialNoOptionsMessage: ${this._searchResultsUsing || this._hasNoInitialOptions ? 'false' : 'true'},
891
+ hasDynamicSearchResults: ${this._searchResultsUsing || (this._relationship && this._searchable) || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
892
+ hasInitialNoOptionsMessage: ${this._searchResultsUsing || (this._relationship && this._searchable && !this._preload) || this._hasNoInitialOptions ? 'false' : 'true'},
533
893
  id: ${js(this.getId())},
534
894
  initialOptionLabel: ${js(hasState ? await this.getOptionLabel() : null)},
535
895
  initialOptionLabels: ${selected.length ? js(selected.map(([value, label]) => ({ label, value, isDisabled: false }))) : '[]'},
536
896
  initialState: ${this._multiple ? (selected.length ? js(selected.map(([value]) => value)) : '[]') : js(hasState ? String(state) : null)},
537
897
  isAutofocused: ${this._autofocus ? 'true' : 'false'},
538
898
  isDisabled: ${isDisabled ? 'true' : 'false'},
539
- isHtmlAllowed: false,
899
+ isHtmlAllowed: ${this._allowHtml ? 'true' : 'false'},
540
900
  isMultiple: ${this._multiple ? 'true' : 'false'},
541
901
  isReorderable: false,
542
- isSearchable: true,
902
+ isSearchable: ${this._searchable ? 'true' : 'false'},
543
903
  key: ${js(this.getKey())},
544
904
  livewireId: ${js(this.livewire.id)},
545
- loadingMessage: ${js(tr('loading_message'))},
546
- maxItems: null,
547
- maxItemsMessage: ${js(tr('max_items_message', { count: '' }))},
548
- noOptionsMessage: ${js(tr('no_options_message'))},
549
- noSearchResultsMessage: ${js(tr('no_search_results_message'))},
905
+ loadingMessage: ${js(this._messages.loading ?? tr('loading_message'))},
906
+ maxItems: ${this._maxItems ?? 'null'},
907
+ maxItemsMessage: ${js(tr('max_items_message', { count: String(this._maxItems ?? '') }))},
908
+ noOptionsMessage: ${js(this._messages.noOptions ?? tr('no_options_message'))},
909
+ noSearchResultsMessage: ${js(this._messages.noSearchResults ?? tr('no_search_results_message'))},
550
910
  options: ${forJs.length ? js(forJs) : '[]'},
551
- optionsLimit: 50,
911
+ optionsLimit: ${this._optionsLimit},
552
912
  placeholder: ${js(this._placeholder ?? tr('placeholder'))},
553
913
  position: null,
554
914
  removeButtonLabel: ${js(tr('actions.remove_option.label'))},
555
- searchDebounce: 1000,
556
- searchingMessage: ${js(tr('searching_message'))},
915
+ searchDebounce: ${this._searchDebounce},
916
+ searchingMessage: ${js(this._messages.searching ?? tr('searching_message'))},
557
917
  searchLabel: ${js(tr('search_label'))},
558
- searchPrompt: ${js(tr('search_prompt'))},
918
+ searchPrompt: ${js(this._messages.searchPrompt ?? tr('search_prompt'))},
559
919
  searchableOptionFields: ${js(['label'])},
560
920
  state: $wire.$entangle(${js(this.getStatePath())}, ${this._live ? 'true' : 'false'}),
561
921
  statePath: ${js(this.getStatePath())},
562
922
  })`;
563
923
  const wrapper = new Attributes().merge({ 'x-on:focus-input.stop': "$el.querySelector('.fi-select-input-btn')?.focus()" }).class(['fi-fo-select', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
564
924
  const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify(forJs)).digest('hex')}`;
565
- return `<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div class="fi-hidden" data-select-accessibility="${e(accessibility)}" x-data="{
925
+ return `<div ${wrapper.toHtml()}>${prefix}<div class="fi-input-wrp-content-ctn"><div class="fi-hidden" data-select-accessibility="${e(accessibility)}" x-data="{
566
926
  isDisabled: ${isDisabled ? 'true' : 'false'},
567
927
  init() {
568
928
  const container = $el.nextElementSibling
@@ -587,28 +947,106 @@ export class TableColumn {
587
947
  const ITEM_KEY = /^[A-Za-z0-9_-]{1,64}$/;
588
948
  const isItemKey = (key) => ITEM_KEY.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
589
949
  /**
590
- * List of repeated items sharing the same fields, displayed as a table.
950
+ * List of repeated items sharing the same fields: stacked cards (default), a table (`table()`), or one field per item (`simple()`).
591
951
  * The state is a map `item key → fields`; key order is display order.
592
- * Add, delete and move are component actions, mounted with `schemaComponent`.
952
+ * Add, delete, clone and move are component actions, mounted with `schemaComponent`.
593
953
  */
594
954
  export class Repeater extends Field {
595
955
  _schema = () => [];
596
956
  _columns = [];
597
957
  _defaultItems = 0;
598
958
  _maxItems = 500;
959
+ _minItems = null;
599
960
  _itemLinks = [];
961
+ _simple = null;
962
+ _grid = null;
963
+ _collapsible = false;
964
+ _collapsed = false;
965
+ _persistCollapsed = false;
966
+ _cloneable = false;
967
+ _addable = true;
968
+ _deletable = true;
969
+ _reorderableWithButtons = false;
970
+ _itemLabel = null;
971
+ _itemNumbers = false;
972
+ _truncateItemLabel = true;
973
+ _headingTag = 'h4';
974
+ _addActionLabel = null;
975
+ _addActionAlignment = null;
976
+ _labelBetweenItems = null;
977
+ _itemHeaders = true;
978
+ _relationship = null;
600
979
  static make(name) { return configured(new Repeater(name)); }
601
- /** Item fields: a function called for each item. */
602
- schema(factory) { this._schema = factory; return this; }
980
+ /** Item components: a function called for each item. */
981
+ schema(factory) { this._schema = typeof factory === 'function' ? factory : () => factory.map(cloneComponentTree); return this; }
603
982
  /** Table display, one column per field. */
604
983
  table(columns) { this._columns = columns; return this; }
984
+ isTable() { return this._columns.length > 0; }
985
+ /**
986
+ * One field per item, without a card: the state is saved as a list of values (`['a@example.com', 'b@example.com']`).
987
+ * The field is cloned for each item.
988
+ */
989
+ simple(field) { this._simple = field; return this; }
605
990
  defaultItems(count) { this._defaultItems = count; return this; }
606
991
  maxItems(count) { this._maxItems = count; return this; }
992
+ /** Minimum number of items, checked on the server. */
993
+ minItems(count) { this._minItems = count; return this; }
607
994
  _reorderable = true;
608
995
  /** `false`: items keep their order (e.g. a relationship without an order column). */
609
996
  reorderable(condition = true) { this._reorderable = condition; return this; }
997
+ _dragAndDrop = true;
998
+ /** Move up / move down buttons in each item header, besides drag and drop. */
999
+ reorderableWithButtons(condition = true) { this._reorderableWithButtons = condition; return this; }
1000
+ /** `false`: no drag and drop (buttons may still reorder, with `reorderableWithButtons()`). */
1001
+ reorderableWithDragAndDrop(condition = true) { this._dragAndDrop = condition; return this; }
1002
+ /** Reordering is off with `reorderable(false)`, and for a relationship without `orderColumn()` (the order could not be saved). */
1003
+ isReorderable() { return this._reorderable && !(this._relationship !== null && this._relationship.orderColumn === null && this._orderColumnPending === null); }
1004
+ canDrag() { return this.isReorderable() && this._dragAndDrop; }
1005
+ canMove() { return this.isReorderable() && (this._dragAndDrop || this._reorderableWithButtons); }
610
1006
  /** Link shown on each row (an item action pointing to a URL). */
611
1007
  extraItemLink(resolver) { this._itemLinks.push(resolver); return this; }
1008
+ /** Items laid out in a grid (`2`, or `{ md: 2, xl: 3 }`). */
1009
+ grid(columns = 2) { this._grid = columns; return this; }
1010
+ /** Items can be collapsed from their header; `collapsed()` collapses them on load. */
1011
+ collapsible(condition = true) { this._collapsible = condition; return this; }
1012
+ collapsed(condition = true) { this._collapsed = condition; if (condition)
1013
+ this._collapsible = true; return this; }
1014
+ /** Keeps each item's collapsed state in the browser (local storage). */
1015
+ persistCollapsed(condition = true) { this._persistCollapsed = condition; return this; }
1016
+ cloneable(condition = true) { this._cloneable = condition; return this; }
1017
+ /** `false`: no "Add" button (and adding is refused on the server). */
1018
+ addable(condition = true) { this._addable = condition; return this; }
1019
+ /** `false`: no delete button (and deleting is refused on the server). */
1020
+ deletable(condition = true) { this._deletable = condition; return this; }
1021
+ /** Item header label: a string, or a function of the item's state (`({ state }) => state.name ?? null`). Escaped. */
1022
+ itemLabel(label) { this._itemLabel = label; return this; }
1023
+ /** Item number after the label. */
1024
+ itemNumbers(condition = true) { this._itemNumbers = condition; return this; }
1025
+ truncateItemLabel(condition = true) { this._truncateItemLabel = condition; return this; }
1026
+ /** `false`: items have no header (and so no buttons): only their fields. */
1027
+ itemHeaders(condition = true) { this._itemHeaders = condition; return this; }
1028
+ addActionLabel(label) { this._addActionLabel = label; return this; }
1029
+ addActionAlignment(alignment) { this._addActionAlignment = alignment; return this; }
1030
+ labelBetweenItems(label) { this._labelBetweenItems = label; return this; }
1031
+ /**
1032
+ * Items are the records of a has-many relationship of the form's model (Objection `relationMappings`): loaded when the form is
1033
+ * filled, then created, updated and deleted once the record is saved. `orderColumn()` saves the items' order.
1034
+ */
1035
+ relationship(name = null, modifyQueryUsing = null, options = {}) {
1036
+ this._relationship = { name: name ?? this._name, modify: modifyQueryUsing, orderColumn: this._relationship?.orderColumn ?? null, model: options.model ?? null };
1037
+ return this;
1038
+ }
1039
+ orderColumn(column = 'sort') { if (this._relationship)
1040
+ this._relationship.orderColumn = column;
1041
+ else
1042
+ this._orderColumnPending = column; return this; }
1043
+ _orderColumnPending = null;
1044
+ itemComponents() {
1045
+ if (this._simple)
1046
+ return [cloneComponentTree(this._simple).hiddenLabel()];
1047
+ return this._schema();
1048
+ }
1049
+ itemFields() { return this.itemComponents().filter((component) => component instanceof Field); }
612
1050
  /** Well-formed items only: keys come from the browser, so only simple keys are kept. */
613
1051
  getItems() {
614
1052
  const state = this.context().state;
@@ -617,28 +1055,118 @@ export class Repeater extends Field {
617
1055
  return Object.fromEntries(Object.entries(state).filter(([key, item]) => isItemKey(key) && item !== null && typeof item === 'object' && !Array.isArray(item)).slice(0, this._maxItems));
618
1056
  }
619
1057
  setItems(items) { writePath(this.livewire, this.getStatePath().split('.'), items); }
620
- itemDefaults() { return Object.fromEntries(this._schema().map((field) => [field.getName(), field.getDefaultState()])); }
1058
+ itemDefaults() {
1059
+ const data = {};
1060
+ fillSchema(new Schema(this.container.livewire).statePath('').components(this.itemComponents()), data);
1061
+ return data;
1062
+ }
621
1063
  getDefaultState() { return Object.fromEntries(Array.from({ length: this._defaultItems }, () => [randomUUID(), this.itemDefaults()])); }
622
- /** Relationship records → `record-<id>` items, limited to the declared fields. */
1064
+ /** A list (records → `record-<id>` items; simple values → one-field items) or a keyed map, limited to the declared fields. */
623
1065
  hydrateState(value) {
1066
+ if (typeof value === 'string') {
1067
+ try {
1068
+ value = JSON.parse(value);
1069
+ }
1070
+ catch {
1071
+ value = null;
1072
+ }
1073
+ }
624
1074
  if (!Array.isArray(value))
625
1075
  return value && typeof value === 'object' ? value : {};
626
- const fields = this._schema();
627
- return Object.fromEntries(value.map((row) => [`record-${row.id}`, Object.fromEntries(fields.map((field) => [field.getName(), field.hydrateState(row[field.getName()] ?? field.getDefaultState())]))]));
1076
+ const fields = this.itemFields();
1077
+ return Object.fromEntries(value.slice(0, this._maxItems).map((row) => {
1078
+ if (this._simple)
1079
+ return [randomUUID(), { [this._simple.getName()]: this._simple.hydrateState(row) }];
1080
+ const key = row && typeof row === 'object' && row.id !== undefined && row.id !== null ? `record-${row.id}` : randomUUID();
1081
+ return [key, Object.fromEntries(fields.map((field) => [field.getName(), field.hydrateState(row?.[field.getName()] ?? field.getDefaultState())]))];
1082
+ }));
628
1083
  }
629
1084
  dehydrateState() { return Object.fromEntries(Object.keys(this.getItems()).map((key) => [key, {}])); }
1085
+ /** Simple repeater: saved as a list of values. */
1086
+ finalizeDehydratedState(state) {
1087
+ if (!this._simple || !state || typeof state !== 'object' || Array.isArray(state))
1088
+ return state;
1089
+ const name = this._simple.getName();
1090
+ return Object.values(state).map((item) => item?.[name] ?? null);
1091
+ }
630
1092
  getChildSchemas() {
631
1093
  const container = this.container;
632
- return Object.keys(this.getItems()).map((key) => new Schema(container.livewire).key(`${this.getKey()}.${key}`).statePath(`${this.getStatePath()}.${key}`).operation(container.getOperation()).record(container.getRecord()).root(false).components(this._schema()));
1094
+ return Object.keys(this.getItems()).map((key) => new Schema(container.livewire).key(`${this.getKey()}.${key}`).statePath(`${this.getStatePath()}.${key}`).operation(container.getOperation()).record(container.getRecord()).root(false).parentComponent(this).columns(this._childColumns ?? 1).components(this.itemComponents()));
633
1095
  }
1096
+ _childColumns = null;
1097
+ /** Grid columns inside each item. */
1098
+ columns(columns) { this._childColumns = columns; return this; }
634
1099
  async getValidationRules() {
635
1100
  const rules = [];
1101
+ const count = () => Object.keys(this.getItems()).length;
636
1102
  if (await this.isRequired())
637
- rules.push({ name: 'required', passes: () => Object.keys(this.getItems()).length > 0, message: (a) => `The ${a} field is required.` });
1103
+ rules.push({ name: 'required', passes: () => count() > 0, message: (a) => `The ${a} field is required.` });
1104
+ if (this._minItems !== null)
1105
+ rules.push({ name: 'min', implicit: true, passes: () => count() >= this._minItems, message: (a) => `The ${a} field must have at least ${this._minItems} items.` });
1106
+ rules.push({ name: 'max', implicit: true, passes: () => count() <= this._maxItems, message: (a) => `The ${a} field must not have more than ${this._maxItems} items.` });
638
1107
  return [...rules, ...this._rules];
639
1108
  }
1109
+ // ---- relationship
1110
+ relationInfo() {
1111
+ const model = modelOfForm(this.context(), this._relationship.model);
1112
+ const relation = model.getRelation(this._relationship.name);
1113
+ return { model, relation, related: relation.relatedModelClass };
1114
+ }
1115
+ async savesRelationships() { return (await super.savesRelationships()) || this._relationship !== null; }
1116
+ relatedQuery(record) {
1117
+ const { model } = this.relationInfo();
1118
+ const query = model.relatedQuery(this._relationship.name).for(record[model.idColumn]);
1119
+ this._relationship.modify?.(query);
1120
+ return query;
1121
+ }
1122
+ async loadStateFromRelationships(record) {
1123
+ if (!this._relationship)
1124
+ return super.loadStateFromRelationships(record);
1125
+ if (this._orderColumnPending !== null) {
1126
+ this._relationship.orderColumn = this._orderColumnPending;
1127
+ this._orderColumnPending = null;
1128
+ }
1129
+ const { related } = this.relationInfo();
1130
+ const query = this.relatedQuery(record);
1131
+ if (this._relationship.orderColumn)
1132
+ query.orderBy(`${related.tableName}.${this._relationship.orderColumn}`);
1133
+ const rows = await query.limit(this._maxItems);
1134
+ return { state: rows.map((row) => ({ ...row, id: row[related.idColumn] })) };
1135
+ }
1136
+ /** Creates, updates and deletes the related records; only records of this owner (and query) are touched. */
1137
+ async saveRelationships(record) {
1138
+ if (!this._relationship)
1139
+ return super.saveRelationships(record);
1140
+ if (this._orderColumnPending !== null) {
1141
+ this._relationship.orderColumn = this._orderColumnPending;
1142
+ this._orderColumnPending = null;
1143
+ }
1144
+ const { related } = this.relationInfo();
1145
+ const existing = new Map((await this.relatedQuery(record)).map((row) => [String(row[related.idColumn]), row]));
1146
+ const kept = new Set();
1147
+ const keys = Object.keys(this.getItems());
1148
+ const schemas = this.getChildSchemas();
1149
+ for (const [index, key] of keys.entries()) {
1150
+ const data = await getSchemaState(schemas[index]);
1151
+ if (this._relationship.orderColumn)
1152
+ data[this._relationship.orderColumn] = index + 1;
1153
+ const id = key.startsWith('record-') ? key.slice('record-'.length) : null;
1154
+ if (id !== null && existing.has(id)) {
1155
+ kept.add(id);
1156
+ await related.query().findById(existing.get(id)[related.idColumn]).patch(data);
1157
+ }
1158
+ else {
1159
+ await this.relatedQuery(record).insert(data);
1160
+ }
1161
+ }
1162
+ const removed = [...existing.keys()].filter((id) => !kept.has(id));
1163
+ if (removed.length)
1164
+ await related.query().whereIn(related.idColumn, removed.map((id) => existing.get(id)[related.idColumn])).delete();
1165
+ }
640
1166
  getActions() {
641
1167
  const move = (offset) => ({ arguments: args }) => {
1168
+ if (!this.canMove())
1169
+ return;
642
1170
  const items = this.getItems();
643
1171
  const keys = Object.keys(items);
644
1172
  const from = keys.indexOf(String(args.item));
@@ -648,16 +1176,26 @@ export class Repeater extends Field {
648
1176
  [keys[from], keys[to]] = [keys[to], keys[from]];
649
1177
  this.setItems(Object.fromEntries(keys.map((key) => [key, items[key]])));
650
1178
  };
1179
+ const insertAfter = (items, after, key, item) => Object.fromEntries(Object.entries(items).flatMap(([k, v]) => (k === after ? [[k, v], [key, item]] : [[k, v]])));
651
1180
  return [
652
1181
  ...this.getHintActions(),
653
- Action.make('add').action(() => { const items = this.getItems(); if (Object.keys(items).length >= this._maxItems)
1182
+ Action.make('add').action(() => { const items = this.getItems(); if (!this._addable || Object.keys(items).length >= this._maxItems)
654
1183
  return; items[randomUUID()] = this.itemDefaults(); this.setItems(items); }),
655
- Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!Object.hasOwn(items, key))
1184
+ Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!this._deletable || !Object.hasOwn(items, key))
656
1185
  return; delete items[key]; this.setItems(items); }),
1186
+ Action.make('clone').action(({ arguments: args }) => {
1187
+ const items = this.getItems();
1188
+ const key = String(args.item);
1189
+ if (!this._cloneable || !Object.hasOwn(items, key) || Object.keys(items).length >= this._maxItems)
1190
+ return;
1191
+ this.setItems(insertAfter(items, key, randomUUID(), JSON.parse(JSON.stringify(items[key]))));
1192
+ }),
657
1193
  Action.make('moveUp').action(move(-1)),
658
1194
  Action.make('moveDown').action(move(1)),
659
1195
  // Drag and drop: the browser sends the new order, only accepted if it is a permutation of the existing keys.
660
1196
  Action.make('reorder').action(({ arguments: args }) => {
1197
+ if (!this.canDrag())
1198
+ return;
661
1199
  const items = this.getItems();
662
1200
  const keys = Object.keys(items);
663
1201
  const order = Array.isArray(args.items) ? args.items.map(String) : [];
@@ -667,9 +1205,85 @@ export class Repeater extends Field {
667
1205
  }),
668
1206
  ];
669
1207
  }
1208
+ async itemLabelOf(key, index, item) {
1209
+ const label = typeof this._itemLabel === 'function' ? await this._itemLabel({ state: item, key, index }) : this._itemLabel;
1210
+ return label === null || label === undefined || label === '' ? null : String(label);
1211
+ }
670
1212
  async toHtml() {
671
1213
  if (!colorManager)
672
1214
  throw new Error('No color manager: call useColorManager().');
1215
+ if (this.isTable())
1216
+ return this.toTableHtml();
1217
+ const id = this.getId();
1218
+ const key = this.getKey();
1219
+ const items = this.getItems();
1220
+ const keys = Object.keys(items);
1221
+ const schemas = this.getChildSchemas();
1222
+ const tr = (name, replace = {}) => t(`filament-forms::components.repeater.${name}`, replace);
1223
+ const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: key };
1224
+ const expression = (action, args) => `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
1225
+ const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
1226
+ const danger = colorManager.getComponentClasses('icon-button', 'danger');
1227
+ const gray = colorManager.getComponentClasses('icon-button', 'gray');
1228
+ const button = (options) => {
1229
+ const loading = options.expression ? `${icon(options.icon, { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': options.expression }) })}${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': options.expression }))}` : icon(options.icon);
1230
+ return `<button title="${e(options.label)}"${options.disabled ? ' aria-disabled="true"' : ''} aria-label="${e(options.label)}"${options.disabled ? ' disabled="disabled"' : ''} type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${cls(['fi-ac-icon-btn-action', 'fi-icon-btn', { 'fi-disabled': options.disabled === true }, 'fi-size-sm', ...(options.colors ?? [])])}"${options.extra ?? ''}${options.expression ? ` wire:click="${e(options.expression)}"` : ''}>${loading}</button>`;
1231
+ };
1232
+ const rows = [];
1233
+ for (const [index, itemKey] of keys.entries()) {
1234
+ const isFirst = index === 0, isLast = index === keys.length - 1;
1235
+ const moveButton = (action, label, iconName, direction, disabled) => {
1236
+ const expr = expression(action, { item: itemKey });
1237
+ return `<li x-on:click.stop${this._reorderableWithButtons ? '' : ' class="fi-sr-only"'}>${button({ label, icon: iconName, expression: expr, disabled, extra: `${this._reorderableWithButtons ? '' : ' tabindex="-1"'} data-reorder-hidden="data-reorder-hidden" data-reorder-direction="${direction}" data-reorder-action="${e(expr)}" aria-describedby="${e(descriptionId(itemKey))}"` })}</li>`;
1238
+ };
1239
+ const content = await schemas[index].toHtml();
1240
+ if (this._simple) {
1241
+ const actions = [
1242
+ ...(this.canDrag() && keys.length > 1 ? [`<li x-on:click.stop>${button({ label: tr('actions.reorder.label'), icon: 'm-arrows-up-down', expression: null, extra: ' x-sortable-handle="x-sortable-handle"' })}</li>`] : []),
1243
+ ...(this.canMove() && keys.length > 1 ? [moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', isFirst), moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', isLast)] : []),
1244
+ ...(this._cloneable ? [`<li x-on:click.stop>${button({ label: tr('actions.clone.label'), icon: 'm-square-2-stack', expression: expression('clone', { item: itemKey }), colors: gray })}</li>`] : []),
1245
+ ...(this._deletable ? [`<li x-on:click.stop>${button({ label: tr('actions.delete.label'), icon: 'm-trash', expression: expression('delete', { item: itemKey }), colors: danger })}</li>`] : []),
1246
+ ];
1247
+ rows.push(`<li wire:key="${e(this.getLivewireKey() ?? '')}.${e(itemKey)}.item" x-sortable-item="${e(itemKey)}" class="fi-fo-simple-repeater-item"><div class="fi-fo-simple-repeater-item-content">${content}</div>${actions.length ? `<ul class="fi-fo-simple-repeater-item-actions">${actions.join('')}</ul>` : ''}</li>`);
1248
+ continue;
1249
+ }
1250
+ const label = await this.itemLabelOf(itemKey, index, items[itemKey]);
1251
+ const start = [
1252
+ ...(this.canDrag() && keys.length > 1 ? [`<li x-on:click.stop>${button({ label: tr('actions.reorder.label'), icon: 'm-arrows-up-down', expression: null, extra: ' x-sortable-handle="x-sortable-handle"' })}</li>`] : []),
1253
+ ...(this.canMove() && keys.length > 1 ? [moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', isFirst), moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', isLast)] : []),
1254
+ ];
1255
+ const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null && /^(\/(?!\/)|https?:\/\/)/.test(link.url))
1256
+ .map((link) => `<li x-on:click.stop><a href="${e(link.url)}" target="_blank" rel="noopener" aria-label="${e(link.label)}" title="${e(link.label)}" class="${cls(['fi-ac-icon-btn-action', 'fi-icon-btn', 'fi-size-sm', ...gray])}">${icon(link.icon)}</a></li>`);
1257
+ const end = [
1258
+ ...links,
1259
+ ...(this._cloneable ? [`<li x-on:click.stop>${button({ label: tr('actions.clone.label'), icon: 'm-square-2-stack', expression: expression('clone', { item: itemKey }), colors: gray })}</li>`] : []),
1260
+ ...(this._deletable ? [`<li x-on:click.stop>${button({ label: tr('actions.delete.label'), icon: 'm-trash', expression: expression('delete', { item: itemKey }), colors: danger })}</li>`] : []),
1261
+ ...(this._collapsible ? [`<li class="fi-fo-repeater-item-header-collapsible-actions" x-on:click.stop="toggleCollapsed"><div class="fi-fo-repeater-item-header-collapse-action">${button({ label: tr('actions.collapse.label'), icon: 'm-chevron-up', expression: null, colors: gray })}</div><div class="fi-fo-repeater-item-header-expand-action">${button({ label: tr('actions.expand.label'), icon: 'm-chevron-down', expression: null, colors: gray })}</div></li>`] : []),
1262
+ ];
1263
+ const hasHeader = this._itemHeaders && (start.length > 0 || label !== null || end.length > 0);
1264
+ const header = hasHeader ? `<div${this._collapsible ? ' x-on:click.stop="toggleCollapsed"' : ''} class="fi-fo-repeater-item-header">${start.length ? `<ul class="fi-fo-repeater-item-header-start-actions">${start.join('')}</ul>` : ''}${label !== null ? `<${this._headingTag} class="${cls(['fi-fo-repeater-item-header-label', { 'fi-truncated': this._truncateItemLabel }])}">${e(label)}${this._itemNumbers ? ` ${index + 1}` : ''}</${this._headingTag}>` : ''}${end.length ? `<ul class="fi-fo-repeater-item-header-end-actions">${end.join('')}</ul>` : ''}</div>` : '';
1265
+ const itemConfig = { isCollapsed: this._collapsed, collapseKey: this._persistCollapsed ? `filament:repeater:collapsed:${key}-${itemKey}` : null };
1266
+ rows.push(`<li wire:ignore.self wire:key="${e(this.getLivewireKey() ?? '')}.${e(itemKey)}.item" x-data="item(${e(js(itemConfig))})" x-on:repeater-expand.window="expandFromEvent" x-on:repeater-collapse.window="collapseFromEvent" x-on:expand="expand" x-sortable-item="${e(itemKey)}" class="${cls(['fi-fo-repeater-item', { 'fi-fo-repeater-item-has-header': hasHeader }])}" x-bind:class="itemClasses">${header}<div x-show="! isCollapsed" class="fi-fo-repeater-item-content">${content}</div></li>`);
1267
+ if (!isLast && this._labelBetweenItems)
1268
+ rows.push(`<li class="fi-fo-repeater-label-between-items-ctn"><div class="fi-fo-repeater-label-between-items-divider-before"></div><span class="fi-fo-repeater-label-between-items">${e(this._labelBetweenItems)}</span><div class="fi-fo-repeater-label-between-items-divider-after"></div></li>`);
1269
+ }
1270
+ const prefix = this._simple ? 'fi-fo-simple-repeater' : 'fi-fo-repeater';
1271
+ const gridAttributes = this._grid !== null ? new Attributes().grid(this._grid).toHtml() : '';
1272
+ const gridClass = /class="([^"]*)"/.exec(gridAttributes)?.[1] ?? '';
1273
+ const gridStyle = /style="([^"]*)"/.exec(gridAttributes)?.[1] ?? null;
1274
+ const list = keys.length ? `<ul x-sortable${gridStyle ? ` style="${gridStyle}"` : ''} data-sortable-animation-duration="300" x-on:end.stop="reorder" x-ref="reorderItems" class="${cls([gridClass, `${prefix}-items`])}">${rows.join('')}</ul>` : '';
1275
+ const label = await this.getLabel();
1276
+ const addExpr = expression('add', null);
1277
+ const add = this._addable && keys.length < this._maxItems ? `<div class="${cls([`${prefix}-add`, this._addActionAlignment ? `fi-align-${this._addActionAlignment}` : null])}"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm" wire:click="${e(addExpr)}">${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': addExpr }), 'sm')}${e(this._addActionLabel ?? tr('actions.add.label', { label: label.toLowerCase() }))}</button></div>` : '';
1278
+ const collapseAll = this._collapsible && !this._simple ? `<div class="${cls(['fi-fo-repeater-actions', { 'fi-hidden': keys.length < 2 }])}"><span x-on:click="collapseAll"><button type="button" class="${cls(['fi-ac-link-action', 'fi-link', 'fi-size-sm', ...colorManager.getComponentClasses('link', 'gray')])}"><span class="fi-link-label">${e(tr('actions.collapse_all.label'))}</span></button></span><span x-on:click="expandAll"><button type="button" class="${cls(['fi-ac-link-action', 'fi-link', 'fi-size-sm', ...colorManager.getComponentClasses('link', 'gray')])}"><span class="fi-link-label">${e(tr('actions.expand_all.label'))}</span></button></span></div>` : '';
1279
+ const itemLabel = tr('reordering.item');
1280
+ const descriptions = keys.map((itemKey, index) => `<span class="fi-sr-only" data-reorder-description="${e(itemKey)}" data-reorder-label="${e(itemLabel)}" id="${e(descriptionId(itemKey))}">${e(tr('reordering.position', { label: itemLabel, position: String(index + 1), count: String(keys.length) }))}</span>`).join('');
1281
+ const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
1282
+ const html = `<div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" data-reorder-owner="data-reorder-owner" data-reorder-items="${e(JSON.stringify(keys))}" x-load="x-load" x-load-src="${e(asset('repeater'))}" x-data="repeaterFormComponent(${e(js(config))})" class="${cls([prefix, { 'fi-collapsible': this._collapsible && !this._simple }])}"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span>${collapseAll}${list}${add}${descriptions}</div>`;
1283
+ return this.wrap(html, { labelAsDiv: true });
1284
+ }
1285
+ async toTableHtml() {
1286
+ const colors = colorManager;
673
1287
  const id = this.getId();
674
1288
  const key = this.getKey();
675
1289
  const livewireKey = this.getLivewireKey() ?? '';
@@ -686,7 +1300,7 @@ export class Repeater extends Field {
686
1300
  const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
687
1301
  const rows = [];
688
1302
  // No reorder column while there is only one row.
689
- const hasReorderColumn = this._reorderable && keys.length > 1;
1303
+ const hasReorderColumn = this.canDrag() && keys.length > 1;
690
1304
  for (const [index, itemKey] of keys.entries()) {
691
1305
  const moveButton = (action, label, iconName, direction, disabled) => {
692
1306
  const expr = expression(action, { item: itemKey });
@@ -695,68 +1309,291 @@ export class Repeater extends Field {
695
1309
  const cells = [];
696
1310
  for (const field of schemas[index].getComponents())
697
1311
  cells.push(`<td>${await field.toSchemaHtml(await field.isVisible())}</td>`);
698
- const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null)
1312
+ // Only local or http(s) URLs: a link built from item data cannot become a `javascript:` URL.
1313
+ const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null && /^(\/(?!\/)|https?:\/\/)/.test(link.url))
699
1314
  .map((link) => `<div x-on:click.stop><a href="${e(link.url)}" target="_blank" x-tooltip="{
700
1315
  content: ${e(js(link.label))},
701
1316
  theme: $store.theme,
702
1317
  allowHTML: false,
703
1318
  }" aria-label="${e(link.label)}" class="${button} fi-size-sm">${icon(link.icon)}</a></div>`);
704
1319
  const deleteExpr = expression('delete', { item: itemKey });
705
- rows.push(`<tr wire:key="${e(livewireKey)}.${e(itemKey)}.item" x-sortable-item="${e(itemKey)}">${hasReorderColumn ? `<td><div class="fi-fo-table-repeater-actions"><div x-on:click.stop><button title="${e(tr('actions.reorder.label'))}" aria-label="${e(tr('actions.reorder.label'))}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${button} fi-size-sm" x-sortable-handle="x-sortable-handle">${icon('m-arrows-up-down')}</button></div>${moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', index === 0)}${moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', index === keys.length - 1)}</div></td>` : ''}${cells.join('')}<td><div class="fi-fo-table-repeater-actions">${links.join('')}<div x-on:click.stop><button title="${e(tr('actions.delete.label'))}" aria-label="${e(tr('actions.delete.label'))}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${[button, 'fi-size-sm', ...colorManager.getComponentClasses('icon-button', 'danger')].join(' ')}" wire:click="${e(deleteExpr)}">${actionIcon('m-trash', deleteExpr)}${loading(deleteExpr)}</button></div></div></td></tr>`);
1320
+ rows.push(`<tr wire:key="${e(livewireKey)}.${e(itemKey)}.item" x-sortable-item="${e(itemKey)}">${hasReorderColumn ? `<td><div class="fi-fo-table-repeater-actions"><div x-on:click.stop><button title="${e(tr('actions.reorder.label'))}" aria-label="${e(tr('actions.reorder.label'))}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${button} fi-size-sm" x-sortable-handle="x-sortable-handle">${icon('m-arrows-up-down')}</button></div>${moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', index === 0)}${moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', index === keys.length - 1)}</div></td>` : ''}${cells.join('')}<td><div class="fi-fo-table-repeater-actions">${links.join('')}${this._deletable ? `<div x-on:click.stop><button title="${e(tr('actions.delete.label'))}" aria-label="${e(tr('actions.delete.label'))}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${[button, 'fi-size-sm', ...colorManager.getComponentClasses('icon-button', 'danger')].join(' ')}" wire:click="${e(deleteExpr)}">${actionIcon('m-trash', deleteExpr)}${loading(deleteExpr)}</button></div>` : ''}</div></td></tr>`);
706
1321
  }
707
1322
  const head = this._columns.map((column) => `<th scope="col"${column.getWidth() ? ` style="width: ${e(column.getWidth())};"` : ''}>${e(column.label)}</th>`).join('');
708
1323
  const addExpr = expression('add', null);
709
1324
  const label = await this.getLabel();
710
1325
  const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
711
1326
  const descriptions = keys.map((itemKey, index) => `<span class="fi-sr-only" data-reorder-description="${e(itemKey)}" data-reorder-label="${e(itemLabel)}" id="${e(descriptionId(itemKey))}">${e(tr('reordering.position', { label: itemLabel, position: String(index + 1), count: String(keys.length) }))}</span>`).join('');
712
- const table = `<div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" data-reorder-owner="data-reorder-owner" data-reorder-items="${e(JSON.stringify(keys))}" x-load="x-load" x-load-src="${e(asset('repeater'))}" x-data="repeaterFormComponent(${e(js(config))})" class="fi-fo-table-repeater"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span><table><thead><tr>${hasReorderColumn ? `<th scope="col" class="fi-fo-table-repeater-empty-header-cell"><span class="fi-sr-only">${e(tr('columns.reorder.label'))}</span></th>` : ''}${head}<th scope="col" class="fi-fo-table-repeater-empty-header-cell"><span class="fi-sr-only">${e(tr('columns.actions.label'))}</span></th></tr></thead><tbody x-sortable data-sortable-animation-duration="300" x-on:end.stop="reorder" x-ref="reorderItems">${rows.join('')}</tbody></table>${keys.length < this._maxItems ? `<div class="fi-fo-table-repeater-add"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm" wire:click="${e(addExpr)}">${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': addExpr }), 'sm')}${e(tr('actions.add.label', { label: label.toLowerCase() }))}</button></div>` : ''}${descriptions}</div>`;
1327
+ const table = `<div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" data-reorder-owner="data-reorder-owner" data-reorder-items="${e(JSON.stringify(keys))}" x-load="x-load" x-load-src="${e(asset('repeater'))}" x-data="repeaterFormComponent(${e(js(config))})" class="fi-fo-table-repeater"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span><table><thead><tr>${hasReorderColumn ? `<th scope="col" class="fi-fo-table-repeater-empty-header-cell"><span class="fi-sr-only">${e(tr('columns.reorder.label'))}</span></th>` : ''}${head}<th scope="col" class="fi-fo-table-repeater-empty-header-cell"><span class="fi-sr-only">${e(tr('columns.actions.label'))}</span></th></tr></thead><tbody x-sortable data-sortable-animation-duration="300" x-on:end.stop="reorder" x-ref="reorderItems">${rows.join('')}</tbody></table>${this._addable && keys.length < this._maxItems ? `<div class="fi-fo-table-repeater-add"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm" wire:click="${e(addExpr)}">${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': addExpr }), 'sm')}${e(this._addActionLabel ?? tr('actions.add.label', { label: label.toLowerCase() }))}</button></div>` : ''}${descriptions}</div>`;
713
1328
  return this.wrap(table, { labelAsDiv: true });
714
1329
  }
715
1330
  }
716
- /** Native time input: the state is `HH:MM` with `seconds(false)`, `HH:MM:SS` otherwise. */
717
- export class TimePicker extends Field {
718
- _seconds = true;
719
- static make(name) { return configured(new TimePicker(name)); }
720
- seconds(condition = true) { this._seconds = condition; return this; }
721
- hydrateState(value) { return typeof value === 'string' && value.length >= 5 ? value.slice(value.length > 8 ? 11 : 0).slice(0, this._seconds ? 8 : 5) : value; }
722
- async toHtml() {
723
- const isDisabled = await this.isDisabled();
724
- const input = new Attributes()
725
- .merge({
726
- 'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
727
- required: (await this.isRequired()) && !isDisabled, step: this._seconds ? 1 : null, type: 'time', 'wire:model': this.getStatePath(),
728
- })
729
- .class(['fi-input']);
730
- const wrapper = new Attributes()
731
- .merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
732
- .class(['fi-fo-date-time-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
733
- return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
1331
+ /** PHP date format (`M j, Y H:i`) to day.js (`MMM D, YYYY HH:mm`), as Filament's `convert_date_format()`. Escaped characters (`\at`) become literals. */
1332
+ export function phpDateFormatToDayjs(format) {
1333
+ const tokens = { d: 'DD', D: 'ddd', j: 'D', l: 'dddd', N: 'd', w: 'd', S: '', F: 'MMMM', m: 'MM', M: 'MMM', n: 'M', Y: 'YYYY', y: 'YY', a: 'a', A: 'A', g: 'h', G: 'H', h: 'hh', H: 'HH', i: 'mm', s: 'ss', v: 'SSS', e: 'z', T: 'z', P: 'Z', O: 'ZZ', U: 'X' };
1334
+ let out = '';
1335
+ for (let index = 0; index < format.length; index++) {
1336
+ const char = format[index];
1337
+ if (char === '\\' && index + 1 < format.length) {
1338
+ out += `[${format[++index]}]`;
1339
+ continue;
1340
+ }
1341
+ out += Object.hasOwn(tokens, char) ? tokens[char] : /[a-zA-Z]/.test(char) ? `[${char}]` : char;
734
1342
  }
1343
+ return out;
735
1344
  }
736
- /** Native `datetime-local` input: the saved state is `YYYY-MM-DD HH:MM:SS`. */
737
- export class DateTimePicker extends Field {
1345
+ const pad = (n, size = 2) => String(n).padStart(size, '0');
1346
+ /** `YYYY-MM-DD HH:MM:SS` of a wall-clock time in a time zone, from a UTC instant. */
1347
+ function formatInZone(date, timeZone) {
1348
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).formatToParts(date).map((part) => [part.type, part.value]));
1349
+ return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
1350
+ }
1351
+ /** UTC instant of a wall-clock `YYYY-MM-DD HH:MM:SS` in a time zone. */
1352
+ function zonedToUtc(value, timeZone) {
1353
+ const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(value);
1354
+ if (!match)
1355
+ return null;
1356
+ const asUtc = Date.UTC(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +(match[6] ?? 0));
1357
+ // Offset of the zone at that moment (twice, to settle around DST changes).
1358
+ let guess = asUtc;
1359
+ for (let i = 0; i < 2; i++) {
1360
+ const shown = Date.parse(formatInZone(new Date(guess), timeZone).replace(' ', 'T') + 'Z');
1361
+ guess = asUtc - (shown - guess);
1362
+ }
1363
+ return new Date(guess);
1364
+ }
1365
+ /** Normalizes a date-like value to `YYYY-MM-DD HH:MM:SS` (or null). */
1366
+ function normalizeDateTime(value) {
1367
+ if (value instanceof Date)
1368
+ return Number.isNaN(value.getTime()) ? null : `${value.getUTCFullYear()}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())} ${pad(value.getUTCHours())}:${pad(value.getUTCMinutes())}:${pad(value.getUTCSeconds())}`;
1369
+ if (typeof value !== 'string' || value === '')
1370
+ return null;
1371
+ const match = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
1372
+ if (match)
1373
+ return `${match[1]} ${match[2] ?? '00'}:${match[3] ?? '00'}:${match[4] ?? '00'}`;
1374
+ const time = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
1375
+ return time ? `1970-01-01 ${time[1]}:${time[2]}:${time[3] ?? '00'}` : null;
1376
+ }
1377
+ /**
1378
+ * Base of the date, time and date-time pickers: native browser input by default, or Filament's JavaScript picker with
1379
+ * `native(false)` (display format, disabled dates, first day of the week, locale). Limits and disabled dates are checked on the server.
1380
+ */
1381
+ class TemporalField extends Field {
738
1382
  _seconds = true;
739
- static make(name) { return configured(new DateTimePicker(name)); }
1383
+ _native = true;
1384
+ _maxDate = null;
1385
+ _minDate = null;
1386
+ _displayFormat = null;
1387
+ _format = null;
1388
+ _firstDayOfWeek = 1;
1389
+ _disabledDates = [];
1390
+ _closeOnDateSelection = false;
1391
+ _locale = null;
1392
+ _timezone = null;
1393
+ _hoursStep = 1;
1394
+ _minutesStep = 1;
1395
+ _secondsStep = 1;
1396
+ _defaultFocusedDate = null;
1397
+ _temporalPlaceholder = null;
740
1398
  seconds(condition = true) { this._seconds = condition; return this; }
741
- dehydrateState(state) {
742
- if (typeof state !== 'string' || state === '')
743
- return state === '' ? null : state;
744
- const value = state.replace('T', ' ');
745
- return value.length === 16 ? `${value}:00` : value;
1399
+ hasSeconds() { return this.hasTime() && this._seconds; }
1400
+ /** `false`: Filament's JavaScript picker instead of the browser's native input. */
1401
+ native(condition = true) { this._native = condition; return this; }
1402
+ isNative() { return this._native; }
1403
+ /** Display format of the JavaScript picker, in PHP's notation (`'d/m/Y'`, `'M j, Y H:i'`). */
1404
+ displayFormat(format) { this._displayFormat = format; return this; }
1405
+ /** Format of the saved value, in PHP's notation (`'Y-m-d'` by default for dates, `'Y-m-d H:i:s'` with a time). */
1406
+ format(format) { this._format = format; return this; }
1407
+ /** First day of the week in the JavaScript picker: 0 (Sunday) to 6. */
1408
+ firstDayOfWeek(day) { this._firstDayOfWeek = Math.min(6, Math.max(0, Math.floor(day))); return this; }
1409
+ weekStartsOnMonday() { return this.firstDayOfWeek(1); }
1410
+ weekStartsOnSunday() { return this.firstDayOfWeek(0); }
1411
+ /** Dates that cannot be picked (`YYYY-MM-DD`), also rejected on the server. */
1412
+ disabledDates(dates) { this._disabledDates = dates; return this; }
1413
+ closeOnDateSelection(condition = true) { this._closeOnDateSelection = condition; return this; }
1414
+ /** Locale of the JavaScript picker's month and day names (`'fr'`). */
1415
+ locale(locale) { this._locale = locale; return this; }
1416
+ /** Shows and edits the value in this time zone, while it is saved in UTC (date-time pickers). */
1417
+ timezone(timezone) { if (timezone !== null)
1418
+ new Intl.DateTimeFormat('en', { timeZone: timezone }); this._timezone = timezone; return this; }
1419
+ hoursStep(step) { this._hoursStep = step; return this; }
1420
+ minutesStep(step) { this._minutesStep = step; return this; }
1421
+ secondsStep(step) { this._secondsStep = step; return this; }
1422
+ /** Date focused when the JavaScript picker opens on an empty field. */
1423
+ defaultFocusedDate(date) { this._defaultFocusedDate = date; return this; }
1424
+ placeholder(text) { this._temporalPlaceholder = text; return this; }
1425
+ static resolveLimit(date) {
1426
+ const day = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
1427
+ return date === 'today' ? day(0) : date === 'tomorrow' ? day(1) : date === 'yesterday' ? day(-1) : date;
1428
+ }
1429
+ async limit(date) { const value = await evaluate(date, this.context()); return value ? TemporalField.resolveLimit(String(value)) : null; }
1430
+ /** Latest accepted date: `'today'`, a date, or a context function (`({ get }) => get('ends_at')`). */
1431
+ maxDate(date) {
1432
+ this._maxDate = date;
1433
+ return this.rule({ name: 'before_or_equal', passes: async (v) => { const max = await this.limit(date); return max === null || this.comparable(v) <= this.comparable(max); }, message: (a) => `The ${a} field must be a date before or equal to ${typeof date === 'string' ? date : 'the maximum date'}.` });
1434
+ }
1435
+ minDate(date) {
1436
+ this._minDate = date;
1437
+ return this.rule({ name: 'after_or_equal', passes: async (v) => { const min = await this.limit(date); return min === null || this.comparable(v) >= this.comparable(min); }, message: (a) => `The ${a} field must be a date after or equal to ${typeof date === 'string' ? date : 'the minimum date'}.` });
1438
+ }
1439
+ /** Comparable string of a value: the date only for date pickers, the time only for time pickers. */
1440
+ comparable(value) {
1441
+ const normalized = normalizeDateTime(this.toUtc(value)) ?? String(value);
1442
+ if (!this.hasTime())
1443
+ return normalized.slice(0, 10);
1444
+ if (!this.hasDate())
1445
+ return normalized.slice(11, this._seconds ? 19 : 16);
1446
+ return normalized;
746
1447
  }
1448
+ async getValidationRules() {
1449
+ const rules = await super.getValidationRules();
1450
+ if (!this.hasDate())
1451
+ return rules;
1452
+ return [...rules,
1453
+ { name: 'date', passes: (v) => normalizeDateTime(v) !== null && !Number.isNaN(Date.parse(normalizeDateTime(v).replace(' ', 'T') + 'Z')), message: (a) => `The ${a} field must be a valid date.` },
1454
+ { name: 'not_in', passes: async (v) => { const disabled = (await evaluate(this._disabledDates, this.context())) ?? []; return !disabled.includes(String(normalizeDateTime(this.toUtc(v)) ?? '').slice(0, 10)); }, message: (a) => `The selected ${a} is invalid.` },];
1455
+ }
1456
+ /** Record value to form state, shown in the field's time zone. */
1457
+ hydrateState(value) {
1458
+ const normalized = normalizeDateTime(value);
1459
+ if (normalized === null)
1460
+ return value === undefined ? null : value === '' ? null : typeof value === 'string' ? value : null;
1461
+ let local = normalized;
1462
+ if (this._timezone && this.hasDate() && this.hasTime())
1463
+ local = formatInZone(new Date(normalized.replace(' ', 'T') + 'Z'), this._timezone);
1464
+ return this.stateOf(local);
1465
+ }
1466
+ /** Form state as the input expects it. */
1467
+ stateOf(local) {
1468
+ if (!this._native)
1469
+ return this.hasDate() ? local : `${new Date().toISOString().slice(0, 10)} ${local.slice(11)}`;
1470
+ if (!this.hasTime())
1471
+ return local.slice(0, 10);
1472
+ if (!this.hasDate())
1473
+ return local.slice(11, this._seconds ? 19 : 16);
1474
+ return local.slice(0, this._seconds ? 19 : 16);
1475
+ }
1476
+ /** Wall-clock value of the field's time zone to UTC. */
1477
+ toUtc(value) {
1478
+ if (!this._timezone || !this.hasDate() || !this.hasTime() || typeof value !== 'string')
1479
+ return value;
1480
+ const instant = zonedToUtc(value, this._timezone);
1481
+ return instant ? normalizeDateTime(instant) : value;
1482
+ }
1483
+ /** Saved value: in UTC, in `format()` (or the default format). */
1484
+ dehydrateState(state) {
1485
+ if (state === '' || state === null || state === undefined)
1486
+ return null;
1487
+ const normalized = normalizeDateTime(this.toUtc(typeof state === 'string' ? state.replace('T', ' ') : state));
1488
+ if (normalized === null)
1489
+ return state;
1490
+ if (this._format)
1491
+ return formatPhpDate(normalized, this._format);
1492
+ if (!this.hasTime())
1493
+ return normalized.slice(0, 10);
1494
+ if (!this.hasDate())
1495
+ return normalized.slice(11, this._seconds ? 19 : 16);
1496
+ return normalized;
1497
+ }
1498
+ defaultDisplayFormat() {
1499
+ if (!this.hasTime())
1500
+ return 'M j, Y';
1501
+ const time = this._seconds ? 'H:i:s' : 'H:i';
1502
+ return this.hasDate() ? `M j, Y ${time}` : time;
1503
+ }
1504
+ nativeType() { return !this.hasTime() ? 'date' : !this.hasDate() ? 'time' : 'datetime-local'; }
747
1505
  async toHtml() {
748
1506
  const isDisabled = await this.isDisabled();
749
- const input = new Attributes()
750
- .merge({
751
- 'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
752
- required: (await this.isRequired()) && !isDisabled, step: this._seconds ? 1 : null, type: 'datetime-local', 'wire:model': this.getStatePath(),
753
- })
754
- .class(['fi-input']);
1507
+ const min = await this.limit(this._minDate), max = await this.limit(this._maxDate);
755
1508
  const wrapper = new Attributes()
756
1509
  .merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
757
1510
  .class(['fi-fo-date-time-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
758
- return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
1511
+ const model = this.wireModel();
1512
+ if (this._native) {
1513
+ const limit = (value) => (value === null ? null : !this.hasTime() ? value.slice(0, 10) : !this.hasDate() ? (value.length > 8 ? value.slice(11) : value) : value.replace(' ', 'T'));
1514
+ const input = new Attributes()
1515
+ .merge({
1516
+ 'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
1517
+ max: limit(max), min: limit(min), placeholder: this._temporalPlaceholder,
1518
+ required: (await this.isRequired()) && !isDisabled, step: this.hasTime() && this._seconds ? 1 : null, type: this.nativeType(), [model]: this.getStatePath(),
1519
+ })
1520
+ .class(['fi-input']);
1521
+ return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
1522
+ }
1523
+ const id = this.getId();
1524
+ const hasDate = this.hasDate(), hasTime = this.hasTime(), hasSeconds = this.hasSeconds();
1525
+ const disabledDates = (await evaluate(this._disabledDates, this.context())) ?? [];
1526
+ const jsLimit = (value) => (value === null ? '' : normalizeDateTime(value.length <= 8 ? `1970-01-01 ${value}` : value) ?? value);
1527
+ const livewireKey = this.getLivewireKey() ?? '';
1528
+ const label = await this.getLabel();
1529
+ const tr = (key) => t(`filament-forms::components.date_time_picker.${key}`);
1530
+ const describedBy = [this.getHelperTextId(), this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
1531
+ const trigger = new Attributes().merge({ 'aria-describedby': describedBy, 'aria-invalid': this.hasError() ? 'true' : null, 'aria-controls': `${id}-panel`, 'aria-expanded': 'false', 'aria-haspopup': 'dialog', disabled: isDisabled, id, placeholder: this._temporalPlaceholder, role: 'combobox', type: 'text' }).class(['fi-fo-date-time-picker-trigger', 'fi-fo-date-time-picker-display-text-input']);
1532
+ const config = `dateTimePickerFormComponent({
1533
+ defaultFocusedDate: ${js(this._defaultFocusedDate)},
1534
+ displayFormat: ${js(phpDateFormatToDayjs(this._displayFormat ?? this.defaultDisplayFormat()))},
1535
+ firstDayOfWeek: ${this._firstDayOfWeek},
1536
+ hasDate: ${hasDate ? 'true' : 'false'},
1537
+ isAutofocused: ${this._autofocus && !isDisabled ? 'true' : 'false'},
1538
+ locale: ${js(this._locale ?? 'en')},
1539
+ shouldCloseOnDateSelection: ${this._closeOnDateSelection ? 'true' : 'false'},
1540
+ state: $wire.$entangle(${js(this.getStatePath())}${this.isLive() ? ', true' : ''}),
1541
+ })`;
1542
+ const keyHash = createHash('md5').update(JSON.stringify([disabledDates, isDisabled, max, min, hasDate, hasTime, hasSeconds])).digest('hex');
1543
+ const events = hasDate
1544
+ ? ` x-on:dropdown-escape="if (isOpen()) { closePanel(true); $event.preventDefault() }" x-on:focusout="handleFocusOut($event)" x-on:keydown.escape="if (isOpen()) { $event.preventDefault(); $event.stopPropagation(); closePanel(true) }" x-on:click.outside="isOpen() &amp;&amp; closePanel()" x-on:livewire:navigating.window="closePanel()"`
1545
+ : ` x-on:dropdown-escape="if (isOpen()) { $refs.panel.close(); $refs.button.focus(); $event.preventDefault() }" x-on:focusout="if (isOpen() &amp;&amp; ! $el.contains($event.relatedTarget)) $refs.panel.close()" x-on:keydown.esc="isOpen() &amp;&amp; $event.stopPropagation()"`;
1546
+ const buttonEvents = isDisabled ? (!hasDate ? ' x-on:keydown.enter.prevent.stop="if (false) { isOpen() ? selectDate() : togglePanelVisibility() }"' : '') : [
1547
+ !hasDate ? ' x-on:keydown.enter.prevent.stop="if (true) { isOpen() ? selectDate() : togglePanelVisibility() }"' : '',
1548
+ ' x-on:click="togglePanelVisibility()"',
1549
+ hasDate ? ' x-on:keydown="handleTriggerKeydown($event)" x-bind:aria-expanded="isPanelOpen.toString()"' : ' x-on:keydown.arrow-left.prevent.stop="focusPreviousDay()" x-on:keydown.arrow-right.prevent.stop="focusNextDay()" x-on:keydown.arrow-up.prevent.stop="focusPreviousWeek()" x-on:keydown.arrow-down.prevent.stop="focusNextWeek()"',
1550
+ ' x-on:keydown.backspace.prevent.stop="clearState()" x-on:keydown.clear.prevent.stop="clearState()" x-on:keydown.delete.prevent.stop="clearState()"',
1551
+ ].join('');
1552
+ const calendar = hasDate ? `<div class="fi-fo-date-time-picker-panel-header"><select aria-label="${e(tr('month_select.label'))}" x-model="focusedMonth" class="fi-fo-date-time-picker-month-select"><template x-for="(month, index) in months"><option x-bind:value="index" x-text="month"></option></template></select><input type="number" inputmode="numeric" aria-label="${e(tr('year_input.label'))}" x-model.debounce="focusedYear" class="fi-fo-date-time-picker-year-input" /></div><div aria-live="polite" aria-atomic="true" x-text="getCalendarLabel()" class="fi-fo-date-time-picker-calendar-description"></div><div x-ref="calendar" role="grid" tabindex="-1" x-bind:aria-label="getCalendarLabel()" x-on:keydown="handleCalendarKeydown($event)" class="fi-fo-date-time-picker-calendar"><div role="row" class="fi-fo-date-time-picker-calendar-header"><template x-for="(day, index) in dayLabels" x-bind:key="index"><div role="columnheader" x-bind:aria-label="fullDayLabels[index]" x-text="day" class="fi-fo-date-time-picker-calendar-header-day"></div></template></div><template x-for="(week, weekIndex) in weeksInFocusedMonth" x-bind:key="weekIndex"><div role="row" class="fi-fo-date-time-picker-calendar-week"><template x-for="(day, dayIndex) in week" x-bind:key="day ?? ('empty-' + dayIndex)"><div role="gridcell" x-text="day" x-bind:data-date="day ? focusedDate.date(day).format('YYYY-MM-DD') : null" x-bind:aria-label="day ? getDayLabel(day) : null" x-bind:aria-selected="day &amp;&amp; dayIsSelected(day) ? 'true' : 'false'" x-bind:aria-current="day &amp;&amp; dayIsToday(day) ? 'date' : null" x-bind:aria-disabled="day &amp;&amp; dayIsDisabled(day) ? 'true' : 'false'" x-bind:tabindex="day ? (focusedDate.date() === day ? 0 : -1) : null" x-on:focus="day &amp;&amp; setFocusedDay(day)" x-on:click="if (day) { $el.focus(); selectDate(day) }" x-bind:class="{ 'fi-fo-date-time-picker-calendar-day': day, 'fi-fo-date-time-picker-calendar-day-today': day &amp;&amp; dayIsToday(day), 'fi-focused': day &amp;&amp; focusedDate.date() === day, 'fi-selected': day &amp;&amp; dayIsSelected(day), 'fi-disabled': day &amp;&amp; dayIsDisabled(day) }"></div></template></div></template></div>` : '';
1553
+ const timeInput = (max, step, label, model) => `<input max="${max}" min="0" step="${step}" type="number" inputmode="numeric" aria-label="${e(label)}" x-on:blur="checkTimeInputValidity" x-on:invalid="timeInputInvalid" x-model.debounce="${model}" />`;
1554
+ const time = hasTime ? `<div class="fi-fo-date-time-picker-time-inputs">${timeInput(23, this._hoursStep, tr('hour_input.label'), 'hour')}<span class="fi-fo-date-time-picker-time-input-separator">:</span>${timeInput(59, this._minutesStep, tr('minute_input.label'), 'minute')}${hasSeconds ? `<span class="fi-fo-date-time-picker-time-input-separator">:</span>${timeInput(59, this._secondsStep, tr('second_input.label'), 'second')}` : ''}</div>` : '';
1555
+ const picker = `<div hidden data-field-accessibility${describedBy ? ` aria-describedby="${e(describedBy)}"` : ''}${this.hasError() ? ' aria-invalid="true"' : ''}></div><div x-load x-load-src="${e(asset('date-time-picker'))}" x-data="${e(config)}" wire:ignore wire:key="${e(livewireKey)}.${keyHash}"${events}><input x-ref="maxDate" type="hidden" value="${e(jsLimit(max))}" /><input x-ref="minDate" type="hidden" value="${e(jsLimit(min))}" /><input x-ref="disabledDates" type="hidden" value="${e(JSON.stringify(disabledDates))}" /><input x-ref="button"${buttonEvents} readonly wire:key="${e(livewireKey)}.display-text" x-model="displayText" ${trigger.toHtml()} /><div x-ref="panel" id="${e(id)}-panel" role="dialog" aria-label="${e(label)}" x-cloak${hasDate ? ' x-show="isPanelOpen"' : ' x-float.placement.bottom-start.offset.flip.shift="{ offset: 8 }"'} wire:ignore wire:key="${e(livewireKey)}.panel" class="fi-fo-date-time-picker-panel">${calendar}${time}</div></div>`;
1556
+ return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn">${picker}</div></div>`);
1557
+ }
1558
+ }
1559
+ /** Formats `YYYY-MM-DD HH:MM:SS` with a PHP date format (the usual tokens). */
1560
+ function formatPhpDate(value, format) {
1561
+ const [date, time] = value.split(' ');
1562
+ const [year, month, day] = date.split('-').map(Number);
1563
+ const [hour, minute, second] = (time ?? '00:00:00').split(':').map(Number);
1564
+ const tokens = {
1565
+ Y: () => String(year), y: () => pad(year % 100), m: () => pad(month), n: () => String(month), d: () => pad(day), j: () => String(day),
1566
+ H: () => pad(hour), G: () => String(hour), h: () => pad(hour % 12 || 12), g: () => String(hour % 12 || 12), i: () => pad(minute), s: () => pad(second),
1567
+ A: () => (hour < 12 ? 'AM' : 'PM'), a: () => (hour < 12 ? 'am' : 'pm'), U: () => String(Date.UTC(year, month - 1, day, hour, minute, second) / 1000),
1568
+ };
1569
+ let out = '';
1570
+ for (let index = 0; index < format.length; index++) {
1571
+ const char = format[index];
1572
+ if (char === '\\' && index + 1 < format.length) {
1573
+ out += format[++index];
1574
+ continue;
1575
+ }
1576
+ out += Object.hasOwn(tokens, char) ? tokens[char]() : char;
759
1577
  }
1578
+ return out;
1579
+ }
1580
+ /** Date picker: native `<input type="date">` by default; the saved state is `YYYY-MM-DD`. */
1581
+ export class DatePicker extends TemporalField {
1582
+ static make(name) { return configured(new DatePicker(name)); }
1583
+ hasDate() { return true; }
1584
+ hasTime() { return false; }
1585
+ }
1586
+ /** Time picker: native `<input type="time">` by default; the state is `HH:MM:SS` (`HH:MM` with `seconds(false)`). */
1587
+ export class TimePicker extends TemporalField {
1588
+ static make(name) { return configured(new TimePicker(name)); }
1589
+ hasDate() { return false; }
1590
+ hasTime() { return true; }
1591
+ }
1592
+ /** Date-time picker: native `datetime-local` input by default; the saved state is `YYYY-MM-DD HH:MM:SS`. */
1593
+ export class DateTimePicker extends TemporalField {
1594
+ static make(name) { return configured(new DateTimePicker(name)); }
1595
+ hasDate() { return true; }
1596
+ hasTime() { return true; }
760
1597
  }
761
1598
  /** Single choice as radio buttons. */
762
1599
  export class Radio extends Field {
@@ -771,8 +1608,16 @@ export class Radio extends Field {
771
1608
  /** Options side by side. */
772
1609
  inline(condition = true) { this._inline = condition; return this; }
773
1610
  live(condition = true) { this._live = condition; return this; }
1611
+ _disableOptionWhen = null;
1612
+ /** Options that cannot be picked; they are refused on the server too. */
1613
+ disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
1614
+ isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
1615
+ /** "Yes" (`1`) and "No" (`0`) options. */
1616
+ boolean(trueLabel = null, falseLabel = null) {
1617
+ return this.options([['1', trueLabel ?? t('filament-forms::components.radio.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.radio.boolean.false')]]);
1618
+ }
774
1619
  async getValidationRules() {
775
- return [...(await super.getValidationRules()), { name: 'in', passes: (v) => this._options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
1620
+ return [...(await super.getValidationRules()), { name: 'in', passes: (v) => this._options.some(([value, label]) => value === String(v) && !this.isOptionDisabled(value, label)), message: (a) => `The selected ${a} is invalid.` }];
776
1621
  }
777
1622
  async toHtml() {
778
1623
  const id = this.getId();
@@ -783,7 +1628,7 @@ export class Radio extends Field {
783
1628
  .class(['fi-fo-radio', this._inline ? 'fi-inline' : null]);
784
1629
  const items = this._options.map(([value, label]) => {
785
1630
  const input = new Attributes()
786
- .merge({ type: 'radio', disabled: isDisabled, id: `${id}-${value}`, name: id, required: isRequired, value, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath() })
1631
+ .merge({ type: 'radio', disabled: isDisabled || this.isOptionDisabled(value, label), id: `${id}-${value}`, name: id, required: isRequired, value, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath() })
787
1632
  .class(['fi-radio-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
788
1633
  const description = this._descriptions[value] ? `<p class="fi-fo-radio-label-description">${e(this._descriptions[value])}</p>` : '';
789
1634
  return `<label class="fi-fo-radio-label"><input ${input.toHtml()} /><div class="fi-fo-radio-label-text"><p>${e(label)}</p>${description}</div></label>`;
@@ -797,18 +1642,40 @@ export class Textarea extends Field {
797
1642
  static make(name) { return configured(new Textarea(name)); }
798
1643
  rows(rows) { this._rows = rows; return this; }
799
1644
  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.` }); }
1645
+ _minLength = null;
1646
+ _placeholder = null;
1647
+ _readOnly = false;
1648
+ _autosize = false;
1649
+ _grammarly = true;
1650
+ _trim = false;
1651
+ 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.` }); }
1652
+ /** Exact length. */
1653
+ length(length) { this.minLength(length); return this.maxLength(length); }
1654
+ placeholder(text) { this._placeholder = text; return this; }
1655
+ readOnly(condition = true) { this._readOnly = condition; return this; }
1656
+ /** The textarea grows with its content. */
1657
+ autosize(condition = true) { this._autosize = condition; return this; }
1658
+ /** Stops the Grammarly extension from analyzing the content. */
1659
+ disableGrammarly(condition = true) { this._grammarly = !condition; return this; }
1660
+ /** Removes the spaces around the value before it is validated and saved. */
1661
+ trim(condition = true) { this._trim = condition; return this; }
1662
+ hydrateState(value) { return this._trim && typeof value === 'string' ? value.trim() : value; }
1663
+ /** `trim()`: the submitted value is trimmed before it is validated and saved. */
1664
+ stateForValidation(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
1665
+ dehydrateState(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
800
1666
  async toHtml() {
801
1667
  const isDisabled = await this.isDisabled();
802
1668
  // Initial height: 1.5rem per row plus padding.
803
1669
  const height = (this._rows ?? 2) * 1.5 + 0.75;
804
1670
  const textarea = new Attributes().merge({
805
- 'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(), maxlength: this._maxLength,
806
- required: (await this.isRequired()) && !isDisabled, rows: this._rows, 'wire:model': this.getStatePath(),
1671
+ 'aria-invalid': this.hasError() ? 'true' : null, 'data-gramm': this._grammarly ? null : 'false', 'data-gramm_editor': this._grammarly ? null : 'false', 'data-enable-grammarly': this._grammarly ? null : 'false',
1672
+ disabled: isDisabled, id: this.getId(), maxlength: this._maxLength, minlength: this._minLength, placeholder: this._placeholder, readonly: this._readOnly,
1673
+ required: (await this.isRequired()) && !isDisabled && !this._readOnly, rows: this._rows, [this.wireModel()]: this.getStatePath(),
807
1674
  });
808
1675
  const wrapper = new Attributes().class(['fi-fo-textarea', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
809
1676
  return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div wire:ignore.self${height !== null ? ` style="height: ${height}rem"` : ''}><textarea x-load x-load-src="${e(asset('textarea'))}" x-data="textareaFormComponent({
810
1677
  initialHeight: ${height ?? 'null'},
811
- shouldAutosize: false,
1678
+ shouldAutosize: ${this._autosize ? 'true' : 'false'},
812
1679
  state: $wire.$entangle(${e(js(this.getStatePath()))}),
813
1680
  })" x-model="state" ${textarea.toHtml()}></textarea></div></div></div>`, { wrapperClass: 'fi-fo-textarea-wrp' });
814
1681
  }
@@ -824,9 +1691,28 @@ export class TagsInput extends Field {
824
1691
  suggestions(suggestions) { this._suggestions = suggestions.map(String).slice(0, 1000); return this; }
825
1692
  /** Keys that confirm the current tag, besides Enter (`['Tab', ',']`). */
826
1693
  splitKeys(keys) { this._splitKeys = keys; return this; }
1694
+ _separator = null;
1695
+ _tagPrefix = null;
1696
+ _tagSuffix = null;
1697
+ _reorderable = false;
1698
+ _tagColor = null;
1699
+ _tagRules = [];
1700
+ /** Saves the tags as one string joined with this separator (`'a,b,c'`) instead of a list. */
1701
+ separator(separator = ',') { this._separator = separator; return this; }
1702
+ /** Text shown before / after each tag (not saved). */
1703
+ tagPrefix(prefix) { this._tagPrefix = prefix; return this; }
1704
+ tagSuffix(suffix) { this._tagSuffix = suffix; return this; }
1705
+ /** Tags can be reordered by dragging. */
1706
+ reorderable(condition = true) { this._reorderable = condition; return this; }
1707
+ /** Badge color of the tags (`primary` by default). */
1708
+ color(color) { this._tagColor = color; return this; }
1709
+ /** Rules applied to each tag (e.g. a maximum length); the message names the tag. */
1710
+ nestedRecursiveRules(rules) { this._tagRules = rules.map((rule) => (typeof rule === 'string' ? stringRule(rule, { numeric: false, name: this._name ?? '' }) : rule)).filter((rule) => rule !== null); return this; }
827
1711
  getDefaultState() { return this._default ?? []; }
828
1712
  hydrateState(value) {
829
- if (typeof value === 'string') {
1713
+ if (typeof value === 'string' && this._separator !== null)
1714
+ value = value === '' ? [] : value.split(this._separator);
1715
+ else if (typeof value === 'string') {
830
1716
  try {
831
1717
  value = JSON.parse(value);
832
1718
  }
@@ -834,10 +1720,23 @@ export class TagsInput extends Field {
834
1720
  value = value === '' ? [] : [value];
835
1721
  }
836
1722
  }
837
- return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map(String) : [];
1723
+ return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map((tag) => String(tag).trim()).filter((tag) => tag !== '') : [];
1724
+ }
1725
+ /** Only non-empty strings are saved, whatever shape the browser sends (joined with the separator, if any). */
1726
+ dehydrateState(state) {
1727
+ const tags = (Array.isArray(state) ? state : []).filter((tag) => typeof tag === 'string' || typeof tag === 'number').map((tag) => String(tag).trim()).filter((tag) => tag !== '').slice(0, 1000);
1728
+ return this._separator !== null ? tags.join(this._separator) : tags;
1729
+ }
1730
+ async getValidationRules() {
1731
+ if (!this._tagRules.length)
1732
+ return super.getValidationRules();
1733
+ return [...(await super.getValidationRules()), ...this._tagRules.map((rule) => ({
1734
+ ...rule,
1735
+ passes: async (value, ctx) => { for (const tag of Array.isArray(value) ? value : [])
1736
+ if (!(await rule.passes(tag, ctx)))
1737
+ return false; return true; },
1738
+ }))];
838
1739
  }
839
- /** Only non-empty strings are saved, whatever shape the browser sends. */
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); }
841
1740
  async toHtml() {
842
1741
  if (!colorManager)
843
1742
  throw new Error('No color manager: call useColorManager().');
@@ -850,14 +1749,15 @@ export class TagsInput extends Field {
850
1749
  const input = new Attributes()
851
1750
  .merge({ autocomplete: 'off', disabled: isDisabled, id, list: `${id}-suggestions`, placeholder: this._placeholder ?? t('filament-forms::components.tags_input.placeholder'), type: 'text', 'x-bind': 'input' })
852
1751
  .class(['fi-input']);
853
- const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', 'primary')].join(' ');
1752
+ const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', this._tagColor ?? 'primary'), this._reorderable && !isDisabled ? 'fi-reorderable' : null].filter(Boolean).join(' ');
1753
+ const reorderable = this._reorderable && !isDisabled;
854
1754
  const deleteButton = isDisabled ? '' : `<button type="button" x-on:click.stop="deleteTag(tag)" x-bind:aria-label="'${e(t('filament-forms::components.tags_input.actions.delete.label'))}: ' + tag" class="fi-badge-delete-btn"><span aria-hidden="true" class="fi-badge-delete-btn-icon"></span></button>`;
855
1755
  return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div x-load x-load-src="${e(asset('tags-input'))}" x-data="tagsInputFormComponent({
856
1756
  state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
857
1757
  splitKeys: ${this._splitKeys.length ? e(js(this._splitKeys)) : '[]'},
858
1758
  tagAddedMessage: ${e(js(t('filament-forms::components.tags_input.tag_added')))},
859
1759
  tagRemovedMessage: ${e(js(t('filament-forms::components.tags_input.tag_removed')))},
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' });
1760
+ })"><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${reorderable ? ' x-on:end.stop="reorderTags($event)" x-sortable data-sortable-animation-duration="300"' : ''} class="fi-fo-tags-input-tags-ctn"><template x-for="(tag, index) in state" x-bind:key="\`\${tag}-\${index}\`"><span${reorderable ? ' x-bind:x-sortable-item="index" x-sortable-handle' : ''} class="${badge}"><span class="fi-badge-label-ctn"><span class="fi-badge-label">${e(this._tagPrefix ?? '')}<span x-text="tag"></span>${e(this._tagSuffix ?? '')}</span></span>${deleteButton}</span></template></div></template></div></div></div></div>`, { wrapperClass: 'fi-fo-tags-input-wrp' });
861
1761
  }
862
1762
  }
863
1763
  /** On/off switch. */
@@ -865,14 +1765,32 @@ export class Toggle extends Field {
865
1765
  static make(name) { return configured(new Toggle(name)); }
866
1766
  getDefaultState() { return this._default ?? false; }
867
1767
  hydrateState(value) { return value === true || value === 1 || value === '1'; }
1768
+ _onIcon = null;
1769
+ _offIcon = null;
1770
+ _onColor = null;
1771
+ _offColor = null;
1772
+ _inline = true;
1773
+ /** Icons inside the switch's knob. */
1774
+ onIcon(icon) { this._onIcon = icon; return this; }
1775
+ offIcon(icon) { this._offIcon = icon; return this; }
1776
+ /** Colors when on (`primary` by default) and off (`gray`). */
1777
+ onColor(color) { this._onColor = color; return this; }
1778
+ offColor(color) { this._offColor = color; return this; }
1779
+ /** `false`: the label goes above the switch instead of beside it. */
1780
+ inline(condition = true) { this._inline = condition; return this; }
868
1781
  async toHtml() {
869
1782
  if (!colorManager)
870
1783
  throw new Error('No color manager: call useColorManager().');
871
1784
  const on = this.context().state === true;
872
- const colors = colorManager.getComponentClasses('toggle', 'primary').join(' ');
1785
+ const colors = colorManager.getComponentClasses('toggle', this._onColor ?? 'primary').join(' ');
1786
+ const offColors = this._offColor && this._offColor !== 'gray' ? ` ${colorManager.getComponentClasses('toggle', this._offColor).join(' ')}` : '';
1787
+ const onIconHtml = this._onIcon ? icon(this._onIcon, { size: 'xs' }) : '';
1788
+ const offIconHtml = this._offIcon ? icon(this._offIcon, { size: 'xs' }) : '';
873
1789
  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']);
874
- const knob = '<div><div aria-hidden="true"></div><div aria-hidden="true"></div></div>';
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>` : ''}` });
1790
+ const knob = `<div><div aria-hidden="true">${offIconHtml}</div><div aria-hidden="true">${onIconHtml}</div></div>`;
1791
+ const placeholderKnob = `<div><div aria-hidden="true"></div><div aria-hidden="true">${onIconHtml}</div></div>`;
1792
+ const control = `<button x-data="{ state: $wire.$entangle(${e(js(this.getStatePath()))}, ${this.isLive() ? 'true' : 'false'}) }" x-bind:aria-checked="state ? 'true' : 'false'" x-on:click="state = ! state" x-bind:class="state ? 'fi-toggle-on ${colors}' : 'fi-toggle-off${offColors}'"${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}">${placeholderKnob}</div>` : ''}`;
1793
+ return this._inline ? this.wrap('', { labelPrefix: control }) : this.wrap(control);
876
1794
  }
877
1795
  }
878
1796
  /** Single choice shown as buttons. */
@@ -895,8 +1813,59 @@ export class ToggleButtons extends Field {
895
1813
  inline(condition = true) { this._inline = condition; return this; }
896
1814
  /** Buttons joined into a single group. */
897
1815
  grouped(condition = true) { this._grouped = condition; return this; }
1816
+ _multiple = false;
1817
+ _tooltips = {};
1818
+ _size = null;
1819
+ _labelsHidden = false;
1820
+ _fullWidth = false;
1821
+ _columns = null;
1822
+ _disableOptionWhen = null;
1823
+ /** Several buttons can be selected: the state is a list of values. */
1824
+ multiple(condition = true) { this._multiple = condition; return this; }
1825
+ /** Tooltip per value. */
1826
+ tooltips(tooltips) { this._tooltips = tooltips; return this; }
1827
+ /** Button size: `xs`, `sm`, `md` (default), `lg` or `xl`. */
1828
+ size(size) { this._size = size; return this; }
1829
+ /** Icons only: the labels stay for screen readers. */
1830
+ hiddenButtonLabels(condition = true) { this._labelsHidden = condition; return this; }
1831
+ /** Buttons stretch to the width of the field. */
1832
+ fullWidth(condition = true) { this._fullWidth = condition; return this; }
1833
+ /** Number of columns the buttons are spread over (from `lg`), when not inline. */
1834
+ columns(columns) { this._columns = columns; return this; }
1835
+ /** Options that cannot be selected; they are refused on the server too. */
1836
+ disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
1837
+ isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
1838
+ /** "Yes" (`1`) and "No" (`0`) buttons, green and red, with check and cross icons. */
1839
+ boolean(trueLabel = null, falseLabel = null) {
1840
+ this.options([['1', trueLabel ?? t('filament-forms::components.toggle_buttons.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.toggle_buttons.boolean.false')]]);
1841
+ this._colors = { 1: 'success', 0: 'danger', ...this._colors };
1842
+ this._icons = { 1: 'o-check', 0: 'o-x-mark', ...this._icons };
1843
+ return this;
1844
+ }
1845
+ getDefaultState() { return this._default ?? (this._multiple ? [] : null); }
1846
+ hydrateState(value) {
1847
+ if (!this._multiple)
1848
+ return value;
1849
+ if (typeof value === 'string') {
1850
+ try {
1851
+ value = JSON.parse(value);
1852
+ }
1853
+ catch {
1854
+ value = value === '' ? [] : [value];
1855
+ }
1856
+ }
1857
+ return (Array.isArray(value) ? value : []).map(String);
1858
+ }
1859
+ /** Multiple: only offered values, in option order. */
1860
+ dehydrateState(state) {
1861
+ if (!this._multiple)
1862
+ return state;
1863
+ const chosen = new Set((Array.isArray(state) ? state : []).map(String));
1864
+ return this._options.map(([value]) => value).filter((value) => chosen.has(value));
1865
+ }
898
1866
  async getValidationRules() {
899
- return [...(await super.getValidationRules()), { name: 'in', passes: (v) => this._options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
1867
+ const allowed = (v) => this._options.some(([value, label]) => value === String(v) && !this.isOptionDisabled(value, label));
1868
+ return [...(await super.getValidationRules()), { name: 'in', passes: (v) => (this._multiple ? Array.isArray(v) && v.every(allowed) : allowed(v)), message: (a) => `The selected ${a} is invalid.` }];
900
1869
  }
901
1870
  async toHtml() {
902
1871
  if (!colorManager)
@@ -907,36 +1876,43 @@ export class ToggleButtons extends Field {
907
1876
  const describedBy = [isRequired ? `${id}-required` : null, this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
908
1877
  const container = new Attributes()
909
1878
  .merge({ 'aria-describedby': describedBy, 'aria-labelledby': `${id}-label`, 'aria-required': isRequired ? 'true' : null, role: 'radiogroup', 'wire:key': `${this.getLivewireKey()}.inputs` })
910
- .class(['fi-fo-toggle-buttons', this._grouped ? 'fi-btn-group' : null, !this._grouped && this._inline ? 'fi-inline' : null]);
1879
+ .class(['fi-fo-toggle-buttons', this._grouped ? 'fi-btn-group' : null, !this._grouped && this._inline ? 'fi-inline' : null, { 'fi-width-full': this._fullWidth }]);
1880
+ if (this._multiple)
1881
+ container.merge({ role: 'group', 'aria-required': null });
1882
+ const grid = !this._grouped && !this._inline && this._columns && this._columns > 1 ? ` class="fi-grid fi-grid-direction-col lg:fi-grid-cols" style="--cols-lg: ${Math.floor(this._columns)}; --cols-default: 1;"` : '';
911
1883
  const manager = colorManager;
912
- const button = (value) => ['fi-btn', this._grouped ? 'fi-btn-group-btn' : null, 'fi-size-md', ...manager.getComponentClasses('button', this._colors[value] ?? 'primary')].filter(Boolean).join(' ');
1884
+ const button = (value, disabled) => ['fi-btn', this._grouped ? 'fi-btn-group-btn' : null, `fi-size-${this._size ?? 'md'}`, disabled ? 'fi-disabled' : null, ...manager.getComponentClasses('button', this._colors[value] ?? 'primary')].filter(Boolean).join(' ');
913
1885
  const items = this._options.map(([value, label]) => {
914
- const input = new Attributes().merge({ id: `${id}-${value}`, name: id, type: 'radio', value, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath(), disabled: isDisabled, required: isRequired, 'aria-describedby': describedBy }).class(['fi-fo-toggle-buttons-input']);
915
- const html = `<input ${input.toHtml()} /><label for="${e(`${id}-${value}`)}" class="${button(value)}">${this._icons[value] ? icon(this._icons[value]) : ''}${e(label)}</label>`;
1886
+ const optionDisabled = this.isOptionDisabled(value, label);
1887
+ const input = new Attributes().merge({ id: `${id}-${value}`, name: this._multiple ? null : id, type: this._multiple ? 'checkbox' : 'radio', value, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath(), disabled: isDisabled || optionDisabled, 'aria-label': this._labelsHidden ? label : null, required: isRequired && !this._multiple && !optionDisabled, 'aria-describedby': describedBy }).class(['fi-fo-toggle-buttons-input']);
1888
+ const tooltip = this._tooltips[value] ? ` x-tooltip="${e(`{ content: ${js(this._tooltips[value])}, theme: $store.theme, allowHTML: false }`)}"` : '';
1889
+ const iconSize = this._size === 'xs' || this._size === 'sm' ? 'sm' : undefined;
1890
+ const html = `<input ${input.toHtml()} /><label for="${e(`${id}-${value}`)}"${tooltip}${this._labelsHidden ? ` aria-label="${e(label)}"` : ''} class="${button(value, optionDisabled)}">${this._icons[value] ? icon(this._icons[value], iconSize ? { size: iconSize } : {}) : ''}${this._labelsHidden ? '' : e(label)}</label>`;
916
1891
  return this._grouped ? html : `<div class="fi-fo-toggle-buttons-btn-ctn">${html}</div>`;
917
1892
  });
918
1893
  const after = isRequired ? `<span id="${e(id)}-required" class="fi-sr-only">${e(t('filament-forms::components.toggle_buttons.required_description'))}</span>` : '';
919
- return this.wrap(`<div ${container.toHtml()}>${items.join('')}</div>`, { wrapperClass: 'fi-fo-toggle-buttons-wrp', labelAsDiv: true, focusable: true, after });
1894
+ return this.wrap(`<div ${container.toHtml()}>${grid ? `<div${grid}>${items.join('')}</div>` : items.join('')}</div>`, { wrapperClass: 'fi-fo-toggle-buttons-wrp', labelAsDiv: true, focusable: true, after });
920
1895
  }
921
1896
  }
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".
924
- const RICH_EXTENSIONS = [StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'start' }), Subscript, Superscript, Highlight, Image, TableKit];
1897
+ // Server-side TipTap schema: the nodes and marks the rich editor can produce, and the extensions your application registers.
1898
+ const richExtensions = () => [StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'start' }), Subscript, Superscript, Highlight, SizedImage, TableKit, ...RICH_CONTENT_EXTENSIONS, ...customRichContentExtensions];
925
1899
  const isRichDocument = (value) => !!value && typeof value === 'object' && value.type === 'doc';
926
1900
  const richText = (node) => (typeof node?.text === 'string' ? node.text : '') + (Array.isArray(node?.content) ? node.content.map(richText).join('') : '');
927
- const hasRichContent = (node) => node?.type === 'image' || node?.type === 'horizontalRule' || (Array.isArray(node?.content) && node.content.some(hasRichContent));
1901
+ const hasRichContent = (node) => ['image', 'horizontalRule', 'mergeTag', 'mention', 'customBlock'].includes(node?.type) || (Array.isArray(node?.content) && node.content.some(hasRichContent));
928
1902
  /** Stored HTML → TipTap document. Parsing drops anything the schema does not know (scripts, event attributes, `javascript:` links). */
929
- export function richHtmlToDocument(html) { return generateJSON(html ?? '', RICH_EXTENSIONS); }
1903
+ export function richHtmlToDocument(html) { return generateJSON(html ?? '', richExtensions()); }
930
1904
  /**
931
1905
  * TipTap document → HTML to save, or null if empty or invalid. The document comes from the browser,
932
1906
  * so it is rendered then re-parsed to keep only what the schema accepts.
933
1907
  */
1908
+ /** HTML of a document already sanitized (`RichContentRenderer`): no re-parsing. */
1909
+ export function renderRichDocument(document) { return generateHTML(document, richExtensions()); }
934
1910
  export function richDocumentToHtml(document) {
935
1911
  if (!isRichDocument(document))
936
1912
  return null;
937
1913
  try {
938
- const clean = generateJSON(generateHTML(document, RICH_EXTENSIONS), RICH_EXTENSIONS);
939
- return richText(clean).trim() === '' && !hasRichContent(clean) ? null : generateHTML(clean, RICH_EXTENSIONS);
1914
+ const clean = generateJSON(generateHTML(document, richExtensions()), richExtensions());
1915
+ return richText(clean).trim() === '' && !hasRichContent(clean) ? null : generateHTML(clean, richExtensions());
940
1916
  }
941
1917
  catch {
942
1918
  return null;
@@ -944,13 +1920,154 @@ export function richDocumentToHtml(document) {
944
1920
  }
945
1921
  /**
946
1922
  * Rich text editor: the browser works on a TipTap document, the database receives HTML.
947
- * Not supported yet: attachments, custom blocks, mentions.
1923
+ * Not supported yet: custom blocks, merge tags, mentions, text colors.
1924
+ */
1925
+ /**
1926
+ * Stores a file uploaded by an editor (`componentFileAttachments.<state path>[.<id>]`): type checked on the file's content, size,
1927
+ * then stored; the reference is consumed. Returns the public URL (local or http(s) only).
1928
+ */
1929
+ async function storeEditorAttachment(field, id, options) {
1930
+ if (!options.storage)
1931
+ return null;
1932
+ const livewire = field.livewire;
1933
+ const segments = field.getStatePath().split('.');
1934
+ const holder = readPath(livewire.componentFileAttachments ?? {}, id === null ? segments.slice(0, -1) : segments);
1935
+ const name = id ?? segments[segments.length - 1];
1936
+ if (!holder || typeof holder !== 'object' || !Object.hasOwn(holder, name))
1937
+ return null;
1938
+ const uploads = livewire._runtime?.uploads;
1939
+ const file = uploads?.resolve(holder[name]);
1940
+ delete holder[name];
1941
+ if (!file || !options.types.includes(file.type) || file.size > options.maxSize * 1024)
1942
+ return null;
1943
+ const key = await options.storage.store(file, options.directory);
1944
+ uploads.remove(file.id);
1945
+ const info = await options.storage.info(key);
1946
+ return info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info.url : null;
1947
+ }
1948
+ /** Tools of the rich editor's toolbar, in their default groups. */
1949
+ export const RICH_EDITOR_TOOLS = [['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link'], ['h2', 'h3'], ['alignStart', 'alignCenter', 'alignEnd'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table', 'attachFiles'], ['undo', 'redo']];
1950
+ /** The editor selection sent by the browser with a toolbar action. */
1951
+ const selectionOf = (args) => (args.editorSelection ?? null);
1952
+ // `details` needs a TipTap extension the server schema does not have: its content would be lost on save.
1953
+ const BUILT_IN_TOOLS = new Map(RICH_EDITOR_TOOL_DEFINITIONS.filter((definition) => definition.name !== 'details').map((definition) => [definition.name, definition]));
1954
+ /**
1955
+ * Rich text editor (TipTap): the browser works on a document, the database receives HTML (or JSON with `json()`).
1956
+ * Besides the starter kit: text colors, merge tags, mentions, custom blocks, columns, resizable images, and your own
1957
+ * tools and TipTap extensions.
948
1958
  */
949
1959
  export class RichEditor extends Field {
1960
+ _toolbar = null;
1961
+ _disabledTools = [];
1962
+ _json = false;
1963
+ _richPlaceholder = null;
1964
+ _attachmentsStorage = null;
1965
+ _attachmentsDirectory = null;
1966
+ _attachmentTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
1967
+ _attachmentMaxSize = 12288;
1968
+ _customTools = [];
1969
+ _textColors = null;
1970
+ _customTextColors = false;
1971
+ _mergeTags = {};
1972
+ _noMergeTagResults = null;
1973
+ _mentions = [];
1974
+ _customBlocks = [];
1975
+ _minimalCustomBlockControls = false;
1976
+ _customBlocksGrid = false;
1977
+ _searchableCustomBlocks = false;
1978
+ _activePanel = null;
1979
+ _stickyToolbar = false;
1980
+ _resizableImages = false;
1981
+ _jsExtensions = [];
1982
+ _editorActions = [];
950
1983
  static make(name) { return configured(new RichEditor(name)); }
951
- hydrateState(value) { return isRichDocument(value) ? value : richHtmlToDocument(typeof value === 'string' ? value : ''); }
1984
+ /** Toolbar tools, by group (`[['bold', 'italic', 'link'], ['h2', 'h3'], ['undo', 'redo']]`); see `RICH_EDITOR_TOOLS` and `RICH_EDITOR_TOOL_DEFINITIONS`. */
1985
+ toolbarButtons(groups) {
1986
+ const list = (groups.length && typeof groups[0] === 'string' ? [groups] : groups);
1987
+ this._toolbar = list;
1988
+ this.assertKnownTools();
1989
+ return this;
1990
+ }
1991
+ assertKnownTools() {
1992
+ if (!this._toolbar)
1993
+ return;
1994
+ const unknown = this._toolbar.flat().filter((tool) => !this.findTool(tool));
1995
+ if (unknown.length)
1996
+ throw new Error(`RichEditor: unknown toolbar buttons ${unknown.join(', ')} (available: ${[...BUILT_IN_TOOLS.keys(), ...this._customTools.map((tool) => tool.getName())].join(', ')}).`);
1997
+ }
1998
+ /** Removes buttons; with no argument, removes them all. */
1999
+ disableToolbarButtons(tools = []) { if (!tools.length)
2000
+ this._toolbar = []; this._disabledTools = [...this._disabledTools, ...tools]; return this; }
2001
+ disableAllToolbarButtons(condition = true) { if (condition)
2002
+ this._toolbar = []; return this; }
2003
+ /** Your own toolbar buttons (or replacements of built-in ones, by name); place them with `toolbarButtons()`. */
2004
+ tools(tools) { this._customTools = [...this._customTools.filter((tool) => !tools.some((other) => other.getName() === tool.getName())), ...tools]; this.assertKnownTools(); return this; }
2005
+ /** Actions mounted by your tools (`RichEditorTool.make('x').action()`), run with the editor selection in `arguments.editorSelection`. */
2006
+ editorActions(actions) { this._editorActions = [...this._editorActions, ...actions]; return this; }
2007
+ /** Browser TipTap extensions: URLs of ES modules exporting an extension (or an array of them). Register their server side with `registerRichContentExtensions()`. */
2008
+ extensions(urls) { this._jsExtensions = [...this._jsExtensions, ...urls]; return this; }
2009
+ /** Saves the content as the editor's JSON document instead of HTML. */
2010
+ json(condition = true) { this._json = condition; return this; }
2011
+ placeholder(text) { this._richPlaceholder = text; return this; }
2012
+ /** Where files attached in the editor are stored (`FileUpload.storage` by default); without storage, attaching is off. */
2013
+ fileAttachmentsStorage(storage) { this._attachmentsStorage = storage; return this; }
2014
+ fileAttachmentsDirectory(directory) { this._attachmentsDirectory = directory; return this; }
2015
+ /** Accepted types of attached files, checked on the file's content (images only: they are inserted as images). */
2016
+ fileAttachmentsAcceptedFileTypes(types) { this._attachmentTypes = types.filter((type) => type.startsWith('image/') && type !== 'image/svg+xml'); return this; }
2017
+ /** Maximum size of attached files, in kilobytes. */
2018
+ fileAttachmentsMaxSize(kilobytes) { this._attachmentMaxSize = kilobytes; return this; }
2019
+ /** Text colors offered by the `textColor` tool (the Tailwind palette by default): `{ brand: TextColor.make('Brand', '#0ea5e9', '#38bdf8') }`. */
2020
+ textColors(colors) { this._textColors = colors; if (colors)
2021
+ registerTextColors(colors); return this; }
2022
+ getTextColors() { return this._textColors ?? TextColor.getDefaults(); }
2023
+ /** Lets users pick any color, besides the listed ones. */
2024
+ customTextColors(condition = true) { this._customTextColors = condition; return this; }
2025
+ /** Merge tags users insert (`mergeTags` tool and `{{` in the text), replaced by values when the content is rendered: `['name', 'today']` or `{ name: 'Name' }`. */
2026
+ mergeTags(tags) { this._mergeTags = Array.isArray(tags) ? Object.fromEntries(tags.map((tag) => [tag, tag])) : { ...tags }; return this; }
2027
+ noMergeTagSearchResultsMessage(message) { this._noMergeTagResults = message; return this; }
2028
+ /** Mentions: `[MentionProvider.make('@').items({ 1: 'Ada' })]`. */
2029
+ mentions(providers) { this._mentions = providers; return this; }
2030
+ /** Custom blocks (`customBlocks` tool), as a list or grouped by label. */
2031
+ customBlocks(blocks) { this._customBlocks = blocks; return this; }
2032
+ minimalCustomBlockControls(condition = true) { this._minimalCustomBlockControls = condition; return this; }
2033
+ /** Shows the blocks of the panel in a grid. */
2034
+ customBlocksGrid(condition = true) { this._customBlocksGrid = condition; return this; }
2035
+ /** Adds a search field to the blocks panel. */
2036
+ searchableCustomBlocks(condition = true) { this._searchableCustomBlocks = condition; return this; }
2037
+ /** Panel open when the editor loads (`customBlocks`, `mergeTags`). */
2038
+ activePanel(panel) { this._activePanel = panel; return this; }
2039
+ /** Keeps the toolbar visible while scrolling the content. */
2040
+ stickyToolbar(condition = true) { this._stickyToolbar = condition; return this; }
2041
+ /** Lets users resize images with handles; the size is saved on the image. */
2042
+ resizableImages(condition = true) { this._resizableImages = condition; return this; }
2043
+ attachmentsStorage() { return this._attachmentsStorage ?? FileUpload.storage; }
2044
+ findTool(name) {
2045
+ const custom = this._customTools.find((tool) => tool.getName() === name);
2046
+ if (custom)
2047
+ return custom;
2048
+ const definition = BUILT_IN_TOOLS.get(name);
2049
+ return definition ? RichEditorTool.fromDefinition(definition) : null;
2050
+ }
2051
+ toolGroups() { return (this._toolbar ?? RICH_EDITOR_TOOLS).map((group) => group.filter((tool) => !this._disabledTools.includes(tool) && (tool !== 'attachFiles' || this.attachmentsStorage() !== null))).filter((group) => group.length); }
2052
+ blockGroups() { return Array.isArray(this._customBlocks) ? [['', this._customBlocks]] : Object.entries(this._customBlocks); }
2053
+ getCustomBlock(id) { return this.blockGroups().flatMap(([, blocks]) => blocks).find((block) => block.getId() === id) ?? null; }
2054
+ hydrateState(value) {
2055
+ if (typeof value === 'string' && value.trim().startsWith('{')) {
2056
+ try {
2057
+ const parsed = JSON.parse(value);
2058
+ if (isRichDocument(parsed))
2059
+ return parsed;
2060
+ }
2061
+ catch { /* HTML */ }
2062
+ }
2063
+ return isRichDocument(value) ? value : richHtmlToDocument(typeof value === 'string' ? value : '');
2064
+ }
952
2065
  getDefaultState() { return richHtmlToDocument(typeof this._default === 'string' ? this._default : ''); }
953
- dehydrateState(state) { return richDocumentToHtml(state); }
2066
+ /** HTML (sanitized by the editor's schema), or the JSON document with `json()` (normalized through the same schema). */
2067
+ dehydrateState(state) {
2068
+ const html = richDocumentToHtml(state);
2069
+ return this._json ? (html === null ? null : richHtmlToDocument(html)) : html;
2070
+ }
954
2071
  async getValidationRules() {
955
2072
  const rules = [];
956
2073
  if (await this.isRequired())
@@ -958,15 +2075,63 @@ export class RichEditor extends Field {
958
2075
  return [...rules, ...this._rules];
959
2076
  }
960
2077
  getExposedMethods() { return ['getMentionSearchResultsForJs', 'getMentionLabelsForJs', 'getCustomBlockPreviewsForJs', 'getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
961
- getMentionSearchResultsForJs() { return []; }
962
- getMentionLabelsForJs() { return []; }
963
- getCustomBlockPreviewsForJs() { return []; }
964
- getUploadedFileAttachmentTemporaryUrl() { return null; }
965
- saveUploadedFileAttachmentAndGetUrl() { return null; }
966
- /** Toolbar "link" action: a modal (URL, new tab), then commands sent back to the browser editor. */
2078
+ mentionProvider(char) { return this._mentions.find((provider) => provider.getTriggerCharacter() === char) ?? this._mentions[0] ?? null; }
2079
+ /** Mention search, for the editor's JavaScript: `[{ id, label }]`. */
2080
+ async getMentionSearchResultsForJs(search = '', triggerCharacter = '@') {
2081
+ const provider = this.mentionProvider(triggerCharacter ?? '@');
2082
+ if (!provider || (await this.isDisabled()))
2083
+ return [];
2084
+ return Object.entries(await provider.getSearchResults(typeof search === 'string' ? search.slice(0, 200) : '')).slice(0, 50).map(([id, label]) => ({ id, label }));
2085
+ }
2086
+ /** Current labels of mentioned ids, by trigger character. */
2087
+ async getMentionLabelsForJs(mentions = []) {
2088
+ const byChar = new Map();
2089
+ for (const mention of Array.isArray(mentions) ? mentions.slice(0, 500) : []) {
2090
+ const char = String(mention?.triggerCharacter ?? '@'), id = mention?.id;
2091
+ if (id === null || id === undefined)
2092
+ continue;
2093
+ byChar.set(char, [...(byChar.get(char) ?? []), String(id)]);
2094
+ }
2095
+ const labels = {};
2096
+ for (const [char, ids] of byChar) {
2097
+ const provider = this.mentionProvider(char);
2098
+ if (provider)
2099
+ labels[char] = await provider.getLabels(ids);
2100
+ }
2101
+ return labels;
2102
+ }
2103
+ /** Previews of inserted blocks, for the editor's JavaScript. */
2104
+ getCustomBlockPreviewsForJs(customBlocks = []) {
2105
+ const previews = [];
2106
+ for (const item of Array.isArray(customBlocks) ? customBlocks.slice(0, 200) : []) {
2107
+ const block = this.getCustomBlock(item?.id);
2108
+ const config = item?.config && typeof item.config === 'object' && !Array.isArray(item.config) ? item.config : {};
2109
+ if (!block || !Number.isInteger(item?.key))
2110
+ continue;
2111
+ previews.push({ key: item.key, label: block.getPreviewLabel(config), preview: Buffer.from(block.toPreviewHtml(config) ?? '').toString('base64'), shouldApplyProseStylingToPreview: block.shouldApplyProseStylingToPreview(config) });
2112
+ }
2113
+ return previews;
2114
+ }
2115
+ /**
2116
+ * URL of a file the editor just uploaded: the file is checked (type from its content, size), stored, and its public URL returned.
2117
+ * It stays stored even if the form is not saved.
2118
+ */
2119
+ async getUploadedFileAttachmentTemporaryUrl(args = {}) {
2120
+ const id = String(args?.attachment ?? '');
2121
+ const storage = this.attachmentsStorage();
2122
+ if (!storage || !/^[A-Za-z0-9_-]{1,64}$/.test(id) || !this.toolGroups().flat().includes('attachFiles') || (await this.isDisabled()))
2123
+ return null;
2124
+ return storeEditorAttachment(this, id, { storage, directory: this._attachmentsDirectory, types: this._attachmentTypes, maxSize: this._attachmentMaxSize });
2125
+ }
2126
+ async saveUploadedFileAttachmentAndGetUrl(args = {}) { return this.getUploadedFileAttachmentTemporaryUrl(args); }
2127
+ /** Sends commands to the editor in the browser (after an action), on the given selection. */
2128
+ runCommands(livewire, commands, editorSelection = null) {
2129
+ const key = this.getKey();
2130
+ livewire.dispatch('run-rich-editor-commands', { awaitSchemaComponent: key, livewireId: livewire.id, key, editorSelection, commands: commands.map((command) => ({ name: command.name, arguments: command.arguments ?? [] })) });
2131
+ }
2132
+ /** Toolbar actions: link, text color, custom block, and the ones of `editorActions()`. */
967
2133
  getActions() {
968
2134
  const tr = (name) => t(`filament-forms::components.rich_editor.actions.link.${name}`);
969
- const key = this.getKey();
970
2135
  return [
971
2136
  ...this.getHintActions(),
972
2137
  Action.make('link').label(tr('label')).modalHeading(tr('modal.heading')).modalWidth('lg')
@@ -978,29 +2143,174 @@ export class RichEditor extends Field {
978
2143
  Checkbox.make('shouldOpenInNewTab').label(tr('modal.form.should_open_in_new_tab.label')),
979
2144
  ])
980
2145
  .action(({ livewire, arguments: args, data }) => {
981
- const selection = (args.editorSelection ?? null);
2146
+ const selection = selectionOf(args);
982
2147
  const single = selection?.head === selection?.anchor;
983
2148
  const url = typeof data.url === 'string' && data.url !== '' ? data.url : null;
984
- const commands = [
2149
+ this.runCommands(livewire, [
985
2150
  ...(single ? [{ name: 'extendMarkRange', arguments: ['link'] }] : []),
986
- url ? { name: 'setLink', arguments: [{ href: url, target: data.shouldOpenInNewTab ? '_blank' : null }] } : { name: 'unsetLink', arguments: [] },
987
- ];
988
- livewire.dispatch('run-rich-editor-commands', { awaitSchemaComponent: key, livewireId: livewire.id, key, editorSelection: selection, commands });
2151
+ url ? { name: 'setLink', arguments: [{ href: url, target: data.shouldOpenInNewTab ? '_blank' : null }] } : { name: 'unsetLink' },
2152
+ ], selection);
989
2153
  }),
2154
+ this.textColorAction(),
2155
+ this.customBlockAction(),
2156
+ ...this._editorActions,
990
2157
  ];
991
2158
  }
2159
+ textColorAction() {
2160
+ const tr = (name) => t(`filament-forms::components.rich_editor.actions.text_color.${name}`);
2161
+ return Action.make('textColor').label(tr('label')).modalHeading(tr('modal.heading')).modalWidth('lg')
2162
+ .fillForm(({ arguments: args }) => {
2163
+ const color = typeof args.color === 'string' ? args.color : null;
2164
+ if (!color)
2165
+ return {};
2166
+ const known = Object.hasOwn(this.getTextColors(), color);
2167
+ return { color: known ? color : null, customColor: this._customTextColors && !known ? color : null };
2168
+ })
2169
+ .schema(() => {
2170
+ const options = Object.fromEntries(Object.entries(this.getTextColors()).map(([name, color]) => [name, `<div class="fi-fo-rich-editor-text-color-select-option"><div class="fi-fo-rich-editor-text-color-select-option-preview" style="--color: ${e(sanitizeCssColor(color.getColor()) ?? '')}; --dark-color: ${e(sanitizeCssColor(color.getDarkColor()) ?? '')}"></div><div>${e(color.getLabel() ?? name)}</div></div>`]));
2171
+ return [
2172
+ ...(Object.keys(options).length ? [Select.make('color').label(tr('modal.form.color.label')).options(options).allowHtml().native(false).live()] : []),
2173
+ ...(this._customTextColors ? [ColorPicker.make('customColor').label(tr('modal.form.custom_color.label')).visible(({ get }) => !get('color'))] : []),
2174
+ ];
2175
+ })
2176
+ .action(({ livewire, arguments: args, data }) => {
2177
+ const selection = selectionOf(args);
2178
+ const single = selection?.head === selection?.anchor;
2179
+ const picked = typeof data.color === 'string' && Object.hasOwn(this.getTextColors(), data.color) ? data.color : this._customTextColors ? sanitizeCssColor(data.customColor) : null;
2180
+ this.runCommands(livewire, picked
2181
+ ? [...(single ? [{ name: 'extendMarkRange', arguments: ['textColor'] }] : []), { name: 'setTextColor', arguments: [{ color: picked }] }]
2182
+ : [...(single ? [{ name: 'extendMarkRange', arguments: ['textColor'] }] : []), { name: 'unsetTextColor' }], selection);
2183
+ });
2184
+ }
2185
+ /** Modal of a custom block, built from the block class named in the arguments (`id`): insert or edit. */
2186
+ customBlockAction() {
2187
+ const blockOf = (args) => this.getCustomBlock(args?.id);
2188
+ const configured = (args) => { const block = blockOf(args); return block ? block.configureEditorAction(Action.make('customBlock')) : null; };
2189
+ const argumentsOf = (livewire) => livewire?.mountedActions?.find?.((mounted) => mounted.name === 'customBlock')?.arguments ?? {};
2190
+ return Action.make('customBlock').modalWidth('lg')
2191
+ .modalHeading(({ arguments: args }) => blockOf(args)?.getLabel() ?? null)
2192
+ .modalSubmitActionLabel(({ arguments: args }) => t(`filament-forms::components.rich_editor.actions.custom_block.modal.actions.${args.mode === 'edit' ? 'save' : 'insert'}.label`))
2193
+ .modalHidden(async (ctx) => { const action = configured(ctx.arguments); return !action || (await action.isModalHidden(ctx)); })
2194
+ .fillForm(({ arguments: args }) => (args.config && typeof args.config === 'object' && !Array.isArray(args.config) ? args.config : {}))
2195
+ .schema((schema) => configured(argumentsOf(schema.livewire))?.applySchema(schema) ?? schema.components([]))
2196
+ .action(({ livewire, arguments: args, data }) => {
2197
+ const block = blockOf(args);
2198
+ if (!block)
2199
+ return;
2200
+ const config = data ?? {};
2201
+ const content = { type: 'customBlock', attrs: { config, id: block.getId(), label: block.getPreviewLabel(config), preview: Buffer.from(block.toPreviewHtml(config) ?? '').toString('base64'), shouldApplyProseStylingToPreview: block.shouldApplyProseStylingToPreview(config) } };
2202
+ const selection = { ...(selectionOf(args) ?? {}) };
2203
+ if (args.dragPosition !== undefined && args.dragPosition !== null) {
2204
+ this.runCommands(livewire, [{ name: 'insertContentAt', arguments: [args.dragPosition, content] }]);
2205
+ return;
2206
+ }
2207
+ if (selection.type === 'node' && args.mode === 'insert') {
2208
+ this.runCommands(livewire, [{ name: 'insertContentAt', arguments: [Number(selection.anchor ?? -1) + 1, content] }]);
2209
+ return;
2210
+ }
2211
+ if (args.mode === 'edit' && selection.type !== 'node') {
2212
+ selection.type = 'node';
2213
+ selection.anchor = Number(selection.anchor ?? 1) - 1;
2214
+ delete selection.head;
2215
+ }
2216
+ this.runCommands(livewire, [{ name: 'insertContent', arguments: [content] }], selection);
2217
+ });
2218
+ }
2219
+ customBlocksPanelHtml(key) {
2220
+ const searchable = this._searchableCustomBlocks;
2221
+ const groups = this.blockGroups();
2222
+ const allLabels = groups.flatMap(([label, blocks]) => [label, ...blocks.map((block) => block.getLabel())]).filter(Boolean);
2223
+ const parts = [];
2224
+ if (searchable)
2225
+ parts.push(`<div class="fi-fo-rich-editor-custom-blocks-search">${icon('m-magnifying-glass')}<input type="search" x-model="customBlockSearch" x-on:keydown.enter.prevent aria-label="${e(t('filament-forms::components.rich_editor.custom_blocks.search_label'))}" placeholder="${e(t('filament-forms::components.rich_editor.custom_blocks.search_prompt'))}" class="fi-fo-rich-editor-custom-blocks-search-input" /></div>`);
2226
+ const list = groups.map(([groupLabel, blocks]) => {
2227
+ const groupLabels = [groupLabel, ...blocks.map((block) => block.getLabel())];
2228
+ const show = (labels) => (searchable ? ` x-show="${e(`matchesCustomBlockSearch(${js(labels)})`)}"` : '');
2229
+ const header = groupLabel ? `<h4${show(groupLabels)} class="fi-fo-rich-editor-custom-blocks-group-header">${e(groupLabel)}</h4>` : '';
2230
+ const buttons = blocks.map((block) => {
2231
+ const id = block.getId();
2232
+ const blockIcon = block.getIcon();
2233
+ return `<button draggable="true" type="button" data-block-id="${e(id)}" x-data="{ isLoading: false }"${show([groupLabel, block.getLabel()])} x-on:click="${e(`isLoading = true\n$wire.mountAction('customBlock', { editorSelection, id: ${js(id)}, mode: 'insert' }, { schemaComponent: ${js(key)} })`)}" x-on:dragstart="${e(`$event.dataTransfer.setData('customBlock', ${js(id)})`)}" x-on:open-modal.window="isLoading = false" x-on:run-rich-editor-commands.window="isLoading = false" class="fi-fo-rich-editor-custom-block-btn">${blockIcon ? `<span x-show="! isLoading" class="fi-fo-rich-editor-custom-block-icon">${icon(blockIcon)}</span>` : ''}${loadingIndicator(new Attributes({ 'x-show': 'isLoading', 'x-cloak': '' }))}<span>${e(block.getLabel())}</span></button>`;
2234
+ }).join('');
2235
+ return `${header}<div${show(groupLabels)} class="${cls(['fi-fo-rich-editor-custom-blocks-list', { 'fi-fo-rich-editor-custom-blocks-grid': this._customBlocksGrid }])}">${buttons}</div>`;
2236
+ }).join('');
2237
+ const noResults = searchable ? `<p x-show="${e(`! matchesCustomBlockSearch(${js(allLabels)})`)}" x-cloak role="status" class="fi-fo-rich-editor-custom-blocks-no-results">${e(t('filament-forms::components.rich_editor.custom_blocks.no_search_results_message'))}</p>` : '';
2238
+ return `${parts.join('')}<div class="fi-fo-rich-editor-custom-blocks-ctn">${list}${noResults}</div>`;
2239
+ }
2240
+ mergeTagsPanelHtml() {
2241
+ return Object.entries(this._mergeTags).map(([id, label]) => `<button draggable="true" type="button" x-on:click="${e(`insertMergeTag(${js(id)})`)}" x-on:dragstart="${e(`$event.dataTransfer.setData('mergeTag', ${js(id)})`)}" class="fi-fo-rich-editor-merge-tag-btn"><span data-type="mergeTag" data-id="${e(id)}">${e(label)}</span></button>`).join('');
2242
+ }
992
2243
  async toHtml() {
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 });
2244
+ const key = this.getKey();
2245
+ let html = views.render('filament-forms::components.rich-editor', { livewireId: this.livewire.id, key, statePath: this.getStatePath(), label: await this.getLabel(), js });
2246
+ // Toolbar: the configured groups, each tool rendered from its definition.
2247
+ const toolbarStart = html.indexOf('data-rich-editor-toolbar="main"');
2248
+ if (toolbarStart >= 0) {
2249
+ const open = html.indexOf('>', toolbarStart) + 1;
2250
+ // The toolbar ends right before the "uploading file" message.
2251
+ const message = html.indexOf('x-show="isUploadingFile"', open);
2252
+ const close = message < 0 ? -1 : html.lastIndexOf('<div', message);
2253
+ if (close < 0)
2254
+ throw new Error('RichEditor: unexpected view markup (toolbar end not found).');
2255
+ const body = html.slice(open, close);
2256
+ const groups = this.toolGroups().map((group) => `<div class="fi-fo-rich-editor-toolbar-group">${group.map((tool) => this.findTool(tool)?.toHtml() ?? '').join('')}</div>`).join('');
2257
+ const endOfToolbar = body.lastIndexOf('</div>');
2258
+ html = html.slice(0, open) + groups + body.slice(endOfToolbar) + html.slice(close);
2259
+ if (this._stickyToolbar)
2260
+ html = html.replace('class="fi-fo-rich-editor-toolbar "', 'class="fi-fo-rich-editor-toolbar fi-fo-rich-editor-sticky-toolbar"');
2261
+ }
2262
+ // Panels: custom blocks and merge tags.
2263
+ const blocksStart = html.indexOf('<div class="fi-fo-rich-editor-custom-blocks-ctn">');
2264
+ if (blocksStart >= 0) {
2265
+ const blocksEnd = html.indexOf('</div>', blocksStart) + '</div>'.length;
2266
+ html = html.slice(0, blocksStart) + this.customBlocksPanelHtml(key) + html.slice(blocksEnd);
2267
+ }
2268
+ const tagsStart = html.indexOf('<div class="fi-fo-rich-editor-merge-tags-list">');
2269
+ if (tagsStart >= 0) {
2270
+ const open = html.indexOf('>', tagsStart) + 1;
2271
+ html = html.slice(0, open) + this.mergeTagsPanelHtml() + html.slice(html.indexOf('</div>', open));
2272
+ }
2273
+ const attach = this.toolGroups().flat().includes('attachFiles');
2274
+ const mentions = await Promise.all(this._mentions.map((provider) => provider.toJs()));
2275
+ html = html.replace('canAttachFiles: true,', `canAttachFiles: ${attach ? 'true' : 'false'},`)
2276
+ .replace('maxFileSize: 12288,', `maxFileSize: ${this._attachmentMaxSize},`)
2277
+ .replace(/maxFileSizeValidationMessage: '[^']*',/, () => `maxFileSizeValidationMessage: ${js(`Uploaded files must not be greater than ${this._attachmentMaxSize} kilobytes.`)},`)
2278
+ .replace(/acceptedFileTypes: JSON\.parse\('[^']*'\),/, () => `acceptedFileTypes: ${js(this._attachmentTypes)},`)
2279
+ .replace(/acceptedFileTypesValidationMessage: '[^']*',/, () => `acceptedFileTypesValidationMessage: ${js(`Uploaded files must be of type: ${this._attachmentTypes.join(', ')}.`)},`)
2280
+ .replace('activePanel: null,', () => `activePanel: ${js(this._activePanel)},`)
2281
+ .replace('extensions: [],', () => `extensions: ${js(this._jsExtensions)},`)
2282
+ .replace('hasResizableImages: false,', `hasResizableImages: ${this._resizableImages ? 'true' : 'false'},`)
2283
+ .replace('hasMinimalCustomBlockControls: false,', `hasMinimalCustomBlockControls: ${this._minimalCustomBlockControls ? 'true' : 'false'},`)
2284
+ .replace('hasStickyToolbar: false,', `hasStickyToolbar: ${this._stickyToolbar ? 'true' : 'false'},`)
2285
+ .replace('mentions: [],', () => `mentions: ${js(mentions)},`)
2286
+ .replace('mergeTags: [],', () => `mergeTags: ${js(Object.keys(this._mergeTags).length ? this._mergeTags : [])},`)
2287
+ .replace(/noMergeTagSearchResultsMessage: '[^']*',/, () => `noMergeTagSearchResultsMessage: ${js(this._noMergeTagResults ?? t('filament-forms::components.rich_editor.no_merge_tag_search_results_message'))},`)
2288
+ .replace(/textColors: JSON\.parse\('[^']*'\),/, () => `textColors: ${js(textColorsForJs(this.getTextColors()))},`)
2289
+ .replace('placeholder: null,', `placeholder: ${js(this._richPlaceholder)},`)
2290
+ .replace('isLiveOnBlur: false,', `isLiveOnBlur: ${this._liveMode === 'blur' ? 'true' : 'false'},`)
2291
+ .replace(/state: \$wire\.\$entangle\(('[^']*'), false\)/, (_m, path) => `state: $wire.$entangle(${path}, ${this._liveMode === 'live' ? 'true' : 'false'})`);
2292
+ return this.wrap(html, { labelAsDiv: true });
994
2293
  }
995
2294
  }
996
2295
  /** Color picker (hex format). */
997
2296
  export class ColorPicker extends Field {
998
2297
  static make(name) { return configured(new ColorPicker(name)); }
999
- /** Hex format: the only one supported, kept for API compatibility. */
1000
- hex() { return this; }
1001
- constructor(name) {
1002
- super(name);
1003
- this.rule({ name: 'hex_color', passes: (v) => /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(String(v)), message: (a) => `The ${a} field must be a valid hexadecimal color.` });
2298
+ _format = 'hex';
2299
+ /** Color format of the state: `#ff0000` (default), `rgb(255, 0, 0)`, `rgba(255, 0, 0, 1)` or `hsl(0, 100%, 50%)`. */
2300
+ format(format) { this._format = format; return this; }
2301
+ hex() { return this.format('hex'); }
2302
+ rgb() { return this.format('rgb'); }
2303
+ rgba() { return this.format('rgba'); }
2304
+ hsl() { return this.format('hsl'); }
2305
+ async getValidationRules() {
2306
+ const patterns = {
2307
+ hex: /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/,
2308
+ rgb: /^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/,
2309
+ rgba: /^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0|1|0?\.\d+)\s*\)$/,
2310
+ hsl: /^hsl\(\s*\d{1,3}(\.\d+)?\s*,\s*\d{1,3}(\.\d+)?%\s*,\s*\d{1,3}(\.\d+)?%\s*\)$/,
2311
+ };
2312
+ const pattern = patterns[this._format];
2313
+ return [...(await super.getValidationRules()), { name: this._format === 'hex' ? 'hex_color' : 'regex', passes: (v) => pattern.test(String(v)), message: (a) => (this._format === 'hex' ? `The ${a} field must be a valid hexadecimal color.` : `The ${a} field format is invalid.`) }];
1004
2314
  }
1005
2315
  async toHtml() {
1006
2316
  const isDisabled = await this.isDisabled();
@@ -1024,17 +2334,20 @@ export class ColorPicker extends Field {
1024
2334
  state: $wire.$entangle(${e(js(this.getStatePath()))}),
1025
2335
  })" x-on:dropdown-escape="if (isOpen()) { closePanelAndRestoreFocus(); $event.preventDefault() }" x-on:keydown.esc="isOpen() &amp;&amp; $event.stopPropagation()" x-on:focusout="if (isOpen() &amp;&amp; ! $el.contains($event.relatedTarget)) $refs.panel.close()" wire:key="${e(key)}.${hash}" class="fi-input-wrp-content"><input x-on:focus="$refs.panel.open($refs.input)" x-on:keydown.enter.prevent.stop="togglePanelVisibility()" x-ref="input" ${input.toHtml()} /><div aria-hidden="true" class="fi-fo-color-picker-preview my-auto me-3 size-5 shrink-0 rounded-full select-none" x-on:click="togglePanelVisibility()" x-bind:class="{
1026
2336
  'fi-empty': ! state,
1027
- }" x-bind:style="{ 'background-color': state }"></div><div wire:ignore.self wire:key="${e(key)}.panel" x-cloak x-float.placement.bottom-start.offset.flip.shift="{ offset: 8 }" x-ref="panel" id="${e(id)}-panel" role="dialog" aria-label="${e(t('filament-forms::components.color_picker.panel_label'))}" class="fi-fo-color-picker-panel"><hex-color-picker x-ref="picker" color="${e(typeof state === 'string' ? state : '')}" /></div></div></div></div>`);
2337
+ }" x-bind:style="{ 'background-color': state }"></div><div wire:ignore.self wire:key="${e(key)}.panel" x-cloak x-float.placement.bottom-start.offset.flip.shift="{ offset: 8 }" x-ref="panel" id="${e(id)}-panel" role="dialog" aria-label="${e(t('filament-forms::components.color_picker.panel_label'))}" class="fi-fo-color-picker-panel"><${({ hex: 'hex', rgb: 'rgb-string', rgba: 'rgba-string', hsl: 'hsl-string' })[this._format]}-color-picker x-ref="picker" color="${e(typeof state === 'string' ? state : '')}" /></div></div></div></div>`);
1028
2338
  }
1029
2339
  }
1030
2340
  export class Checkbox extends Field {
2341
+ _inline = true;
1031
2342
  static make(name) { return configured(new Checkbox(name)); }
1032
2343
  getDefaultState() { return this._default ?? false; }
2344
+ /** `false`: the label goes above the checkbox instead of beside it. */
2345
+ inline(condition = true) { this._inline = condition; return this; }
1033
2346
  async toHtml() {
1034
2347
  const input = new Attributes()
1035
- .merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(), 'wire:model': this.getStatePath() })
2348
+ .merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(), [this.wireModel()]: this.getStatePath() })
1036
2349
  .class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
1037
- return this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` });
2350
+ return this._inline ? this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` }) : this.wrap(`<input ${input.toHtml()} />`);
1038
2351
  }
1039
2352
  }
1040
2353
  /** Checkboxes for a list of values (no search or "select all" yet). */
@@ -1045,6 +2358,23 @@ export class CheckboxList extends Field {
1045
2358
  options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
1046
2359
  /** Number of columns the options are spread over (from `lg`). */
1047
2360
  columns(columns) { this._optionColumns = columns; return this; }
2361
+ _descriptions = {};
2362
+ _searchable = false;
2363
+ _bulkToggleable = false;
2364
+ _disableOptionWhen = null;
2365
+ /** Helper text per value, under the option label. */
2366
+ descriptions(descriptions) { this._descriptions = descriptions; return this; }
2367
+ /** A search field filters the options in the browser. */
2368
+ searchable(condition = true) { this._searchable = condition; return this; }
2369
+ _searchPrompt = 'Search';
2370
+ _noSearchResultsMessage = 'No search results.';
2371
+ searchPrompt(prompt) { this._searchPrompt = prompt; return this; }
2372
+ noSearchResultsMessage(message) { this._noSearchResultsMessage = message; return this; }
2373
+ /** "Select all" / "Deselect all" links. */
2374
+ bulkToggleable(condition = true) { this._bulkToggleable = condition; return this; }
2375
+ /** Options that cannot be checked; they are refused on the server too. */
2376
+ disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
2377
+ isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
1048
2378
  getDefaultState() { return this._default ?? []; }
1049
2379
  hydrateState(value) {
1050
2380
  let list = value;
@@ -1064,21 +2394,28 @@ export class CheckboxList extends Field {
1064
2394
  return this._options.map(([value]) => value).filter((value) => chosen.has(value));
1065
2395
  }
1066
2396
  async getValidationRules() {
1067
- const known = new Set(this._options.map(([value]) => value));
2397
+ // A disabled option cannot be checked, even by a forged request.
2398
+ const known = new Set(this._options.filter(([value, label]) => !this.isOptionDisabled(value, label)).map(([value]) => value));
1068
2399
  return [...(await super.getValidationRules()), { name: 'in', passes: (v) => Array.isArray(v) && v.every((value) => known.has(String(value))), message: (a) => `The selected ${a} is invalid.` }];
1069
2400
  }
1070
2401
  async toHtml() {
1071
2402
  const isDisabled = await this.isDisabled();
1072
2403
  const key = this.getLivewireKey() ?? '';
1073
2404
  const options = this._options.map(([value, label]) => {
1074
- const input = new Attributes().merge({ disabled: isDisabled, type: 'checkbox', value, 'wire:model': this.getStatePath() }).class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
1075
- return `<div wire:key="${e(`${key}.options.${value}`)}" class="fi-fo-checkbox-list-option-ctn"><label class="fi-fo-checkbox-list-option"><input ${input.toHtml()} /><div class="fi-fo-checkbox-list-option-text"><span class="fi-fo-checkbox-list-option-label">${e(label)}</span></div></label></div>`;
2405
+ const input = new Attributes().merge({ disabled: isDisabled || this.isOptionDisabled(value, label), type: 'checkbox', value, [this.wireModel()]: this.getStatePath(), 'x-on:change': this._bulkToggleable ? 'checkIfAllCheckboxesAreChecked()' : null }).class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
2406
+ const description = this._descriptions[value] ? `<p class="fi-fo-checkbox-list-option-description">${e(this._descriptions[value])}</p>` : '';
2407
+ return `<div wire:key="${e(`${key}.options.${value}`)}"${this._searchable ? ' x-show="matchesSearch($el)"' : ''} class="fi-fo-checkbox-list-option-ctn"><label class="fi-fo-checkbox-list-option"><input ${input.toHtml()} /><div class="fi-fo-checkbox-list-option-text"><span class="fi-fo-checkbox-list-option-label">${e(label)}</span>${description}</div></label></div>`;
1076
2408
  });
1077
2409
  const columns = this._optionColumns ?? 1;
1078
2410
  const grid = columns > 1 ? `class="fi-grid fi-grid-direction-col lg:fi-grid-cols fi-fo-checkbox-list-options" style="--cols-lg: ${Math.floor(columns)}; --cols-default: 1;"` : 'class="fi-grid fi-grid-direction-col fi-fo-checkbox-list-options" style="--cols-default: 1;"';
2411
+ const searchPrompt = this._searchPrompt;
2412
+ const search = this._searchable ? `<div class="fi-input-wrp fi-fo-checkbox-list-search-input-wrp"><div class="fi-input-wrp-prefix fi-input-wrp-prefix-has-content fi-inline">${icon('m-magnifying-glass')}</div><div class="fi-input-wrp-content-ctn"><input aria-label="${e(searchPrompt)}" placeholder="${e(searchPrompt)}" type="search" x-model.debounce.1000="search" class="fi-input fi-input-has-inline-prefix" /></div></div>` : '';
2413
+ const link = (label) => `<button type="button" class="fi-ac-link-action fi-link fi-size-sm ${colorManager ? colorManager.getComponentClasses('link', 'primary').join(' ') : ''}"><span class="fi-link-label">${e(label)}</span></button>`;
2414
+ const bulk = this._bulkToggleable && !isDisabled && this._options.length ? `<div x-cloak class="fi-fo-checkbox-list-actions" wire:key="${e(key)}.actions"><span x-show="! areAllCheckboxesChecked" x-on:click="toggleAllCheckboxes()" wire:key="${e(key)}.actions.select-all">${link(t('filament-forms::components.checkbox_list.actions.select_all.label'))}</span><span x-show="areAllCheckboxesChecked" x-on:click="toggleAllCheckboxes()" wire:key="${e(key)}.actions.deselect-all">${link(t('filament-forms::components.checkbox_list.actions.deselect_all.label'))}</span></div>` : '';
2415
+ const noResults = this._searchable ? `<div x-cloak x-show="search && ! visibleCheckboxListOptions.length" role="status" aria-live="polite" class="fi-fo-checkbox-list-no-search-results-message">${e(this._noSearchResultsMessage)}</div>` : '';
1079
2416
  return this.wrap(`<div role="group" x-load x-load-src="${e(asset('checkbox-list'))}" x-data="checkboxListFormComponent({
1080
2417
  livewireId: ${e(js(this.livewire.id))},
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 });
2418
+ })" aria-labelledby="${e(this.getId())}-label" class="fi-fo-checkbox-list">${search}${bulk}<div wire:key="${e(key)}.options"${this._searchable ? ' x-show="visibleCheckboxListOptions.length"' : ''} ${grid}>${options.join('')}</div>${noResults}</div>`, { labelAsDiv: true });
1082
2419
  }
1083
2420
  }
1084
2421
  /** Free-form key/value pairs, saved as an object. */
@@ -1090,6 +2427,26 @@ export class KeyValue extends Field {
1090
2427
  keyLabel(label) { this._keyLabel = label; return this; }
1091
2428
  valueLabel(label) { this._valueLabel = label; return this; }
1092
2429
  reorderable(condition = true) { this._reorderable = condition; return this; }
2430
+ _addable = true;
2431
+ _deletable = true;
2432
+ _editableKeys = true;
2433
+ _editableValues = true;
2434
+ _keyPlaceholder = null;
2435
+ _valuePlaceholder = null;
2436
+ _addLabel = null;
2437
+ _deleteLabel = null;
2438
+ /** `false`: no "Add row" button. */
2439
+ addable(condition = true) { this._addable = condition; return this; }
2440
+ /** `false`: rows cannot be deleted. */
2441
+ deletable(condition = true) { this._deletable = condition; return this; }
2442
+ /** `false`: keys cannot be changed. The server keeps the saved keys. */
2443
+ editableKeys(condition = true) { this._editableKeys = condition; return this; }
2444
+ /** `false`: values cannot be changed. */
2445
+ editableValues(condition = true) { this._editableValues = condition; return this; }
2446
+ keyPlaceholder(text) { this._keyPlaceholder = text; return this; }
2447
+ valuePlaceholder(text) { this._valuePlaceholder = text; return this; }
2448
+ addActionLabel(label) { this._addLabel = label; return this; }
2449
+ deleteActionLabel(label) { this._deleteLabel = label; return this; }
1093
2450
  // In the form, the state is a list of `{ key, value }` pairs (what the browser component expects, and it keeps order);
1094
2451
  // it becomes an object again when saved.
1095
2452
  getDefaultState() { return KeyValue.toPairs(this._default); }
@@ -1105,10 +2462,29 @@ export class KeyValue extends Field {
1105
2462
  }
1106
2463
  return object && typeof object === 'object' && !Array.isArray(object) ? Object.entries(object).map(([key, v]) => ({ key, value: v })) : [];
1107
2464
  }
1108
- hydrateState(value) { return KeyValue.toPairs(value); }
2465
+ _originalValue = null;
2466
+ hydrateState(value) { this._originalValue = value; return KeyValue.toPairs(value); }
1109
2467
  /** Flat object of strings: empty keys, nested objects and dangerous keys are dropped; 200 pairs at most. */
1110
2468
  dehydrateState(state) {
1111
2469
  const out = {};
2470
+ // Restrictions set on the field are enforced on the server: a forged state cannot add, delete or edit what the UI forbids.
2471
+ // The saved value: the one hydrated by this instance, else the record's (fields are rebuilt on every request), else the default.
2472
+ const record = this.context().record;
2473
+ const saved = KeyValue.toPairs(this._originalValue ?? (record && typeof record === 'object' ? record[this._name] : null) ?? this._default);
2474
+ if (!this._addable || !this._deletable || !this._editableKeys || !this._editableValues) {
2475
+ const received = Array.isArray(state) ? state.filter((pair) => pair && typeof pair === 'object' && typeof pair.key === 'string') : [];
2476
+ const byKey = new Map(received.map((pair) => [String(pair.key).trim(), pair]));
2477
+ let rows = this._editableKeys ? received.map((pair) => ({ key: String(pair.key), value: pair.value })) : saved.map((pair, index) => ({ key: pair.key, value: received[index]?.value ?? pair.value }));
2478
+ if (!this._editableValues)
2479
+ rows = rows.map((row) => ({ ...row, value: saved.find((pair) => pair.key === row.key)?.value ?? null }));
2480
+ if (!this._addable)
2481
+ rows = rows.filter((row) => saved.some((pair) => pair.key === row.key));
2482
+ if (!this._deletable)
2483
+ for (const pair of saved)
2484
+ if (!rows.some((row) => row.key === pair.key))
2485
+ rows.push({ key: pair.key, value: byKey.get(pair.key)?.value ?? pair.value });
2486
+ state = rows;
2487
+ }
1112
2488
  if (!Array.isArray(state))
1113
2489
  return out;
1114
2490
  for (const pair of state.slice(0, 200)) {
@@ -1134,7 +2510,7 @@ export class KeyValue extends Field {
1134
2510
  return this.wrap(`<div aria-labelledby="${e(id)}-label" role="group" class="fi-fo-key-value fi-input-wrp"><div class="fi-input-wrp-content-ctn"><div x-load x-load-src="${e(asset('key-value'))}" x-id="['key-value-row']" x-data="keyValueFormComponent({
1135
2511
  state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
1136
2512
  reordering: ${e(js(reordering))},
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' });
2513
+ })" 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>${this._deletable ? `<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="${e(this._keyPlaceholder ?? '')}" ${this._editableKeys ? '' : 'disabled '}type="text" x-model="row.key" x-on:input.debounce.500ms="updateState" class="fi-input" /></td><td><input aria-label="${e(valueLabel)}" placeholder="${e(this._valuePlaceholder ?? '')}" ${this._editableValues ? '' : 'disabled '}type="text" x-model="row.value" x-on:input.debounce.500ms="updateState" class="fi-input" /></td>${this._deletable ? `<td class="fi-has-action"><div x-on:click="deleteRow(index)"><button title="${e(this._deleteLabel ?? tr('actions.delete.label'))}" aria-label="${e(this._deleteLabel ?? 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>${this._addable ? `<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(this._addLabel ?? tr('actions.add.label'))}</span></button></div>` : ''}</div></div></div>`, { labelAsDiv: true, wrapperClass: 'fi-fo-key-value-wrp' });
1138
2514
  }
1139
2515
  }
1140
2516
  /** Developer-written JavaScript expression (e.g. a Slider tooltip formatter), inserted as is: never put user data in it. */
@@ -1153,10 +2529,10 @@ function accessibilityOf(field, options = {}) {
1153
2529
  }
1154
2530
  /** Second `$entangle()` argument: true only for a plain `live()` field (no `onBlur`, no `debounce`). */
1155
2531
  const entangleLive = (live) => (live !== null && !live.onBlur && live.debounce === null ? 'true' : 'false');
1156
- const MARKDOWN_TOOLBAR = [['bold', 'italic', 'strike', 'link'], ['heading'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table'], ['undo', 'redo']];
2532
+ const MARKDOWN_TOOLBAR = [['bold', 'italic', 'strike', 'link'], ['heading'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table', 'attachFiles'], ['undo', 'redo']];
1157
2533
  /**
1158
2534
  * 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()`),
2535
+ * Files can be attached (images, stored on upload) when a storage is set (`fileAttachmentsStorage()` or `FileUpload.storage`); when read-only (`disabled()`),
1160
2536
  * the text is shown escaped, without Markdown conversion.
1161
2537
  */
1162
2538
  export class MarkdownEditor extends Field {
@@ -1191,9 +2567,31 @@ export class MarkdownEditor extends Field {
1191
2567
  async getValidationRules() {
1192
2568
  return [...(await super.getValidationRules()), { name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }];
1193
2569
  }
2570
+ _attachmentsStorage = null;
2571
+ _attachmentsDirectory = null;
2572
+ _attachmentTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
2573
+ _attachmentMaxSize = 12288;
2574
+ /** Where attached files are stored (`FileUpload.storage` by default); without storage, the "Attach files" button is hidden. */
2575
+ fileAttachmentsStorage(storage) { this._attachmentsStorage = storage; return this; }
2576
+ fileAttachmentsDirectory(directory) { this._attachmentsDirectory = directory; return this; }
2577
+ /** Accepted types of attached files (images), checked on the file's content. */
2578
+ fileAttachmentsAcceptedFileTypes(types) { this._attachmentTypes = types.filter((type) => type.startsWith('image/') && type !== 'image/svg+xml'); return this; }
2579
+ /** Maximum size of attached files, in kilobytes. */
2580
+ fileAttachmentsMaxSize(kilobytes) { this._attachmentMaxSize = kilobytes; return this; }
2581
+ canAttachFiles() { return this.hasToolbarButton('attachFiles') && (this._attachmentsStorage ?? FileUpload.storage) !== null; }
2582
+ visibleToolbar() {
2583
+ if (this.canAttachFiles())
2584
+ return this._toolbarButtons;
2585
+ return this._toolbarButtons.map((group) => (Array.isArray(group) ? group.filter((name) => name !== 'attachFiles') : group)).filter((group) => (Array.isArray(group) ? group.length > 0 : group !== 'attachFiles'));
2586
+ }
1194
2587
  getExposedMethods() { return ['getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
1195
2588
  getUploadedFileAttachmentTemporaryUrl() { return null; }
1196
- saveUploadedFileAttachmentAndGetUrl() { return null; }
2589
+ /** Stores the file just uploaded to `componentFileAttachments.<state path>` and returns its URL. */
2590
+ async saveUploadedFileAttachmentAndGetUrl() {
2591
+ if (!this.canAttachFiles() || (await this.isDisabled()))
2592
+ return null;
2593
+ return storeEditorAttachment(this, null, { storage: this._attachmentsStorage ?? FileUpload.storage, directory: this._attachmentsDirectory, types: this._attachmentTypes, maxSize: this._attachmentMaxSize });
2594
+ }
1197
2595
  async toHtml() {
1198
2596
  const id = this.getId();
1199
2597
  const accessibility = accessibilityOf(this);
@@ -1214,7 +2612,7 @@ export class MarkdownEditor extends Field {
1214
2612
  tools: Object.fromEntries(['attach_files', 'blockquote', 'bold', 'bullet_list', 'code_block', 'heading', 'italic', 'link', 'ordered_list', 'redo', 'strike', 'table', 'undo'].map((tool) => [tool, tr(`tools.${tool}`)])),
1215
2613
  };
1216
2614
  const config = `markdownEditorFormComponent({
1217
- canAttachFiles: false,
2615
+ canAttachFiles: ${this.canAttachFiles() ? 'true' : 'false'},
1218
2616
  isLiveDebounced: ${this._live?.debounce !== null && this._live?.debounce !== undefined ? 'true' : 'false'},
1219
2617
  isLiveOnBlur: ${this._live?.onBlur ? 'true' : 'false'},
1220
2618
  label: ${js(await this.getLabel())},
@@ -1223,10 +2621,26 @@ export class MarkdownEditor extends Field {
1223
2621
  minHeight: ${js(this._minHeight)},
1224
2622
  placeholder: ${js(this._placeholder)},
1225
2623
  state: $wire.$entangle(${js(this.getStatePath())}, ${entangleLive(this._live)}),
1226
- toolbarButtons: ${js(this._toolbarButtons)},
2624
+ toolbarButtons: ${js(this.visibleToolbar())},
1227
2625
  translations: ${js(translations)},
1228
2626
  uploadFileAttachmentUsing: async (file, onSuccess, onError) => {
2627
+ const acceptedTypes = ${js(this._attachmentTypes)}
2628
+ if (acceptedTypes && ! acceptedTypes.includes(file.type)) {
2629
+ return onError(${js(tr('file_attachments_accepted_file_types_message').replace(':values', this._attachmentTypes.join(', ')))})
2630
+ }
2631
+ const maxSize = ${js(this._attachmentMaxSize)}
2632
+ if (maxSize && file.size > +maxSize * 1024) {
2633
+ return onError(${js(tr('file_attachments_max_size_message').replace(':max', String(this._attachmentMaxSize)))})
2634
+ }
2635
+ $wire.upload(${js(`componentFileAttachments.${this.getStatePath()}`)}, file, () => {
2636
+ $saveUploadedFileAttachmentAndGetUrl()
2637
+ .then((url) => {
2638
+ if (! url) {
1229
2639
  return onError()
2640
+ }
2641
+ onSuccess(url)
2642
+ })
2643
+ })
1230
2644
  },
1231
2645
  })`;
1232
2646
  const wrapper = new Attributes().class(['fi-fo-markdown-editor', 'fi-input-wrp', { 'fi-invalid': this.hasError() }]);
@@ -1508,6 +2922,26 @@ export function fillSchemaFromRecord(schema, target, record) {
1508
2922
  writePath(target, segments, c.hydrateState(readPath(record, segments) ?? c.getDefaultState()));
1509
2923
  }
1510
2924
  }
2925
+ /** Fills the fields that load their state from the record's relationships (`loadStateFromRelationshipsUsing()`, `Select::relationship()`…). */
2926
+ export async function loadSchemaRelationships(schema, target, record) {
2927
+ if (!record)
2928
+ return;
2929
+ for (const c of schema.getFlatComponents()) {
2930
+ if (!(c instanceof Field))
2931
+ continue;
2932
+ const loaded = await c.loadStateFromRelationships(record);
2933
+ if (loaded)
2934
+ writePath(target, relativePath(schema, c), c.hydrateState(loaded.state));
2935
+ }
2936
+ }
2937
+ /** Saves the visible fields that write the record's relationships, once the record is created or updated. */
2938
+ export async function saveSchemaRelationships(schema, record) {
2939
+ if (!record)
2940
+ return;
2941
+ for (const c of await schema.getVisibleFlatComponents())
2942
+ if (c instanceof Field && (await c.isDehydrated() || c._dehydrated === false) && (await c.savesRelationships()))
2943
+ await c.saveRelationships(record);
2944
+ }
1511
2945
  /** Validates every visible field of a schema; returns errors by state path. */
1512
2946
  export async function validateSchema(schema) {
1513
2947
  const errors = {};
@@ -1524,11 +2958,21 @@ export async function validateSchema(schema) {
1524
2958
  export async function getSchemaState(schema) {
1525
2959
  const data = {};
1526
2960
  const fields = [];
2961
+ const skipped = [];
1527
2962
  for (const c of await schema.getVisibleFlatComponents()) {
1528
2963
  if (!(c instanceof Field) || !(await c.isDehydrated()))
1529
2964
  continue;
2965
+ // Relationship fields (and the fields of their items) save themselves after the record (see saveSchemaRelationships), unless forced with `dehydrated()`.
2966
+ if (skipped.some((path) => c.getStatePath().startsWith(`${path}.`)))
2967
+ continue;
2968
+ if (c._dehydrated !== true && (await c.savesRelationships())) {
2969
+ skipped.push(c.getStatePath());
2970
+ continue;
2971
+ }
1530
2972
  const state = await c.dehydrateState(c.context().state);
1531
2973
  writePath(data, relativePath(schema, c), state === '' || state === undefined ? null : state);
2974
+ for (const [name, value] of Object.entries(c.getExtraDehydratedState?.() ?? {}))
2975
+ writePath(data, [...relativePath(schema, c).slice(0, -1), name], value);
1532
2976
  fields.push(c);
1533
2977
  }
1534
2978
  // Deepest first: a container sees its children already shaped.
@@ -1716,6 +3160,60 @@ export class Wizard extends Component {
1716
3160
  }
1717
3161
  }
1718
3162
  /** Local disk storage, served under a public URL. File names are random; the extension comes from the detected type. */
3163
+ /**
3164
+ * A file name chosen by the application or the user, made safe for storage: no directory part, only letters, digits, `_` and
3165
+ * `-`, and always the extension detected from the file's content (an uploaded `shell.php` declared as an image is stored
3166
+ * as `shell.png`).
3167
+ */
3168
+ export function safeStorageName(name, extension) {
3169
+ const base = (name.split(/[\\/]/).pop() ?? '').replace(/\.[^.]*$/, '').normalize('NFKD').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 180) || randomUUID();
3170
+ return `${base}.${extension.replace(/[^A-Za-z0-9]/g, '') || 'bin'}`;
3171
+ }
3172
+ /** Width and height of a PNG, GIF, JPEG or WebP image, read from its header; `null` for other files. */
3173
+ export function readImageSize(path) {
3174
+ let buffer;
3175
+ try {
3176
+ const fd = openSync(path, 'r');
3177
+ buffer = Buffer.alloc(1024 * 1024);
3178
+ const read = readSync(fd, buffer, 0, buffer.length, 0);
3179
+ closeSync(fd);
3180
+ buffer = buffer.subarray(0, read);
3181
+ }
3182
+ catch {
3183
+ return null;
3184
+ }
3185
+ if (buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504e47)
3186
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
3187
+ if (buffer.length >= 10 && buffer.toString('ascii', 0, 3) === 'GIF')
3188
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
3189
+ if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
3190
+ const chunk = buffer.toString('ascii', 12, 16);
3191
+ if (chunk === 'VP8X')
3192
+ return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) };
3193
+ if (chunk === 'VP8L') {
3194
+ const bits = buffer.readUInt32LE(21);
3195
+ return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
3196
+ }
3197
+ if (chunk === 'VP8 ')
3198
+ return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
3199
+ return null;
3200
+ }
3201
+ if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
3202
+ let offset = 2;
3203
+ while (offset + 9 < buffer.length) {
3204
+ if (buffer[offset] !== 0xff) {
3205
+ offset++;
3206
+ continue;
3207
+ }
3208
+ const marker = buffer[offset + 1];
3209
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker))
3210
+ return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) };
3211
+ offset += 2 + buffer.readUInt16BE(offset + 2);
3212
+ }
3213
+ }
3214
+ return null;
3215
+ }
3216
+ const ratioOf = (ratio) => { const [width, height] = ratio.split(/[:/]/).map(Number); return width > 0 && height > 0 ? width / height : null; };
1719
3217
  export class LocalFileStorage {
1720
3218
  root;
1721
3219
  baseUrl;
@@ -1725,13 +3223,24 @@ export class LocalFileStorage {
1725
3223
  return null;
1726
3224
  return nodePath.join(this.root, ...key.split('/'));
1727
3225
  }
1728
- async store(file, directory) {
1729
- const key = [directory, `${randomUUID()}.${file.extension}`].filter(Boolean).join('/');
3226
+ async store(file, directory, name = null) {
3227
+ let key = [directory, name === null ? `${randomUUID()}.${file.extension}` : safeStorageName(name, file.extension)].filter(Boolean).join('/');
3228
+ // A chosen name never overwrites another file: a short random suffix is added.
3229
+ if (name !== null && this.resolve(key) && existsSync(this.resolve(key)))
3230
+ key = key.replace(/(\.[A-Za-z0-9]+)$/, `-${randomUUID().slice(0, 8)}$1`);
1730
3231
  const target = this.resolve(key);
1731
3232
  if (!target)
1732
3233
  throw new Error('Invalid upload directory.');
1733
3234
  mkdirSync(nodePath.dirname(target), { recursive: true });
1734
- copyFileSync(file.path, target);
3235
+ try {
3236
+ copyFileSync(file.path, target, fsConstants.COPYFILE_EXCL);
3237
+ }
3238
+ catch (error) {
3239
+ // Another save took this name meanwhile: random suffix.
3240
+ if (error?.code !== 'EEXIST' || name === null)
3241
+ throw error;
3242
+ return this.store(file, directory, `${safeStorageName(name, file.extension).replace(/\.[^.]*$/, '')}-${randomUUID().slice(0, 8)}`);
3243
+ }
1735
3244
  writeFileSync(`${target}.meta.json`, JSON.stringify({ name: file.name, type: file.type }));
1736
3245
  return key;
1737
3246
  }
@@ -1764,7 +3273,107 @@ export class FileUpload extends Field {
1764
3273
  _storeFiles = true;
1765
3274
  _storage = null;
1766
3275
  _getUploadedFileUsing = null;
3276
+ _minSize = null;
3277
+ _ui = {};
3278
+ _avatar = false;
3279
+ _imageEditor = false;
3280
+ _circleCropper = false;
3281
+ _editorAspectRatios = [];
3282
+ _cropAspectRatio = null;
3283
+ _imageAspectRatios = null;
3284
+ _dimensions = null;
3285
+ _deletable = true;
3286
+ _preserveFilenames = false;
3287
+ _fileNameUsing = null;
3288
+ _fileNamesField = null;
3289
+ _storedNames = {};
3290
+ _alignment = null;
3291
+ _placeholder = null;
1767
3292
  static make(name) { return configured(new FileUpload(name)); }
3293
+ /** Minimum file size, in kilobytes. */
3294
+ minSize(kilobytes) { this._minSize = kilobytes; return this; }
3295
+ /** Download button on each saved file. */
3296
+ downloadable(condition = true) { this._ui.isDownloadable = condition; return this; }
3297
+ /** Button opening each saved file in a new tab. */
3298
+ openable(condition = true) { this._ui.isOpenable = condition; return this; }
3299
+ /** `false`: no preview of images. */
3300
+ previewable(condition = true) { this._ui.isPreviewable = condition; return this; }
3301
+ /** `false`: files cannot be pasted from the clipboard. */
3302
+ pasteable(condition = true) { this._ui.isPasteable = condition; return this; }
3303
+ /** New files are added after the existing ones (instead of before). */
3304
+ appendFiles(condition = true) { this._ui.shouldAppendFiles = condition; return this; }
3305
+ /** `false`: saved files cannot be removed (the remove button is hidden, and removing is refused on the server). */
3306
+ deletable(condition = true) { this._deletable = condition; return this; }
3307
+ /** `'grid'` (with `multiple()`), `'compact'`, `'integrated'` or `'circle'`. */
3308
+ panelLayout(layout) { this._ui.panelLayout = layout; return this; }
3309
+ panelAspectRatio(ratio) { this._ui.panelAspectRatio = ratio; return this; }
3310
+ itemPanelAspectRatio(ratio) { this._ui.itemPanelAspectRatio = ratio; return this; }
3311
+ imagePreviewHeight(height) { this._ui.imagePreviewHeight = height; return this; }
3312
+ loadingIndicatorPosition(position) { this._ui.loadingIndicatorPosition = position; return this; }
3313
+ removeUploadedFileButtonPosition(position) { this._ui.removeUploadedFileButtonPosition = position; return this; }
3314
+ uploadButtonPosition(position) { this._ui.uploadButtonPosition = position; return this; }
3315
+ uploadProgressIndicatorPosition(position) { this._ui.uploadProgressIndicatorPosition = position; return this; }
3316
+ /** Message shown while a file uploads. */
3317
+ uploadingMessage(message) { this._ui.uploadingMessage = message; return this; }
3318
+ /** Number of files uploaded at the same time. */
3319
+ maxParallelUploads(count) { this._ui.maxParallelUploads = count; return this; }
3320
+ /** Extension → MIME type map for files the browser cannot type (`{ zip: 'application/zip' }`). */
3321
+ mimeTypeMap(map) { this._ui.mimeTypeMap = map; return this; }
3322
+ /** `false`: images keep their EXIF orientation. */
3323
+ orientImagesFromExif(condition = true) { this._ui.shouldOrientImageFromExif = condition; return this; }
3324
+ alignment(alignment) { this._alignment = alignment; return this; }
3325
+ /** Text of the drop area. */
3326
+ placeholder(text) { this._placeholder = text; return this; }
3327
+ /** Round avatar: one image, cropped to a square and shown as a circle. */
3328
+ avatar() { this._avatar = true; this.image(); this._ui.panelLayout ??= 'compact circle'; this._ui.imagePreviewHeight ??= '250'; this._cropAspectRatio ??= '1:1'; this._ui.automaticallyResizeImagesHeight ??= '500'; this._ui.automaticallyResizeImagesWidth ??= '500'; this._ui.loadingIndicatorPosition ??= 'center bottom'; this._ui.removeUploadedFileButtonPosition ??= 'center bottom'; this._ui.uploadButtonPosition ??= 'center bottom'; this._ui.uploadProgressIndicatorPosition ??= 'center bottom'; this._alignment ??= 'center'; return this; }
3329
+ /** Image editor (crop, rotate, flip, zoom) opened from each image, powered by Cropper.js in the browser. */
3330
+ imageEditor(condition = true) { this._imageEditor = condition; return this; }
3331
+ /** Aspect ratios offered by the image editor (`['16:9', '4:3', '1:1']`; `null` for a free crop). */
3332
+ imageEditorAspectRatioOptions(ratios) { this._editorAspectRatios = ratios; return this; }
3333
+ imageEditorAspectRatios(ratios) { return this.imageEditorAspectRatioOptions(ratios); }
3334
+ /** Cropper.js view mode (1, 2 or 3). */
3335
+ imageEditorMode(mode) { this._ui.imageEditorMode = mode; return this; }
3336
+ imageEditorEmptyFillColor(color) { this._ui.imageEditorEmptyFillColor = color; return this; }
3337
+ imageEditorViewportWidth(width) { this._ui.imageEditorViewportWidth = width === null ? null : String(width); return this; }
3338
+ imageEditorViewportHeight(height) { this._ui.imageEditorViewportHeight = height === null ? null : String(height); return this; }
3339
+ _autoOpenEditor = true;
3340
+ /** With `imageEditor()` and a single aspect ratio, opens the editor when an uploaded image has another ratio (on by default). */
3341
+ automaticallyOpenImageEditorForAspectRatio(condition = true) { this._autoOpenEditor = condition; return this; }
3342
+ autoOpenEditorRatio() {
3343
+ if (!this._imageEditor || !this._autoOpenEditor)
3344
+ return null;
3345
+ const ratio = this._cropAspectRatio ?? (this._imageAspectRatios?.length === 1 ? this._imageAspectRatios[0] : null);
3346
+ return ratio === null ? null : ratioOf(ratio);
3347
+ }
3348
+ /** Circle crop in the image editor (avatars). */
3349
+ circleCropper(condition = true) { this._circleCropper = condition; return this; }
3350
+ /** Crops images to this ratio in the browser before upload (`'16:9'`), without opening the editor. */
3351
+ automaticallyCropImagesToAspectRatio(ratio) { this._cropAspectRatio = ratio; return this; }
3352
+ imageCropAspectRatio(ratio) { return this.automaticallyCropImagesToAspectRatio(ratio); }
3353
+ /** Resizes images in the browser before upload (`'cover'`, `'contain'` or `'force'`). */
3354
+ automaticallyResizeImagesMode(mode) { this._ui.automaticallyResizeImagesMode = mode; return this; }
3355
+ automaticallyResizeImagesToWidth(width) { this._ui.automaticallyResizeImagesWidth = width === null ? null : String(width); return this; }
3356
+ automaticallyResizeImagesToHeight(height) { this._ui.automaticallyResizeImagesHeight = height === null ? null : String(height); return this; }
3357
+ automaticallyUpscaleImagesWhenResizing(condition = true) { this._ui.shouldAutomaticallyUpscaleImagesWhenResizing = condition; return this; }
3358
+ imageResizeMode(mode) { return this.automaticallyResizeImagesMode(mode); }
3359
+ imageResizeTargetWidth(width) { return this.automaticallyResizeImagesToWidth(width); }
3360
+ imageResizeTargetHeight(height) { return this.automaticallyResizeImagesToHeight(height); }
3361
+ /** Accepted aspect ratio(s) of uploaded images (`'16:9'` or a list), checked on the server. */
3362
+ imageAspectRatio(ratio) { this._imageAspectRatios = ratio === null ? null : [ratio].flat(); return this; }
3363
+ /** Width, height and ratio bounds of uploaded images (`{ minWidth: 800, maxHeight: 1080 }`), checked on the server. */
3364
+ imageDimensions(dimensions) { this._dimensions = dimensions; return this; }
3365
+ /** Stores files under their original (sanitized) names. See the security notes in the docs. */
3366
+ preserveFilenames(condition = true) { this._preserveFilenames = condition; return this; }
3367
+ /** Name of the stored file, from the temporary file; sanitized, and its extension is the detected one. */
3368
+ getUploadedFileNameForStorageUsing(callback) { this._fileNameUsing = callback; return this; }
3369
+ /** Saves the original file names (keyed by stored file) in another attribute of the record. */
3370
+ storeFileNamesIn(field) { this._fileNamesField = field; return this; }
3371
+ /** Extra saved values, next to the field (the original file names of `storeFileNamesIn()`). */
3372
+ getExtraDehydratedState() {
3373
+ if (this._fileNamesField === null)
3374
+ return {};
3375
+ return { [this._fileNamesField]: this._multiple ? this._storedNames : (Object.values(this._storedNames)[0] ?? null) };
3376
+ }
1768
3377
  multiple(condition = true) { this._multiple = condition; return this; }
1769
3378
  maxFiles(count) { this._maxFiles = count; return this; }
1770
3379
  /** Maximum file size, in kilobytes. */
@@ -1799,6 +3408,23 @@ export class FileUpload extends Field {
1799
3408
  return new Set(Object.values(this.hydrateState(record?.[this.getName()])));
1800
3409
  }
1801
3410
  isTemporary(value) { return value.startsWith('livewire-file:'); }
3411
+ /** Original names saved by `storeFileNamesIn()`, by stored key. */
3412
+ savedFileNames() {
3413
+ if (this._fileNamesField === null)
3414
+ return {};
3415
+ const record = this.container.getRecord();
3416
+ let names = record?.[this._fileNamesField];
3417
+ if (typeof names === 'string') {
3418
+ try {
3419
+ names = JSON.parse(names);
3420
+ }
3421
+ catch {
3422
+ const key = [...this.originalKeys()][0];
3423
+ return key ? { [key]: names } : {};
3424
+ }
3425
+ }
3426
+ return names && typeof names === 'object' && !Array.isArray(names) ? Object.fromEntries(Object.entries(names).filter(([, name]) => typeof name === 'string')) : {};
3427
+ }
1802
3428
  getExposedMethods() { return ['deleteUploadedFile', 'removeUploadedFile', 'reorderUploadedFiles', 'getUploadedFiles']; }
1803
3429
  async getUploadedFiles() {
1804
3430
  const original = this.originalKeys();
@@ -1809,6 +3435,9 @@ export class FileUpload extends Field {
1809
3435
  continue;
1810
3436
  }
1811
3437
  const info = this._getUploadedFileUsing ? await this._getUploadedFileUsing(value, this.context()) : await this.getStorage()?.info(value);
3438
+ const savedName = this.savedFileNames()[value];
3439
+ if (info && savedName)
3440
+ info.name = savedName;
1812
3441
  // Only local or http(s) URLs are sent to the browser.
1813
3442
  files[fileKey] = info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info : null;
1814
3443
  }
@@ -1818,6 +3447,9 @@ export class FileUpload extends Field {
1818
3447
  /** Removes a file from the field. A stored file is only deleted by the application when the form is saved. */
1819
3448
  removeUploadedFile(fileKey) {
1820
3449
  const removed = this.entries().find(([key]) => key === fileKey);
3450
+ // `deletable(false)`: a saved file stays (a file just uploaded can still be removed).
3451
+ if (removed && !this._deletable && !this.isTemporary(removed[1]))
3452
+ return;
1821
3453
  if (removed && this.isTemporary(removed[1]))
1822
3454
  this.uploads?.remove(removed[1].slice('livewire-file:'.length));
1823
3455
  this.writeState(this.entries().filter(([key]) => key !== fileKey));
@@ -1846,6 +3478,23 @@ export class FileUpload extends Field {
1846
3478
  rules.push({ name: 'mimetypes', passes: () => temporary().every((file) => file !== null && accepts(file.type)), message: (a) => `The ${a} field must be a file of type: ${this._acceptedFileTypes.join(', ')}.` });
1847
3479
  if (this._maxSize !== null)
1848
3480
  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.` });
3481
+ if (this._minSize !== null)
3482
+ rules.push({ name: 'min', passes: () => temporary().every((file) => file !== null && file.size >= this._minSize * 1024), message: (a) => `The ${a} field must be at least ${this._minSize} kilobytes.` });
3483
+ // Image rules only apply to new uploads, as in Laravel; a file whose size cannot be read fails them.
3484
+ if (this._dimensions) {
3485
+ const d = this._dimensions;
3486
+ const fits = (size) => size !== null
3487
+ && (d.width === undefined || size.width === d.width) && (d.height === undefined || size.height === d.height)
3488
+ && (d.minWidth === undefined || size.width >= d.minWidth) && (d.maxWidth === undefined || size.width <= d.maxWidth)
3489
+ && (d.minHeight === undefined || size.height >= d.minHeight) && (d.maxHeight === undefined || size.height <= d.maxHeight)
3490
+ && (d.ratio === undefined || Math.abs(size.width / size.height - d.ratio) < 0.01)
3491
+ && (d.minRatio === undefined || size.width / size.height >= d.minRatio - 1e-9) && (d.maxRatio === undefined || size.width / size.height <= d.maxRatio + 1e-9);
3492
+ rules.push({ name: 'dimensions', passes: () => temporary().every((file) => file !== null && fits(readImageSize(file.path))), message: (a) => `The ${a} field has invalid image dimensions.` });
3493
+ }
3494
+ if (this._imageAspectRatios) {
3495
+ const ratios = this._imageAspectRatios.map(ratioOf).filter((ratio) => ratio !== null);
3496
+ rules.push({ name: 'dimensions', passes: () => temporary().every((file) => { const size = file && readImageSize(file.path); return !!size && size.height > 0 && ratios.some((ratio) => Math.abs(size.width / size.height - ratio) < 0.01); }), message: (a) => `The ${a} field has invalid image dimensions.` });
3497
+ }
1849
3498
  return rules;
1850
3499
  }
1851
3500
  /** Validates the whole state, even when empty (a required field without files must fail). */
@@ -1861,6 +3510,8 @@ export class FileUpload extends Field {
1861
3510
  /** On save: temporary files are stored, unrecognized existing keys are dropped. */
1862
3511
  async dehydrateState() {
1863
3512
  const original = this.originalKeys();
3513
+ const savedNames = this.savedFileNames();
3514
+ this._storedNames = {};
1864
3515
  const out = [];
1865
3516
  for (const [, value] of this.entries()) {
1866
3517
  if (this.isTemporary(value)) {
@@ -1874,74 +3525,115 @@ export class FileUpload extends Field {
1874
3525
  const storage = this.getStorage();
1875
3526
  if (!storage)
1876
3527
  throw new Error('FileUpload: no storage configured (FileUpload.storage).');
1877
- out.push(await storage.store(file, this._directory));
3528
+ const name = this._fileNameUsing ? await this._fileNameUsing(file, this.context()) : this._preserveFilenames ? file.name : null;
3529
+ const stored = await storage.store(file, this._directory, name);
3530
+ this._storedNames[stored] = file.name;
3531
+ out.push(stored);
1878
3532
  this.uploads?.remove(file.id);
1879
3533
  }
1880
- else if (original.has(value))
3534
+ else if (original.has(value)) {
1881
3535
  out.push(this._storeFiles ? value : { key: value });
3536
+ if (savedNames[value])
3537
+ this._storedNames[value] = savedNames[value];
3538
+ }
1882
3539
  }
3540
+ // `deletable(false)`: a saved file the browser dropped from the state is kept.
3541
+ if (!this._deletable)
3542
+ for (const key of original)
3543
+ if (!out.some((item) => item === key || (typeof item === 'object' && 'key' in item && item.key === key))) {
3544
+ out.push(this._storeFiles ? key : { key });
3545
+ if (savedNames[key])
3546
+ this._storedNames[key] = savedNames[key];
3547
+ }
1883
3548
  return this._multiple ? out : (out[0] ?? null);
1884
3549
  }
3550
+ /** Filament's image editor dialog (Cropper.js), rendered once per field. */
3551
+ imageEditorHtml() {
3552
+ const tr = (key, replace = {}) => t(`filament-forms::components.file_upload.editor.${key}`, replace);
3553
+ const explicit = this._imageEditor;
3554
+ const fields = [
3555
+ ['x_position', 'xPositionInput', 'editor.setData({...editor.getData(true), x: +$el.value})'], ['y_position', 'yPositionInput', 'editor.setData({...editor.getData(true), y: +$el.value})'],
3556
+ ['width', 'widthInput', 'editor.setData({...editor.getData(true), width: +$el.value})'], ['height', 'heightInput', 'editor.setData({...editor.getData(true), height: +$el.value})'],
3557
+ ['rotation', 'rotationInput', 'editor.rotateTo(+$el.value)'],
3558
+ ].map(([name, ref, save]) => `<label><div class="fi-input-wrp"><div class="fi-input-wrp-prefix fi-input-wrp-prefix-has-content fi-input-wrp-prefix-has-label"><span class="fi-input-wrp-label">${e(tr(`fields.${name}.label`))}</span></div><div class="fi-input-wrp-content-ctn"><input x-on:keyup.enter.prevent.stop="editor &amp;&amp; ${e(save)}" x-on:blur="editor &amp;&amp; ${e(save)}" x-ref="${ref}" x-on:keydown.enter.prevent type="text" class="fi-input" /></div><div class="fi-input-wrp-suffix fi-input-wrp-suffix-has-label"><span class="fi-input-wrp-label">${e(tr(`fields.${name}.unit`))}</span></div></div></label>`).join('');
3559
+ const groups = [
3560
+ [['drag_move', 'fi-o-arrows-move', "editor.setDragMode('move')"], ['drag_crop', 'fi-o-crop', "editor.setDragMode('crop')"], ['zoom_in', 'o-magnifying-glass-plus', 'editor.zoom(0.1)'], ['zoom_out', 'o-magnifying-glass-minus', 'editor.zoom(-0.1)'], ['zoom_100', 'o-arrows-pointing-out', 'editor.zoomTo(1)']],
3561
+ [['move_left', 'o-arrow-left-circle', 'editor.move(-10, 0)'], ['move_right', 'o-arrow-right-circle', 'editor.move(10, 0)'], ['move_up', 'o-arrow-up-circle', 'editor.move(0, -10)'], ['move_down', 'o-arrow-down-circle', 'editor.move(0, 10)']],
3562
+ [['rotate_left', 'o-arrow-uturn-left', 'editor.rotate(-90)'], ['rotate_right', 'o-arrow-uturn-right', 'editor.rotate(90)'], ['flip_horizontal', 'fi-o-flip-horizontal', 'editor.scaleX(-editor.getData().scaleX || -1)'], ['flip_vertical', 'fi-o-flip-vertical', 'editor.scaleY(-editor.getData().scaleY || -1)']],
3563
+ ].map((group) => `<div class="fi-btn-group">${group.map(([name, iconName, click]) => { const label = tr(`actions.${name}.label`); return `<button aria-label="${e(label)}" type="button" x-on:click.prevent.stop="${e(click)}" x-tooltip="${e(`{ content: ${js(label)}, theme: $store.theme }`)}" class="fi-btn">${icon(iconName)}</button>`; }).join('')}</div>`).join('');
3564
+ const ratioOptions = [...this._editorAspectRatios, ...(this._cropAspectRatio !== null ? [this._cropAspectRatio] : [])].filter((ratio, index, all) => all.indexOf(ratio) === index)
3565
+ .map((ratio) => [ratio === null ? tr('aspect_ratios.no_fixed.label') : ratio.replace('/', ':'), ratio === null ? 'NaN' : (ratioOf(ratio) ?? 'NaN')]);
3566
+ const ratios = ratioOptions.length >= 2 ? `<div class="fi-fo-file-upload-editor-control-panel-group"><div class="fi-fo-file-upload-editor-control-panel-group-title">${e(tr('aspect_ratios.label'))}</div>${Array.from({ length: Math.ceil(ratioOptions.length / 5) }, (_, chunk) => `<div class="fi-btn-group">${ratioOptions.slice(chunk * 5, chunk * 5 + 5).map(([label, ratio]) => `<button type="button" x-on:click.prevent.stop="${e(`currentRatio = ${js(label)}; editor.setAspectRatio(${ratio === 'NaN' ? 'NaN' : ratio})`)}" x-tooltip="${e(`{ content: ${js(tr('actions.set_aspect_ratio.label', { ratio: label }))}, theme: $store.theme }`)}" x-bind:class="${e(`{ 'fi-active': currentRatio === ${js(label)} }`)}" class="fi-btn">${e(label)}</button>`).join('')}</div>`).join('')}</div>` : '';
3567
+ const success = cls(['fi-btn', ...(colorManager?.getComponentClasses('button', 'success') ?? [])]);
3568
+ const cancel = `<button type="button" x-on:click.prevent="pond.imageEditEditor.oncancel" class="fi-btn">${e(tr('actions.cancel.label'))}</button>`;
3569
+ const save = `<button type="button" x-on:click.prevent="saveEditor" class="${success}">${e(tr('actions.save.label'))}</button>`;
3570
+ const main = explicit ? `<div class="fi-fo-file-upload-editor-control-panel-main"><div class="fi-fo-file-upload-editor-control-panel-group">${fields}</div><div class="fi-fo-file-upload-editor-control-panel-group">${groups}</div>${ratios}<div class="fi-fo-file-upload-editor-control-panel-reset-action-ctn"><button type="button" x-on:click.prevent.stop="editor.reset()" class="${cls(['fi-link', 'fi-size-sm', 'fi-fo-file-upload-editor-control-panel-reset-action', ...(colorManager?.getComponentClasses('link', 'danger') ?? [])])}"><span class="fi-link-label">${e(tr('actions.reset.label'))}</span></button></div></div>` : '';
3571
+ return `<div aria-label="${e(tr('label'))}" aria-modal="true" role="dialog" x-show="isEditorOpen" x-cloak x-on:click.stop="" x-trap.noscroll="isEditorOpen" x-on:keydown.escape.prevent.stop="closeEditor" class="${cls(['fi-fo-file-upload-editor', { 'fi-fo-file-upload-editor-circle-cropper': this._circleCropper, 'fi-fo-file-upload-editor-crop-only': !explicit }])}"><div aria-hidden="true" class="fi-fo-file-upload-editor-overlay"></div><div class="fi-fo-file-upload-editor-window"><div class="fi-fo-file-upload-editor-image-ctn"><img alt="" x-ref="editor" class="fi-fo-file-upload-editor-image" /></div><div class="fi-fo-file-upload-editor-control-panel">${main}<div class="fi-fo-file-upload-editor-control-panel-footer">${explicit ? cancel + save : save + cancel}</div></div></div></div>`;
3572
+ }
1885
3573
  async toHtml() {
1886
3574
  const isDisabled = await this.isDisabled();
1887
3575
  const id = this.getId();
1888
3576
  const label = (await this.getLabel()).toLowerCase();
1889
3577
  const tr = (key) => t(`filament-forms::components.file_upload.${key}`);
3578
+ const ui = (key, fallback) => (this._ui[key] === undefined ? fallback : this._ui[key]);
3579
+ const hasEditor = (this._imageEditor || this._cropAspectRatio !== null || this._circleCropper) && !isDisabled;
1890
3580
  const config = `fileUploadFormComponent({
1891
3581
  acceptedFileTypes: ${this._acceptedFileTypes ? js(this._acceptedFileTypes) : 'null'},
1892
- automaticallyCropImagesAspectRatio: null,
1893
- automaticallyOpenImageEditorForAspectRatio: null,
1894
- automaticallyResizeImagesMode: null,
1895
- automaticallyResizeImagesHeight: null,
1896
- automaticallyResizeImagesWidth: null,
3582
+ automaticallyCropImagesAspectRatio: ${js(this._cropAspectRatio)},
3583
+ automaticallyOpenImageEditorForAspectRatio: ${js(this.autoOpenEditorRatio())},
3584
+ automaticallyResizeImagesMode: ${js(ui('automaticallyResizeImagesMode', null))},
3585
+ automaticallyResizeImagesHeight: ${js(ui('automaticallyResizeImagesHeight', null))},
3586
+ automaticallyResizeImagesWidth: ${js(ui('automaticallyResizeImagesWidth', null))},
1897
3587
  canEditSvgs: true,
1898
3588
  confirmSvgEditingMessage: ${js(tr('editor.svg.messages.confirmation'))},
1899
3589
  disabledSvgEditingMessage: ${js(tr('editor.svg.messages.disabled'))},
1900
- hasCircleCropper: false,
1901
- hasImageEditor: false,
1902
- imageEditorEmptyFillColor: null,
1903
- imageEditorMode: 1,
1904
- imageEditorViewportHeight: null,
1905
- imageEditorViewportWidth: null,
1906
- imagePreviewHeight: null,
1907
- isAvatar: false,
1908
- isDeletable: true,
3590
+ hasCircleCropper: ${js(this._circleCropper)},
3591
+ hasImageEditor: ${js(hasEditor)},
3592
+ imageEditorEmptyFillColor: ${js(ui('imageEditorEmptyFillColor', null))},
3593
+ imageEditorMode: ${js(ui('imageEditorMode', 1))},
3594
+ imageEditorViewportHeight: ${js(ui('imageEditorViewportHeight', null))},
3595
+ imageEditorViewportWidth: ${js(ui('imageEditorViewportWidth', null))},
3596
+ imagePreviewHeight: ${js(ui('imagePreviewHeight', null))},
3597
+ isAvatar: ${js(this._avatar)},
3598
+ isDeletable: ${js(this._deletable)},
1909
3599
  isDisabled: ${isDisabled ? 'true' : 'false'},
1910
- isDownloadable: false,
1911
- isImageEditorExplicitlyEnabled: false,
3600
+ isDownloadable: ${js(ui('isDownloadable', false))},
3601
+ isImageEditorExplicitlyEnabled: ${js(this._imageEditor)},
1912
3602
  isMultiple: ${this._multiple ? 'true' : 'false'},
1913
- isOpenable: false,
1914
- isPasteable: true,
1915
- isPreviewable: true,
3603
+ isOpenable: ${js(ui('isOpenable', false))},
3604
+ isPasteable: ${js(ui('isPasteable', true))},
3605
+ isPreviewable: ${js(ui('isPreviewable', true))},
1916
3606
  isReorderable: ${this._reorderable ? 'true' : 'false'},
1917
3607
  isSvgEditingConfirmed: false,
1918
- itemPanelAspectRatio: null,
1919
- loadingIndicatorPosition: 'right',
3608
+ itemPanelAspectRatio: ${js(ui('itemPanelAspectRatio', null))},
3609
+ loadingIndicatorPosition: ${js(ui('loadingIndicatorPosition', 'right'))},
1920
3610
  locale: 'en',
1921
3611
  maxFiles: ${this._maxFiles ?? 'null'},
1922
3612
  maxFilesValidationMessage: ${this._maxFiles !== null ? js(`The ${label} field must not have more than ${this._maxFiles} items.`) : 'null'},
1923
- maxParallelUploads: null,
3613
+ maxParallelUploads: ${js(ui('maxParallelUploads', null))},
1924
3614
  maxSize: ${this._maxSize !== null ? js(`${this._maxSize}KB`) : 'null'},
1925
- mimeTypeMap: [],
1926
- minSize: null,
1927
- panelAspectRatio: null,
1928
- panelLayout: 'compact',
1929
- placeholder: null,
1930
- removeUploadedFileButtonPosition: 'left',
1931
- shouldAppendFiles: false,
1932
- shouldAutomaticallyUpscaleImagesWhenResizing: true,
1933
- shouldOrientImageFromExif: true,
1934
- shouldTransformImage: false,
3615
+ mimeTypeMap: ${ui('mimeTypeMap', null) ? js(ui('mimeTypeMap', null)) : '[]'},
3616
+ minSize: ${this._minSize !== null ? js(`${this._minSize}KB`) : 'null'},
3617
+ panelAspectRatio: ${js(ui('panelAspectRatio', null))},
3618
+ panelLayout: ${js(ui('panelLayout', 'compact'))},
3619
+ placeholder: ${js(this._placeholder)},
3620
+ removeUploadedFileButtonPosition: ${js(ui('removeUploadedFileButtonPosition', 'left'))},
3621
+ shouldAppendFiles: ${js(ui('shouldAppendFiles', false))},
3622
+ shouldAutomaticallyUpscaleImagesWhenResizing: ${js(ui('shouldAutomaticallyUpscaleImagesWhenResizing', true))},
3623
+ shouldOrientImageFromExif: ${js(ui('shouldOrientImageFromExif', true))},
3624
+ shouldTransformImage: ${js(Boolean(this._cropAspectRatio || ui('automaticallyResizeImagesHeight', null) || ui('automaticallyResizeImagesWidth', null)))},
1935
3625
  state: $wire.$entangle(${js(this.getStatePath())}, false),
1936
3626
  statePath: ${js(this.getStatePath())},
1937
- uploadButtonPosition: 'right',
1938
- uploadingMessage: ${js(t('filament::components/button.messages.uploading_file'))},
3627
+ uploadButtonPosition: ${js(ui('uploadButtonPosition', 'right'))},
3628
+ uploadingMessage: ${js(ui('uploadingMessage', t('filament::components/button.messages.uploading_file')))},
1939
3629
  downloadActionLabel: ${js(tr('actions.download.label'))},
1940
3630
  openActionLabel: ${js(tr('actions.open.label'))},
1941
- uploadProgressIndicatorPosition: 'right',
3631
+ uploadProgressIndicatorPosition: ${js(ui('uploadProgressIndicatorPosition', 'right'))},
1942
3632
  })`;
1943
- const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([this._acceptedFileTypes, this._maxFiles, this._multiple, isDisabled])).digest('hex')}`;
3633
+ const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([this._acceptedFileTypes, this._maxFiles, this._multiple, isDisabled, this._ui, this._imageEditor, this._cropAspectRatio])).digest('hex')}`;
1944
3634
  const input = new Attributes().merge({ 'x-ref': 'input', 'aria-labelledby': `${id}-label`, disabled: isDisabled, multiple: this._multiple ? 'multiple' : null, type: 'file' });
1945
- return this.wrap(`<div hidden data-field-accessibility data-preserve-filepond-description></div><div x-load x-load-src="${e(asset('file-upload'))}" x-data="${e(config)}" wire:ignore wire:key="${e(key)}" aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" class="fi-fo-file-upload fi-align-start"><div class="fi-fo-file-upload-input-ctn"><input ${input.toHtml()} /></div><div x-show="error" x-text="error" x-cloak role="alert" class="fi-fo-file-upload-error-message"></div></div>`, { labelAsDiv: true });
3635
+ return this.wrap(`<div hidden data-field-accessibility data-preserve-filepond-description></div><div x-load x-load-src="${e(asset('file-upload'))}" x-data="${e(config)}" wire:ignore wire:key="${e(key)}" aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" class="${cls(['fi-fo-file-upload', { 'fi-fo-file-upload-avatar': this._avatar }, `fi-align-${this._alignment ?? 'start'}`])}"><div class="fi-fo-file-upload-input-ctn"><input ${input.toHtml()} /></div><div x-show="error" x-text="error" x-cloak role="alert" class="fi-fo-file-upload-error-message"></div>${hasEditor ? this.imageEditorHtml() : ''}</div>`, { labelAsDiv: true });
1946
3636
  }
1947
3637
  }
3638
+ // Modal wizards of actions (`Action.steps()`): the wizard is not contained, the last step submits the action.
3639
+ Action.stepsRenderer = (steps, submit, cancel) => Wizard.make(steps).contained(false).submitAction(submit).cancelAction(cancel);