@navette-js/forms 1.0.0-beta.2 → 1.0.0-beta.4

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,5 +1,5 @@
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
5
  import { Component, Icon as SchemaIcon, Schema, Text, childSchema, renderInlineItems, slug } from '@navette-js/schemas';
@@ -16,7 +16,7 @@ import Image from '@tiptap/extension-image';
16
16
  import { TableKit } from '@tiptap/extension-table';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import nodePath from 'node:path';
19
- import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
19
+ import { constants as fsConstants, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
20
20
  views.namespace('filament-forms', fileURLToPath(new URL('../views', import.meta.url)));
21
21
  let colorManager = null;
22
22
  export const useColorManager = (manager) => { colorManager = manager; };
@@ -28,7 +28,7 @@ const phpNumber = (n) => {
28
28
  const [mantissa, exponent] = n.toPrecision(14).split('e');
29
29
  return `${mantissa.replace(/\.?0+$/, '')}E${exponent}`;
30
30
  };
31
- const blank = (v) => v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
31
+ import { isBlank as blank, stringRule, parseRegex } from "./rules.js";
32
32
  const headline = (s) => s.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()).replace(/ (.)/g, (_, c) => ' ' + c.toLowerCase());
33
33
  export class Field extends Component {
34
34
  _label = null;
@@ -41,7 +41,16 @@ export class Field extends Component {
41
41
  _dehydrated = null;
42
42
  _hiddenLabel = false;
43
43
  _helperText = null;
44
+ _liveMode = null;
45
+ _validationAttribute = null;
46
+ _validationMessages = {};
47
+ _markedAsRequired = null;
44
48
  constructor(name) { super(); this._name = name; }
49
+ /** Sends the state to the server as soon as it changes (`onBlur`: when the field loses focus). `false` turns it off. */
50
+ live(condition = true) { this._liveMode = condition === false ? null : typeof condition === 'object' && condition.onBlur ? 'blur' : 'live'; return this; }
51
+ isLive() { return this._liveMode !== null; }
52
+ /** `wire:model` directive of the field, with its live modifiers. */
53
+ wireModel() { return `wire:model${this._liveMode ? '.live' : ''}${this._liveMode === 'blur' ? '.blur' : ''}`; }
45
54
  hasStatePath() { return true; }
46
55
  getName() { return this._name; }
47
56
  getId() { return this.getKey(); }
@@ -110,7 +119,125 @@ export class Field extends Component {
110
119
  getDefaultState() { return typeof this._default === 'function' ? this._default() : this._default; }
111
120
  /** Shapes a record value as it enters the form (hydration). */
112
121
  hydrateState(value) { return value; }
113
- rule(rule) { this._rules.push(rule); return this; }
122
+ /** Adds a rule: an object `{ name, passes, message }`, or a Laravel-style string (`'max:255'`, `'regex:/^[a-z]+$/'`). */
123
+ rule(rule, condition = true) {
124
+ if (typeof rule === 'string') {
125
+ const spec = rule;
126
+ // Built now, so that an unknown or malformed rule fails when the form is declared.
127
+ const probe = stringRule(spec, { numeric: false, name: this._name ?? '' });
128
+ if (!probe)
129
+ return this;
130
+ const build = () => stringRule(spec, { numeric: this.hasNumericValidation(), name: this._name ?? '' });
131
+ rule = { name: probe.name, implicit: probe.implicit, passes: (value, ctx) => build().passes(value, ctx), message: (attribute) => build().message(attribute) };
132
+ }
133
+ if (condition === false)
134
+ return this;
135
+ if (condition === true) {
136
+ this._rules.push(rule);
137
+ return this;
138
+ }
139
+ const inner = rule;
140
+ this._rules.push({ ...inner, passes: async (value, ctx) => !(await condition(ctx)) || inner.passes(value, ctx) });
141
+ return this;
142
+ }
143
+ /** Several rules at once: `rules(['required', 'max:255'])` or `rules('required|max:255')`. */
144
+ rules(rules, condition = true) {
145
+ for (const rule of typeof rules === 'string' ? rules.split('|').filter((item) => item.trim() !== '') : rules)
146
+ this.rule(rule, condition);
147
+ return this;
148
+ }
149
+ /** Value checked by the rules (a subclass may trim it, for instance). */
150
+ stateForValidation(state) { return state; }
151
+ /** Sizes (`min`, `max`, `size`, `between`) compare the number, not the text length. */
152
+ hasNumericValidation() { return false; }
153
+ /** Name of the field in validation messages (the lowercased label by default). */
154
+ validationAttribute(name) { this._validationAttribute = name; return this; }
155
+ /** Custom messages by rule name (`{ required: 'Tell us your name.', max: 'Too long.' }`); `:attribute` is replaced by the field name. */
156
+ validationMessages(messages) { this._validationMessages = { ...this._validationMessages, ...messages }; return this; }
157
+ _loadStateFromRelationships = null;
158
+ _saveRelationships = null;
159
+ /** Fills the field from the record's relationships (after the record's own values): `(record, ctx) => state`. */
160
+ loadStateFromRelationshipsUsing(callback) { this._loadStateFromRelationships = callback; return this; }
161
+ /** Saves the field into the record's relationships, once the record is created or updated. Such a field is not in the saved data. */
162
+ saveRelationshipsUsing(callback) { this._saveRelationships = callback; return this; }
163
+ /** Whether the field saves itself through `saveRelationshipsUsing()` (and is then left out of the saved data). */
164
+ async savesRelationships() { return this._saveRelationships !== null; }
165
+ async loadStateFromRelationships(record) { return this._loadStateFromRelationships ? { state: await this._loadStateFromRelationships(record, this.context()) } : null; }
166
+ async saveRelationships(record) { if (this._saveRelationships)
167
+ await this._saveRelationships({ record, state: await this.dehydrateState(this.context().state), ctx: this.context() }); }
168
+ /** Shows (or hides, with `false`) the required asterisk, regardless of `required()`. */
169
+ markAsRequired(condition = true) { this._markedAsRequired = condition; return this; }
170
+ async isMarkedAsRequired() { return this._markedAsRequired ?? (await this.isRequired()); }
171
+ stringRuleWith(name, parameters = []) { return this.rule(parameters.length ? `${name}:${parameters.map(String).join(',')}` : name); }
172
+ /** The value must match the pattern (a RegExp, or a `/pattern/flags` string). */
173
+ 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.` }); }
174
+ 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.` }); }
175
+ alpha() { return this.stringRuleWith('alpha'); }
176
+ alphaDash() { return this.stringRuleWith('alpha_dash'); }
177
+ alphaNum() { return this.stringRuleWith('alpha_num'); }
178
+ ascii() { return this.stringRuleWith('ascii'); }
179
+ uuid() { return this.stringRuleWith('uuid'); }
180
+ ulid() { return this.stringRuleWith('ulid'); }
181
+ json() { return this.stringRuleWith('json'); }
182
+ ip() { return this.stringRuleWith('ip'); }
183
+ ipv4() { return this.stringRuleWith('ipv4'); }
184
+ ipv6() { return this.stringRuleWith('ipv6'); }
185
+ macAddress() { return this.stringRuleWith('mac_address'); }
186
+ hexColor() { return this.stringRuleWith('hex_color'); }
187
+ multipleOf(value) { return this.stringRuleWith('multiple_of', [value]); }
188
+ /** The value must be one of these (a list, or the values of an enum-like object). */
189
+ 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.` }); }
190
+ 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.` }); }
191
+ /** `in()` with the values of an enum-like object. */
192
+ enum(values) { return this.in(values); }
193
+ startsWith(prefixes) { return this.stringRuleWith('starts_with', [prefixes].flat()); }
194
+ endsWith(suffixes) { return this.stringRuleWith('ends_with', [suffixes].flat()); }
195
+ doesntStartWith(prefixes) { return this.stringRuleWith('doesnt_start_with', [prefixes].flat()); }
196
+ doesntEndWith(suffixes) { return this.stringRuleWith('doesnt_end_with', [suffixes].flat()); }
197
+ /** The field must equal `<name>_confirmation` (or the given field) of the same form. */
198
+ confirmed(field = null) { return this.rule(field ? `confirmed:${field}` : 'confirmed'); }
199
+ same(field) { return this.stringRuleWith('same', [field]); }
200
+ different(field) { return this.stringRuleWith('different', [field]); }
201
+ /** Compares with a number or another field of the same form. */
202
+ gt(fieldOrValue) { return this.stringRuleWith('gt', [fieldOrValue]); }
203
+ gte(fieldOrValue) { return this.stringRuleWith('gte', [fieldOrValue]); }
204
+ lt(fieldOrValue) { return this.stringRuleWith('lt', [fieldOrValue]); }
205
+ lte(fieldOrValue) { return this.stringRuleWith('lte', [fieldOrValue]); }
206
+ /** No duplicates in a list value (tags, multiple select, checkbox list). */
207
+ distinct() { return this.stringRuleWith('distinct'); }
208
+ /** Required when another field of the form has one of these values (`requiredIf('type', 'company')`). */
209
+ requiredIf(field, values) { return this.stringRuleWith('required_if', [field, ...[values].flat()]); }
210
+ requiredUnless(field, values) { return this.stringRuleWith('required_unless', [field, ...[values].flat()]); }
211
+ requiredIfAccepted(field) { return this.stringRuleWith('required_if_accepted', [field]); }
212
+ requiredWith(fields) { return this.stringRuleWith('required_with', [fields].flat()); }
213
+ requiredWithAll(fields) { return this.stringRuleWith('required_with_all', [fields].flat()); }
214
+ requiredWithout(fields) { return this.stringRuleWith('required_without', [fields].flat()); }
215
+ requiredWithoutAll(fields) { return this.stringRuleWith('required_without_all', [fields].flat()); }
216
+ prohibited(condition = true) { return condition ? this.stringRuleWith('prohibited') : this; }
217
+ prohibitedIf(field, values) { return this.stringRuleWith('prohibited_if', [field, ...[values].flat()]); }
218
+ prohibitedUnless(field, values) { return this.stringRuleWith('prohibited_unless', [field, ...[values].flat()]); }
219
+ prohibits(fields) { return this.stringRuleWith('prohibits', [fields].flat()); }
220
+ /**
221
+ * The value must exist: in a column of the host's data adapter (`exists()`, `exists({ column })`), in another adapter
222
+ * (`exists({ adapter: new ObjectionAdapter(Brand), column: 'id' })`), or according to a function.
223
+ */
224
+ exists(check = {}) {
225
+ return this.rule({
226
+ name: 'exists',
227
+ passes: async (value, ctx) => {
228
+ if (typeof check === 'function')
229
+ return check(value, ctx);
230
+ const adapter = check.adapter ?? ctx.livewire.getDataAdapter?.();
231
+ if (!adapter)
232
+ throw new Error('exists(): no data adapter (pass one with { adapter }).');
233
+ for (const item of Array.isArray(value) ? value : [value])
234
+ if (!(await adapter.exists(check.column ?? this._name, item, null)))
235
+ return false;
236
+ return true;
237
+ },
238
+ message: (a) => `The selected ${a} is invalid.`,
239
+ });
240
+ }
114
241
  /** `unique` rule: the value must not exist in the column of the same name; `ignoreRecord` excludes the record being edited. */
115
242
  unique(options = {}) {
116
243
  return this.rule({
@@ -144,13 +271,16 @@ export class Field extends Component {
144
271
  if ((await this.isDisabled()) && this._dehydrated !== true)
145
272
  return [];
146
273
  // An empty string is treated as missing.
147
- const value = this.context().state === '' ? null : this.context().state;
148
- const attribute = (await this.getLabel()).toLowerCase();
274
+ const raw = this.stateForValidation(this.context().state);
275
+ const value = raw === '' ? null : raw;
276
+ const attribute = this._validationAttribute ?? (await this.getLabel()).toLowerCase();
149
277
  for (const rule of await this.getValidationRules()) {
150
- if (rule.name !== 'required' && blank(value))
278
+ if (rule.name !== 'required' && !rule.implicit && blank(value))
151
279
  continue;
152
- if (!(await rule.passes(value, this.context())))
153
- return [rule.message(attribute)];
280
+ if (!(await rule.passes(value, this.context()))) {
281
+ const custom = this._validationMessages[rule.name];
282
+ return [custom === undefined ? rule.message(attribute) : typeof custom === 'function' ? custom(attribute) : custom.replace(/:attribute/g, attribute)];
283
+ }
154
284
  }
155
285
  return [];
156
286
  }
@@ -170,7 +300,7 @@ export class Field extends Component {
170
300
  if (this._helperText !== null)
171
301
  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>`;
172
302
  return views.render('filament-forms::components.field-wrapper', {
173
- id: this.getId(), label: await this.getLabel(), required: (await this.isRequired()) && !(await this.isDisabled()),
303
+ id: this.getId(), label: await this.getLabel(), required: (await this.isMarkedAsRequired()) && !(await this.isDisabled()),
174
304
  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
305
  });
176
306
  }
@@ -189,6 +319,7 @@ export class TextInput extends Field {
189
319
  _prefix = null;
190
320
  _live = null;
191
321
  static make(name) { return configured(new TextInput(name)); }
322
+ hasNumericValidation() { return this._numeric !== null; }
192
323
  /** Decimal number: `type="number"`, `step="any"`. */
193
324
  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
325
  /** Integer: `type="number"`, `step="1"`. */
@@ -198,21 +329,60 @@ export class TextInput extends Field {
198
329
  step(step) { this._step = step; return this; }
199
330
  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
331
  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; }
332
+ /** Label shown before the input (e.g. `$`); `isInline`: inside the input instead of a separate box. */
333
+ prefix(label, isInline = false) { this._prefix = label; this._prefixInline = isInline; return this; }
203
334
  _suffix = null;
204
335
  /** Label shown after the input ("hours", "kg"…). */
205
- suffix(label) { this._suffix = label; return this; }
336
+ suffix(label, isInline = false) { this._suffix = label; this._suffixInline = isInline; return this; }
337
+ _prefixInline = false;
338
+ _suffixInline = false;
339
+ _prefixIcon = null;
340
+ _suffixIcon = null;
341
+ _prefixIconColor = null;
342
+ _suffixIconColor = null;
343
+ _prefixActions = [];
344
+ _suffixActions = [];
345
+ prefixIcon(icon, isInline = false) { this._prefixIcon = icon; this._prefixInline = isInline; return this; }
346
+ suffixIcon(icon, isInline = false) { this._suffixIcon = icon; this._suffixInline = isInline; return this; }
347
+ prefixIconColor(color) { this._prefixIconColor = color; return this; }
348
+ suffixIconColor(color) { this._suffixIconColor = color; return this; }
349
+ /**
350
+ * Icon button before the input, running an action. The action gets the field's `state`, and `set(name, value)` to change a
351
+ * field of the same form.
352
+ */
353
+ prefixAction(action, isInline = false) { this._prefixActions = [...this._prefixActions, action]; this._prefixInline = isInline; return this; }
354
+ prefixActions(actions) { this._prefixActions = actions; return this; }
355
+ suffixAction(action, isInline = false) { this._suffixActions = [...this._suffixActions, action]; this._suffixInline = isInline; return this; }
356
+ suffixActions(actions) { this._suffixActions = actions; return this; }
357
+ /** Affix actions, mounted through the schema component. */
358
+ getActions() { return [...this.getHintActions(), ...this._prefixActions, ...this._suffixActions]; }
359
+ /** Exact length: `minlength` and `maxlength`, and the matching rule. */
360
+ length(length) { this.minLength(length); this.maxLength(length); return this; }
361
+ _datalist = [];
362
+ /** Suggestions offered by the browser while typing (`<datalist>`). */
363
+ datalist(options) { this._datalist = options.map(String); return this; }
364
+ _readOnly = false;
365
+ /** The value is shown and sent, but cannot be changed (`readonly`). */
366
+ readOnly(condition = true) { this._readOnly = condition; return this; }
367
+ _copyable = null;
368
+ /** A button after the input copies its value (the browser only allows it on HTTPS or `localhost`). */
369
+ copyable(condition = true, copyMessage = null, copyMessageDuration = 2000) { this._copyable = condition ? { message: copyMessage, duration: copyMessageDuration } : null; return this; }
370
+ _trim = false;
371
+ /** Removes the spaces around the value before it is validated and saved. */
372
+ trim(condition = true) { this._trim = condition; return this; }
373
+ hydrateState(value) { return this._trim && typeof value === 'string' ? value.trim() : value; }
374
+ /** `trim()`: the submitted value is trimmed before it is validated and saved. */
375
+ stateForValidation(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
376
+ dehydrateState(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
377
+ _inputMode = null;
378
+ _autocapitalize = null;
379
+ /** Virtual keyboard: `none`, `text`, `decimal`, `numeric`, `tel`, `search`, `email` or `url`. */
380
+ inputMode(mode) { this._inputMode = mode; return this; }
381
+ /** `none`, `sentences`, `words` or `characters` (`true` / `false` for `on` / `off`). */
382
+ autocapitalize(mode = true) { this._autocapitalize = mode === true ? 'on' : mode === false ? 'off' : mode; return this; }
206
383
  /** Sends the state to the server on every keystroke, or on blur with `onBlur`. */
207
384
  live(options = {}) { this._live = options.onBlur ? 'blur' : 'live'; return this; }
208
385
  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
386
  password(condition = true) { this._isPassword = condition; return this; }
217
387
  revealable(condition = true) { this._isRevealable = condition; return this; }
218
388
  /** `true` → "on", `false` → "off", a string as is. */
@@ -223,7 +393,16 @@ export class TextInput extends Field {
223
393
  mask(pattern) { this._mask = pattern; return this; }
224
394
  /** Phone number: digits, spaces and common punctuation. */
225
395
  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.` }); }
396
+ email(condition = true) {
397
+ if (!condition) {
398
+ if (this._type === 'email')
399
+ this._type = null;
400
+ this._rules = this._rules.filter((rule) => rule.name !== 'email');
401
+ return this;
402
+ }
403
+ this._type = 'email';
404
+ return this.rule({ name: 'email', passes: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)), message: (a) => `The ${a} field must be a valid email address.` });
405
+ }
227
406
  /** Web address: `url` input type, a valid http(s) value is required. */
228
407
  url() { this._type = 'url'; return this.rule({ name: 'url', passes: (v) => { try {
229
408
  return ['http:', 'https:'].includes(new URL(String(v)).protocol);
@@ -236,6 +415,18 @@ export class TextInput extends Field {
236
415
  async toHtml() {
237
416
  const isDisabled = await this.isDisabled();
238
417
  const revealable = this._isPassword && this._isRevealable;
418
+ // Affixes: label, icon and actions (only visible actions, mounted through this component).
419
+ const ctx = this.context();
420
+ const key = this.getKey();
421
+ const visibleActions = async (actions) => { const html = []; for (const action of actions)
422
+ if (await action.isVisible(ctx))
423
+ html.push(await action.iconButton().size('sm').toHtml(ctx, { schemaComponent: key })); return html; };
424
+ const prefixActions = await visibleActions(this._prefixActions);
425
+ const copyLabel = t('filament-forms::components.text_input.actions.copy.label');
426
+ const copyMessage = this._copyable?.message ?? t('filament-forms::components.text_input.actions.copy.message');
427
+ 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>`] : [])];
428
+ const hasPrefix = this._prefix !== null || this._prefixIcon !== null || prefixActions.length > 0;
429
+ const hasSuffix = this._suffix !== null || this._suffixIcon !== null || suffixActions.length > 0;
239
430
  const type = revealable ? 'password' : this._mask !== null ? 'text' : (this._type ?? (this._isPassword ? 'password' : this._numeric ? 'number' : 'text'));
240
431
  const model = `wire:model${this._live ? '.live' : ''}${this._live === 'blur' ? '.blur' : ''}`;
241
432
  const input = new Attributes()
@@ -243,16 +434,19 @@ export class TextInput extends Field {
243
434
  'aria-invalid': this.hasError() ? 'true' : null,
244
435
  'aria-describedby': this.getHelperTextId(),
245
436
  autocomplete: this._autocomplete, autofocus: this._autofocus, disabled: isDisabled, id: this.getId(),
246
- inputmode: this._numeric === 'decimal' ? 'decimal' : this._numeric === 'integer' ? 'numeric' : null,
437
+ autocapitalize: this._autocapitalize,
438
+ inputmode: this._inputMode ?? (this._numeric === 'decimal' ? 'decimal' : this._numeric === 'integer' ? 'numeric' : null),
439
+ list: this._datalist.length && !isDisabled && !this._readOnly ? `${this.getId()}-list` : null,
440
+ readonly: this._readOnly,
247
441
  max: phpNumber(this._maxValue), min: phpNumber(this._minValue),
248
442
  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,
443
+ required: (await this.isRequired()) && !isDisabled && !this._readOnly, step: this._step ?? (this._numeric === 'decimal' ? 'any' : this._numeric === 'integer' ? 1 : null), type,
250
444
  // Modifiers (`.live`, `.blur`) also apply to `wire:model.fill`.
251
445
  [revealable ? model.replace('wire:model', 'wire:model.fill') : model]: this.getStatePath(),
252
446
  'x-bind:type': revealable ? "isPasswordRevealed ? 'text' : 'password'" : null,
253
447
  'x-mask': this._mask,
254
448
  })
255
- .class(['fi-input', { 'fi-revealable': revealable }]);
449
+ .class(['fi-input', { 'fi-input-has-inline-prefix': this._prefixInline && hasPrefix, 'fi-input-has-inline-suffix': this._suffixInline && hasSuffix, 'fi-revealable': revealable }]);
256
450
  const wrapper = new Attributes()
257
451
  .merge({
258
452
  'x-data': revealable ? 'passwordRevealFormComponent()' : this._mask !== null ? '{}' : null,
@@ -263,49 +457,58 @@ export class TextInput extends Field {
263
457
  .class(['fi-fo-text-input', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
264
458
  return this.wrap(views.render('filament-forms::components.text-input', {
265
459
  input: input.toHtml(), wrapper: wrapper.toHtml(), revealable, prefix: this._prefix, suffix: this._suffix,
460
+ prefixIcon: this._prefixIcon ? icon(this._prefixIcon, { attributes: new Attributes().class(this._prefixIconColor && this._prefixIconColor !== 'gray' ? ['fi-color', `fi-color-${this._prefixIconColor}`] : []) }) : '',
461
+ suffixIcon: this._suffixIcon ? icon(this._suffixIcon, { attributes: new Attributes().class(this._suffixIconColor && this._suffixIconColor !== 'gray' ? ['fi-color', `fi-color-${this._suffixIconColor}`] : []) }) : '',
462
+ prefixActions, suffixActions, hasPrefix, hasSuffix, prefixInline: this._prefixInline, suffixInline: this._suffixInline,
463
+ datalist: this._datalist.length && !isDisabled && !this._readOnly ? { id: `${this.getId()}-list`, options: this._datalist } : null,
266
464
  showLabel: t('filament-forms::components.text_input.actions.show_password.label'),
267
465
  hideLabel: t('filament-forms::components.text_input.actions.hide_password.label'),
268
466
  }));
269
467
  }
270
468
  }
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
- }
469
+ const flattenOptions = (groups) => groups.flatMap((item) => (Array.isArray(item) ? [item] : item.options));
470
+ function normalizeOptions(options) {
471
+ const entries = Array.isArray(options) ? options : Object.entries(options);
472
+ return entries.map(([key, value]) => {
473
+ if (value !== null && typeof value === 'object')
474
+ return { label: String(key), options: (Array.isArray(value) ? value : Object.entries(value)).map(([v, l]) => [String(v), String(l)]) };
475
+ return [String(key), String(value)];
476
+ });
477
+ }
478
+ /** Objection model of the form (the host's data adapter), for relationships. */
479
+ function modelOfForm(ctx, explicit) {
480
+ if (explicit)
481
+ return explicit;
482
+ const host = ctx.livewire;
483
+ const adapter = host.getDataAdapter?.() ?? host.getDataAdapterForTable?.() ?? host.resource?.adapter ?? null;
484
+ const model = adapter?.model;
485
+ if (typeof model !== 'function' || typeof model.getRelation !== 'function')
486
+ throw new Error('relationship(): the form has no Objection model; pass it with relationship(name, title, modify, { model }).');
487
+ return model;
488
+ }
489
+ /**
490
+ * Copy of a component and of the components it holds (layout children, item schemas), so that each repeated item gets its own
491
+ * instances: components keep a reference to their container.
492
+ */
493
+ export function cloneComponentTree(component) {
494
+ const copy = Object.assign(Object.create(Object.getPrototypeOf(component)), component);
495
+ for (const [property, value] of Object.entries(copy)) {
496
+ if (value instanceof Component)
497
+ copy[property] = cloneComponentTree(value);
498
+ else if (Array.isArray(value) && value.length && value.every((item) => item instanceof Component))
499
+ copy[property] = value.map((item) => cloneComponentTree(item));
500
+ }
501
+ return copy;
502
+ }
503
+ /** Prefix or suffix of an input wrapper: actions, icon and label (escaped). */
504
+ function inputAffixHtml(side, options) {
505
+ if (options.label === null && !options.icon && !options.actions.length)
506
+ return '';
507
+ const label = options.label !== null ? `<span class="fi-input-wrp-label">${e(options.label)}</span>` : '';
508
+ const actions = options.actions.length ? `<div class="fi-input-wrp-actions">${options.actions.join('')}</div>` : '';
509
+ const focus = options.label !== null || options.icon ? ` x-on:click="if (! $event.target.closest('.fi-input-wrp-actions')) $dispatch('focus-input')"` : '';
510
+ 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 }]);
511
+ return `<div${focus} class="${classes}">${side === 'prefix' ? actions + options.icon + label : label + options.icon + actions}</div>`;
309
512
  }
310
513
  /**
311
514
  * Dropdown. Native (`<select>`) by default; `searchable()` switches to the JavaScript select,
@@ -315,6 +518,15 @@ export class Select extends Field {
315
518
  _options = [];
316
519
  _placeholder = null;
317
520
  _searchable = false;
521
+ _native = true;
522
+ _allowHtml = false;
523
+ _maxItems = null;
524
+ _minItems = null;
525
+ _optionsLimit = 50;
526
+ _searchDebounce = 1000;
527
+ _messages = {};
528
+ _relationship = null;
529
+ _affixes = { prefix: null, suffix: null, prefixIcon: null, suffixIcon: null, prefixIconColor: null, suffixIconColor: null, prefixActions: [], suffixActions: [], prefixInline: false, suffixInline: false };
318
530
  _dynamic = null;
319
531
  _searchOnServer = null;
320
532
  _live = false;
@@ -335,6 +547,97 @@ export class Select extends Field {
335
547
  * `searchOnServer: false`: search runs in the browser, among the options already sent (for a complete list).
336
548
  */
337
549
  options(options, config = {}) { this._options = options; this._dynamic = config.dynamic ?? null; this._searchOnServer = config.searchOnServer ?? null; return this; }
550
+ /** "Yes" / "No" options, saved as `1` / `0`. */
551
+ boolean(trueLabel = null, falseLabel = null, placeholder = null) {
552
+ this._options = [['1', trueLabel ?? t('filament-forms::components.select.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.select.boolean.false')]];
553
+ if (placeholder !== null)
554
+ this._placeholder = placeholder;
555
+ return this;
556
+ }
557
+ /** `false`: the JavaScript select, even without search. */
558
+ native(condition = true) { this._native = condition; return this; }
559
+ /** Option labels are HTML (sanitized on the server; JavaScript select only). */
560
+ allowHtml(condition = true) { this._allowHtml = condition; return this; }
561
+ /** Maximum (and minimum) number of selected items of a multiple select, checked on the server. */
562
+ maxItems(count) { this._maxItems = count; return this; }
563
+ minItems(count) { this._minItems = count; return this; }
564
+ /** Number of options shown at once in the JavaScript select (50 by default). */
565
+ optionsLimit(limit) { this._optionsLimit = Math.max(1, Math.floor(limit)); return this; }
566
+ searchDebounce(ms) { this._searchDebounce = Math.max(0, Math.floor(ms)); return this; }
567
+ loadingMessage(message) { this._messages.loading = message; return this; }
568
+ noSearchResultsMessage(message) { this._messages.noSearchResults = message; return this; }
569
+ searchPrompt(message) { this._messages.searchPrompt = message; return this; }
570
+ searchingMessage(message) { this._messages.searching = message; return this; }
571
+ noOptionsMessage(message) { this._messages.noOptions = message; return this; }
572
+ prefix(label, isInline = false) { this._affixes.prefix = label; this._affixes.prefixInline = isInline; return this; }
573
+ suffix(label, isInline = false) { this._affixes.suffix = label; this._affixes.suffixInline = isInline; return this; }
574
+ prefixIcon(name, isInline = false) { this._affixes.prefixIcon = name; this._affixes.prefixInline = isInline; return this; }
575
+ suffixIcon(name, isInline = false) { this._affixes.suffixIcon = name; this._affixes.suffixInline = isInline; return this; }
576
+ prefixIconColor(color) { this._affixes.prefixIconColor = color; return this; }
577
+ suffixIconColor(color) { this._affixes.suffixIconColor = color; return this; }
578
+ prefixAction(action, isInline = false) { this._affixes.prefixActions = [...this._affixes.prefixActions, action]; this._affixes.prefixInline = isInline; return this; }
579
+ suffixAction(action, isInline = false) { this._affixes.suffixActions = [...this._affixes.suffixActions, action]; this._affixes.suffixInline = isInline; return this; }
580
+ prefixActions(actions) { this._affixes.prefixActions = actions; return this; }
581
+ suffixActions(actions) { this._affixes.suffixActions = actions; return this; }
582
+ /**
583
+ * Options from a relationship of the form's model (Objection `relationMappings`): a belongs-to relationship (the field is the
584
+ * foreign key, `Select.make('author_id').relationship('author', 'name')`), or a many-to-many one with `multiple()` (the field is
585
+ * named after the relationship, and saves the links once the record is saved). `modifyQueryUsing(query, search)` scopes the
586
+ * options; picked keys outside that scope are rejected. With `searchable()`, the options are searched on the server.
587
+ */
588
+ relationship(name, titleAttribute = null, modifyQueryUsing = null, options = {}) {
589
+ this._relationship = { name, title: titleAttribute, modify: modifyQueryUsing, model: options.model ?? null };
590
+ return this;
591
+ }
592
+ relationInfo() {
593
+ const ctx = this.context();
594
+ const model = modelOfForm(ctx, this._relationship.model);
595
+ const relation = model.getRelation(this._relationship.name);
596
+ const related = relation.relatedModelClass;
597
+ const id = `${related.tableName}.${related.idColumn}`;
598
+ const title = `${related.tableName}.${this._relationship.title ?? related.idColumn}`;
599
+ const isMany = /ManyToMany/.test(relation.constructor?.name ?? '') || typeof relation.joinTable === 'string';
600
+ return { model, relation, related, id, title, isMany };
601
+ }
602
+ async relationshipOptions(search, keys, limit = 1000) {
603
+ const { related, id, title } = this.relationInfo();
604
+ const query = related.query();
605
+ this._relationship.modify?.(query, search ?? undefined);
606
+ if (keys !== null) {
607
+ if (!keys.length)
608
+ return [];
609
+ query.whereIn(id, keys.slice(0, 1000));
610
+ }
611
+ if (search)
612
+ query.where(title, 'like', `%${search.slice(0, 255)}%`);
613
+ const rows = await query.clearSelect().select({ __key: id, __title: title }).orderBy(title).limit(limit);
614
+ return rows.map((row) => [String(row.__key), String(row.__title ?? row.__key)]);
615
+ }
616
+ isManyRelationship() { return this._relationship !== null && this.relationInfo().isMany; }
617
+ async savesRelationships() { return (await super.savesRelationships()) || this.isManyRelationship(); }
618
+ async loadStateFromRelationships(record) {
619
+ if (!this.isManyRelationship())
620
+ return super.loadStateFromRelationships(record);
621
+ const { model, related } = this.relationInfo();
622
+ const rows = await model.relatedQuery(this._relationship.name).for(record[model.idColumn]).clearSelect().select(`${related.tableName}.${related.idColumn} as __key`);
623
+ return { state: rows.map((row) => String(row.__key)) };
624
+ }
625
+ /** Many-to-many: links the picked records and unlinks the others (only keys allowed by the relationship's query). */
626
+ async saveRelationships(record) {
627
+ if (!this.isManyRelationship())
628
+ return super.saveRelationships(record);
629
+ const { model, related } = this.relationInfo();
630
+ const key = record[model.idColumn];
631
+ const desired = new Set((await this.relationshipOptions(null, this.selectedValues())).map(([value]) => value));
632
+ const current = (await this.loadStateFromRelationships(record)).state;
633
+ const removed = current.filter((value) => !desired.has(value));
634
+ if (removed.length)
635
+ await model.relatedQuery(this._relationship.name).for(key).unrelate().whereIn(`${related.tableName}.${related.idColumn}`, removed);
636
+ for (const value of desired)
637
+ if (!current.includes(value))
638
+ await model.relatedQuery(this._relationship.name).for(key).relate(related.idColumn === 'id' && /^\d+$/.test(value) ? Number(value) : value);
639
+ await super.saveRelationships(record);
640
+ }
338
641
  /** Disables an option depending on the context (e.g. already picked in another row). */
339
642
  disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
340
643
  /** Sends the state to the server as soon as it changes. */
@@ -356,33 +659,52 @@ export class Select extends Field {
356
659
  async labelsOf(values) {
357
660
  if (!values.length)
358
661
  return [];
359
- const known = this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
662
+ const known = this._relationship ? await this.relationshipOptions(null, values) : this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
360
663
  const map = new Map((Array.isArray(known) ? known : Object.entries(known)).map(([value, label]) => [String(value), String(label)]));
361
664
  return values.filter((value) => map.has(value)).map((value) => [value, map.get(value)]);
362
665
  }
363
- /** No-op: options are always loaded with the form. */
364
- preload() { return this; }
666
+ _preload = false;
667
+ /** Relationship with `searchable()`: loads the options with the form, instead of on search only. Other options are always loaded. */
668
+ preload(condition = true) { this._preload = condition; return this; }
365
669
  hydrateState(value) {
366
670
  if (this._multiple)
367
671
  return (Array.isArray(value) ? value : []).map(String);
368
672
  return value === null || value === undefined || value === '' ? null : String(value);
369
673
  }
370
674
  dehydrateState(state) { return this._multiple ? this.selectedValues() : state; }
371
- async resolveOptions() {
675
+ /** Options with their groups (labels sanitized when `allowHtml()`). */
676
+ async resolveGroupedOptions() {
677
+ if (this._relationship)
678
+ return this._searchable && !this._preload ? [] : this.relationshipOptions(null, null);
372
679
  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)]);
680
+ const groups = normalizeOptions(options);
681
+ if (!this._allowHtml)
682
+ return groups;
683
+ const clean = ([value, label]) => [value, sanitizeHtml(label)];
684
+ return groups.map((item) => (Array.isArray(item) ? clean(item) : { label: item.label, options: item.options.map(clean) }));
685
+ }
686
+ async resolveOptions() { return flattenOptions(await this.resolveGroupedOptions()); }
687
+ itemCountRules() {
688
+ const count = (v) => (Array.isArray(v) ? new Set(v.map(String)).size : 0);
689
+ return [
690
+ ...(this._minItems !== null ? [{ name: 'min', passes: (v) => count(v) >= this._minItems, message: (a) => `The ${a} field must have at least ${this._minItems} items.` }] : []),
691
+ ...(this._maxItems !== null ? [{ name: 'max', passes: (v) => count(v) <= this._maxItems, message: (a) => `The ${a} field must not have more than ${this._maxItems} items.` }] : []),
692
+ ];
374
693
  }
375
694
  async getValidationRules() {
376
695
  // With server search, a value is valid if the server can label it; otherwise it must be among the options.
377
696
  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)));
697
+ 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.` }];
698
+ 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
699
  return [...(await super.getValidationRules()), { name: 'in', passes, message: (a) => `The selected ${a} is invalid.` }];
381
700
  }
382
701
  // Methods exposed to the select's JavaScript (called through callSchemaComponentMethod).
383
702
  getExposedMethods() { return ['getOptionLabel', 'getSearchResultsForJs', 'getOptionsForJs', 'getOptionLabelsForJs']; }
384
703
  static forJs(options) { return options.map(([value, label]) => ({ label, value, isDisabled: false })); }
385
- async getOptionsForJs() { return this.withDisabled(Select.forJs(await this.resolveOptions())); }
704
+ groupsForJs(groups) {
705
+ return groups.map((item) => (Array.isArray(item) ? this.withDisabled(Select.forJs([item]))[0] : { label: item.label, options: this.withDisabled(Select.forJs(item.options)) }));
706
+ }
707
+ async getOptionsForJs() { return this.groupsForJs(await this.resolveGroupedOptions()); }
386
708
  withDisabled(options) {
387
709
  if (!this._disableOptionWhen)
388
710
  return options;
@@ -390,17 +712,21 @@ export class Select extends Field {
390
712
  return options.map((option) => ({ ...option, isDisabled: this._disableOptionWhen(option.value, ctx) }));
391
713
  }
392
714
  async getSearchResultsForJs(search = '') {
715
+ if (this._relationship)
716
+ return this.withDisabled(Select.forJs(await this.relationshipOptions(String(search ?? '').slice(0, 1000), null, this._optionsLimit)));
393
717
  if (this._searchResultsUsing) {
394
718
  const found = await this._searchResultsUsing(String(search ?? '').slice(0, 1000), this.context());
395
719
  return Select.forJs((Array.isArray(found) ? found : Object.entries(found)).map(([value, label]) => [String(value), String(label)]));
396
720
  }
397
721
  const needle = String(search ?? '').toLowerCase();
398
- return Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle)));
722
+ return this.withDisabled(Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle))));
399
723
  }
400
724
  async getOptionLabel() {
401
725
  const state = this.context().state;
402
726
  if (state === null || state === undefined || state === '')
403
727
  return null;
728
+ if (this._relationship)
729
+ return (await this.relationshipOptions(null, [String(state)]))[0]?.[1] ?? null;
404
730
  if (this._optionLabelUsing)
405
731
  return this._optionLabelUsing(String(state), this.context());
406
732
  return (await this.resolveOptions()).find(([value]) => value === String(state))?.[1] ?? null;
@@ -449,9 +775,16 @@ export class Select extends Field {
449
775
  .modalSubmitActionLabel(tr('modal.actions.create.label'))
450
776
  .schema(this._createOptionForm)
451
777
  .action(async ({ data }) => {
452
- if (!this._createOptionUsing)
778
+ let created;
779
+ if (this._createOptionUsing)
780
+ created = await this._createOptionUsing(data, this.context());
781
+ else if (this._relationship) {
782
+ const { related } = this.relationInfo();
783
+ const record = await related.query().insert(data);
784
+ created = record[related.idColumn];
785
+ }
786
+ else
453
787
  throw new Error(`Select [${this.getStatePath()}]: createOptionUsing() is required with createOptionForm().`);
454
- const created = await this._createOptionUsing(data, this.context());
455
788
  if (created === null || created === undefined || created === '')
456
789
  return;
457
790
  this.setState(this._multiple ? [...new Set([...this.selectedValues(), String(created)])] : String(created));
@@ -481,36 +814,53 @@ export class Select extends Field {
481
814
  return this._modifyEditOptionAction?.(action) ?? action;
482
815
  }
483
816
  /** 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. */
817
+ getActions() { return [...this.getHintActions(), ...this._affixes.prefixActions, ...this._affixes.suffixActions, ...[this.getCreateOptionAction(), this.getEditOptionAction()].filter((action) => action !== null)]; }
818
+ affixIcon(name, color) { return name ? icon(name, { attributes: new Attributes().class(color && color !== 'gray' ? ['fi-color', `fi-color-${color}`] : []) }) : ''; }
819
+ async affixActionsHtml(actions) {
820
+ const ctx = this.context();
821
+ const html = [];
822
+ for (const action of actions)
823
+ if (await action.isVisible(ctx))
824
+ html.push(await action.iconButton().size('sm').toHtml(ctx, { schemaComponent: this.getKey() }));
825
+ return html;
826
+ }
827
+ async prefixHtml() {
828
+ const a = this._affixes;
829
+ return inputAffixHtml('prefix', { label: a.prefix, icon: this.affixIcon(a.prefixIcon, a.prefixIconColor), actions: await this.affixActionsHtml(a.prefixActions), inline: a.prefixInline });
830
+ }
831
+ /** Suffix: label, icon, actions, then the "+" and "edit" buttons. */
486
832
  async suffixHtml() {
487
833
  const ctx = this.context();
488
834
  const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.getKey() };
489
- const buttons = [];
835
+ const buttons = await this.affixActionsHtml(this._affixes.suffixActions);
490
836
  for (const action of [this.getCreateOptionAction(), this.getEditOptionAction()]) {
491
837
  if (action && (await action.isVisible(ctx)))
492
838
  buttons.push((await action.toLinkHtml(ctx, context, '')).replace(' wire:key=""', ''));
493
839
  }
494
- return buttons.length ? `<div class="fi-input-wrp-suffix"><div class="fi-input-wrp-actions">${buttons.join('')}</div></div>` : '';
840
+ const a = this._affixes;
841
+ return inputAffixHtml('suffix', { label: a.suffix, icon: this.affixIcon(a.suffixIcon, a.suffixIconColor), actions: buttons, inline: a.suffixInline });
495
842
  }
496
843
  async toHtml() {
497
844
  const isDisabled = await this.isDisabled();
498
- const resolved = await this.resolveOptions();
845
+ const grouped = await this.resolveGroupedOptions();
846
+ const resolved = flattenOptions(grouped);
499
847
  const suffix = await this.suffixHtml();
500
- if (this._searchable) {
848
+ const prefix = await this.prefixHtml();
849
+ if (this._searchable || this._multiple || !this._native || this._allowHtml) {
501
850
  const isRequired = (await this.isRequired()) && !isDisabled;
502
851
  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 });
852
+ return this.wrap(await this.toJsHtml(grouped, isDisabled, suffix, prefix), { wrapperClass: 'fi-fo-select-wrp', after });
504
853
  }
505
854
  const select = new Attributes()
506
855
  .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
856
  .class(['fi-select-input']);
508
857
  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' });
858
+ const option = ([value, label]) => `<option value="${e(value)}"${this._disableOptionWhen?.(value, this.context()) ? ' disabled' : ''}>${e(label)}</option>`;
859
+ 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('');
860
+ 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
861
  }
512
862
  /** JavaScript select rendering. */
513
- async toJsHtml(options, isDisabled, suffix = '') {
863
+ async toJsHtml(grouped, isDisabled, suffix = '', prefix = '') {
514
864
  const state = this.context().state;
515
865
  const hasState = !this._multiple && state !== null && state !== undefined && state !== '';
516
866
  const selected = this._multiple ? await this.labelsOf(this.selectedValues()) : [];
@@ -518,7 +868,7 @@ export class Select extends Field {
518
868
  // Required field: a screen-reader instruction ("Select an option.") is linked to the select.
519
869
  const describedBy = [isRequired ? `${this.getId()}-required` : null, this.hasError() ? `${this.getId()}-error` : null].filter(Boolean).join(' ') || null;
520
870
  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));
871
+ const forJs = this.groupsForJs(grouped);
522
872
  // Dynamic options come from a function, so they may change between requests.
523
873
  const hasDynamicOptions = this._dynamic ?? typeof this._options === 'function';
524
874
  const tr = (key, replace = {}) => t(`filament-forms::components.select.${key}`, replace);
@@ -528,41 +878,41 @@ export class Select extends Field {
528
878
  clearButtonLabel: ${js(tr('actions.clear.label'))},
529
879
  errorMessage: ${js(tr('error_message'))},
530
880
  hasDynamicOptions: ${hasDynamicOptions ? 'true' : 'false'},
531
- hasDynamicSearchResults: ${this._searchResultsUsing || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
532
- hasInitialNoOptionsMessage: ${this._searchResultsUsing || this._hasNoInitialOptions ? 'false' : 'true'},
881
+ hasDynamicSearchResults: ${this._searchResultsUsing || (this._relationship && this._searchable) || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
882
+ hasInitialNoOptionsMessage: ${this._searchResultsUsing || (this._relationship && this._searchable && !this._preload) || this._hasNoInitialOptions ? 'false' : 'true'},
533
883
  id: ${js(this.getId())},
534
884
  initialOptionLabel: ${js(hasState ? await this.getOptionLabel() : null)},
535
885
  initialOptionLabels: ${selected.length ? js(selected.map(([value, label]) => ({ label, value, isDisabled: false }))) : '[]'},
536
886
  initialState: ${this._multiple ? (selected.length ? js(selected.map(([value]) => value)) : '[]') : js(hasState ? String(state) : null)},
537
887
  isAutofocused: ${this._autofocus ? 'true' : 'false'},
538
888
  isDisabled: ${isDisabled ? 'true' : 'false'},
539
- isHtmlAllowed: false,
889
+ isHtmlAllowed: ${this._allowHtml ? 'true' : 'false'},
540
890
  isMultiple: ${this._multiple ? 'true' : 'false'},
541
891
  isReorderable: false,
542
- isSearchable: true,
892
+ isSearchable: ${this._searchable ? 'true' : 'false'},
543
893
  key: ${js(this.getKey())},
544
894
  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'))},
895
+ loadingMessage: ${js(this._messages.loading ?? tr('loading_message'))},
896
+ maxItems: ${this._maxItems ?? 'null'},
897
+ maxItemsMessage: ${js(tr('max_items_message', { count: String(this._maxItems ?? '') }))},
898
+ noOptionsMessage: ${js(this._messages.noOptions ?? tr('no_options_message'))},
899
+ noSearchResultsMessage: ${js(this._messages.noSearchResults ?? tr('no_search_results_message'))},
550
900
  options: ${forJs.length ? js(forJs) : '[]'},
551
- optionsLimit: 50,
901
+ optionsLimit: ${this._optionsLimit},
552
902
  placeholder: ${js(this._placeholder ?? tr('placeholder'))},
553
903
  position: null,
554
904
  removeButtonLabel: ${js(tr('actions.remove_option.label'))},
555
- searchDebounce: 1000,
556
- searchingMessage: ${js(tr('searching_message'))},
905
+ searchDebounce: ${this._searchDebounce},
906
+ searchingMessage: ${js(this._messages.searching ?? tr('searching_message'))},
557
907
  searchLabel: ${js(tr('search_label'))},
558
- searchPrompt: ${js(tr('search_prompt'))},
908
+ searchPrompt: ${js(this._messages.searchPrompt ?? tr('search_prompt'))},
559
909
  searchableOptionFields: ${js(['label'])},
560
910
  state: $wire.$entangle(${js(this.getStatePath())}, ${this._live ? 'true' : 'false'}),
561
911
  statePath: ${js(this.getStatePath())},
562
912
  })`;
563
913
  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
914
  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="{
915
+ return `<div ${wrapper.toHtml()}>${prefix}<div class="fi-input-wrp-content-ctn"><div class="fi-hidden" data-select-accessibility="${e(accessibility)}" x-data="{
566
916
  isDisabled: ${isDisabled ? 'true' : 'false'},
567
917
  init() {
568
918
  const container = $el.nextElementSibling
@@ -587,28 +937,106 @@ export class TableColumn {
587
937
  const ITEM_KEY = /^[A-Za-z0-9_-]{1,64}$/;
588
938
  const isItemKey = (key) => ITEM_KEY.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
589
939
  /**
590
- * List of repeated items sharing the same fields, displayed as a table.
940
+ * List of repeated items sharing the same fields: stacked cards (default), a table (`table()`), or one field per item (`simple()`).
591
941
  * The state is a map `item key → fields`; key order is display order.
592
- * Add, delete and move are component actions, mounted with `schemaComponent`.
942
+ * Add, delete, clone and move are component actions, mounted with `schemaComponent`.
593
943
  */
594
944
  export class Repeater extends Field {
595
945
  _schema = () => [];
596
946
  _columns = [];
597
947
  _defaultItems = 0;
598
948
  _maxItems = 500;
949
+ _minItems = null;
599
950
  _itemLinks = [];
951
+ _simple = null;
952
+ _grid = null;
953
+ _collapsible = false;
954
+ _collapsed = false;
955
+ _persistCollapsed = false;
956
+ _cloneable = false;
957
+ _addable = true;
958
+ _deletable = true;
959
+ _reorderableWithButtons = false;
960
+ _itemLabel = null;
961
+ _itemNumbers = false;
962
+ _truncateItemLabel = true;
963
+ _headingTag = 'h4';
964
+ _addActionLabel = null;
965
+ _addActionAlignment = null;
966
+ _labelBetweenItems = null;
967
+ _itemHeaders = true;
968
+ _relationship = null;
600
969
  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; }
970
+ /** Item components: a function called for each item. */
971
+ schema(factory) { this._schema = typeof factory === 'function' ? factory : () => factory.map(cloneComponentTree); return this; }
603
972
  /** Table display, one column per field. */
604
973
  table(columns) { this._columns = columns; return this; }
974
+ isTable() { return this._columns.length > 0; }
975
+ /**
976
+ * One field per item, without a card: the state is saved as a list of values (`['a@example.com', 'b@example.com']`).
977
+ * The field is cloned for each item.
978
+ */
979
+ simple(field) { this._simple = field; return this; }
605
980
  defaultItems(count) { this._defaultItems = count; return this; }
606
981
  maxItems(count) { this._maxItems = count; return this; }
982
+ /** Minimum number of items, checked on the server. */
983
+ minItems(count) { this._minItems = count; return this; }
607
984
  _reorderable = true;
608
985
  /** `false`: items keep their order (e.g. a relationship without an order column). */
609
986
  reorderable(condition = true) { this._reorderable = condition; return this; }
987
+ _dragAndDrop = true;
988
+ /** Move up / move down buttons in each item header, besides drag and drop. */
989
+ reorderableWithButtons(condition = true) { this._reorderableWithButtons = condition; return this; }
990
+ /** `false`: no drag and drop (buttons may still reorder, with `reorderableWithButtons()`). */
991
+ reorderableWithDragAndDrop(condition = true) { this._dragAndDrop = condition; return this; }
992
+ /** Reordering is off with `reorderable(false)`, and for a relationship without `orderColumn()` (the order could not be saved). */
993
+ isReorderable() { return this._reorderable && !(this._relationship !== null && this._relationship.orderColumn === null && this._orderColumnPending === null); }
994
+ canDrag() { return this.isReorderable() && this._dragAndDrop; }
995
+ canMove() { return this.isReorderable() && (this._dragAndDrop || this._reorderableWithButtons); }
610
996
  /** Link shown on each row (an item action pointing to a URL). */
611
997
  extraItemLink(resolver) { this._itemLinks.push(resolver); return this; }
998
+ /** Items laid out in a grid (`2`, or `{ md: 2, xl: 3 }`). */
999
+ grid(columns = 2) { this._grid = columns; return this; }
1000
+ /** Items can be collapsed from their header; `collapsed()` collapses them on load. */
1001
+ collapsible(condition = true) { this._collapsible = condition; return this; }
1002
+ collapsed(condition = true) { this._collapsed = condition; if (condition)
1003
+ this._collapsible = true; return this; }
1004
+ /** Keeps each item's collapsed state in the browser (local storage). */
1005
+ persistCollapsed(condition = true) { this._persistCollapsed = condition; return this; }
1006
+ cloneable(condition = true) { this._cloneable = condition; return this; }
1007
+ /** `false`: no "Add" button (and adding is refused on the server). */
1008
+ addable(condition = true) { this._addable = condition; return this; }
1009
+ /** `false`: no delete button (and deleting is refused on the server). */
1010
+ deletable(condition = true) { this._deletable = condition; return this; }
1011
+ /** Item header label: a string, or a function of the item's state (`({ state }) => state.name ?? null`). Escaped. */
1012
+ itemLabel(label) { this._itemLabel = label; return this; }
1013
+ /** Item number after the label. */
1014
+ itemNumbers(condition = true) { this._itemNumbers = condition; return this; }
1015
+ truncateItemLabel(condition = true) { this._truncateItemLabel = condition; return this; }
1016
+ /** `false`: items have no header (and so no buttons): only their fields. */
1017
+ itemHeaders(condition = true) { this._itemHeaders = condition; return this; }
1018
+ addActionLabel(label) { this._addActionLabel = label; return this; }
1019
+ addActionAlignment(alignment) { this._addActionAlignment = alignment; return this; }
1020
+ labelBetweenItems(label) { this._labelBetweenItems = label; return this; }
1021
+ /**
1022
+ * Items are the records of a has-many relationship of the form's model (Objection `relationMappings`): loaded when the form is
1023
+ * filled, then created, updated and deleted once the record is saved. `orderColumn()` saves the items' order.
1024
+ */
1025
+ relationship(name = null, modifyQueryUsing = null, options = {}) {
1026
+ this._relationship = { name: name ?? this._name, modify: modifyQueryUsing, orderColumn: this._relationship?.orderColumn ?? null, model: options.model ?? null };
1027
+ return this;
1028
+ }
1029
+ orderColumn(column = 'sort') { if (this._relationship)
1030
+ this._relationship.orderColumn = column;
1031
+ else
1032
+ this._orderColumnPending = column; return this; }
1033
+ _orderColumnPending = null;
1034
+ itemComponents() {
1035
+ if (this._simple)
1036
+ return [cloneComponentTree(this._simple).hiddenLabel()];
1037
+ return this._schema();
1038
+ }
1039
+ itemFields() { return this.itemComponents().filter((component) => component instanceof Field); }
612
1040
  /** Well-formed items only: keys come from the browser, so only simple keys are kept. */
613
1041
  getItems() {
614
1042
  const state = this.context().state;
@@ -617,28 +1045,118 @@ export class Repeater extends Field {
617
1045
  return Object.fromEntries(Object.entries(state).filter(([key, item]) => isItemKey(key) && item !== null && typeof item === 'object' && !Array.isArray(item)).slice(0, this._maxItems));
618
1046
  }
619
1047
  setItems(items) { writePath(this.livewire, this.getStatePath().split('.'), items); }
620
- itemDefaults() { return Object.fromEntries(this._schema().map((field) => [field.getName(), field.getDefaultState()])); }
1048
+ itemDefaults() {
1049
+ const data = {};
1050
+ fillSchema(new Schema(this.container.livewire).statePath('').components(this.itemComponents()), data);
1051
+ return data;
1052
+ }
621
1053
  getDefaultState() { return Object.fromEntries(Array.from({ length: this._defaultItems }, () => [randomUUID(), this.itemDefaults()])); }
622
- /** Relationship records → `record-<id>` items, limited to the declared fields. */
1054
+ /** A list (records → `record-<id>` items; simple values → one-field items) or a keyed map, limited to the declared fields. */
623
1055
  hydrateState(value) {
1056
+ if (typeof value === 'string') {
1057
+ try {
1058
+ value = JSON.parse(value);
1059
+ }
1060
+ catch {
1061
+ value = null;
1062
+ }
1063
+ }
624
1064
  if (!Array.isArray(value))
625
1065
  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())]))]));
1066
+ const fields = this.itemFields();
1067
+ return Object.fromEntries(value.slice(0, this._maxItems).map((row) => {
1068
+ if (this._simple)
1069
+ return [randomUUID(), { [this._simple.getName()]: this._simple.hydrateState(row) }];
1070
+ const key = row && typeof row === 'object' && row.id !== undefined && row.id !== null ? `record-${row.id}` : randomUUID();
1071
+ return [key, Object.fromEntries(fields.map((field) => [field.getName(), field.hydrateState(row?.[field.getName()] ?? field.getDefaultState())]))];
1072
+ }));
628
1073
  }
629
1074
  dehydrateState() { return Object.fromEntries(Object.keys(this.getItems()).map((key) => [key, {}])); }
1075
+ /** Simple repeater: saved as a list of values. */
1076
+ finalizeDehydratedState(state) {
1077
+ if (!this._simple || !state || typeof state !== 'object' || Array.isArray(state))
1078
+ return state;
1079
+ const name = this._simple.getName();
1080
+ return Object.values(state).map((item) => item?.[name] ?? null);
1081
+ }
630
1082
  getChildSchemas() {
631
1083
  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()));
1084
+ 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
1085
  }
1086
+ _childColumns = null;
1087
+ /** Grid columns inside each item. */
1088
+ columns(columns) { this._childColumns = columns; return this; }
634
1089
  async getValidationRules() {
635
1090
  const rules = [];
1091
+ const count = () => Object.keys(this.getItems()).length;
636
1092
  if (await this.isRequired())
637
- rules.push({ name: 'required', passes: () => Object.keys(this.getItems()).length > 0, message: (a) => `The ${a} field is required.` });
1093
+ rules.push({ name: 'required', passes: () => count() > 0, message: (a) => `The ${a} field is required.` });
1094
+ if (this._minItems !== null)
1095
+ rules.push({ name: 'min', implicit: true, passes: () => count() >= this._minItems, message: (a) => `The ${a} field must have at least ${this._minItems} items.` });
1096
+ rules.push({ name: 'max', implicit: true, passes: () => count() <= this._maxItems, message: (a) => `The ${a} field must not have more than ${this._maxItems} items.` });
638
1097
  return [...rules, ...this._rules];
639
1098
  }
1099
+ // ---- relationship
1100
+ relationInfo() {
1101
+ const model = modelOfForm(this.context(), this._relationship.model);
1102
+ const relation = model.getRelation(this._relationship.name);
1103
+ return { model, relation, related: relation.relatedModelClass };
1104
+ }
1105
+ async savesRelationships() { return (await super.savesRelationships()) || this._relationship !== null; }
1106
+ relatedQuery(record) {
1107
+ const { model } = this.relationInfo();
1108
+ const query = model.relatedQuery(this._relationship.name).for(record[model.idColumn]);
1109
+ this._relationship.modify?.(query);
1110
+ return query;
1111
+ }
1112
+ async loadStateFromRelationships(record) {
1113
+ if (!this._relationship)
1114
+ return super.loadStateFromRelationships(record);
1115
+ if (this._orderColumnPending !== null) {
1116
+ this._relationship.orderColumn = this._orderColumnPending;
1117
+ this._orderColumnPending = null;
1118
+ }
1119
+ const { related } = this.relationInfo();
1120
+ const query = this.relatedQuery(record);
1121
+ if (this._relationship.orderColumn)
1122
+ query.orderBy(`${related.tableName}.${this._relationship.orderColumn}`);
1123
+ const rows = await query.limit(this._maxItems);
1124
+ return { state: rows.map((row) => ({ ...row, id: row[related.idColumn] })) };
1125
+ }
1126
+ /** Creates, updates and deletes the related records; only records of this owner (and query) are touched. */
1127
+ async saveRelationships(record) {
1128
+ if (!this._relationship)
1129
+ return super.saveRelationships(record);
1130
+ if (this._orderColumnPending !== null) {
1131
+ this._relationship.orderColumn = this._orderColumnPending;
1132
+ this._orderColumnPending = null;
1133
+ }
1134
+ const { related } = this.relationInfo();
1135
+ const existing = new Map((await this.relatedQuery(record)).map((row) => [String(row[related.idColumn]), row]));
1136
+ const kept = new Set();
1137
+ const keys = Object.keys(this.getItems());
1138
+ const schemas = this.getChildSchemas();
1139
+ for (const [index, key] of keys.entries()) {
1140
+ const data = await getSchemaState(schemas[index]);
1141
+ if (this._relationship.orderColumn)
1142
+ data[this._relationship.orderColumn] = index + 1;
1143
+ const id = key.startsWith('record-') ? key.slice('record-'.length) : null;
1144
+ if (id !== null && existing.has(id)) {
1145
+ kept.add(id);
1146
+ await related.query().findById(existing.get(id)[related.idColumn]).patch(data);
1147
+ }
1148
+ else {
1149
+ await this.relatedQuery(record).insert(data);
1150
+ }
1151
+ }
1152
+ const removed = [...existing.keys()].filter((id) => !kept.has(id));
1153
+ if (removed.length)
1154
+ await related.query().whereIn(related.idColumn, removed.map((id) => existing.get(id)[related.idColumn])).delete();
1155
+ }
640
1156
  getActions() {
641
1157
  const move = (offset) => ({ arguments: args }) => {
1158
+ if (!this.canMove())
1159
+ return;
642
1160
  const items = this.getItems();
643
1161
  const keys = Object.keys(items);
644
1162
  const from = keys.indexOf(String(args.item));
@@ -648,16 +1166,26 @@ export class Repeater extends Field {
648
1166
  [keys[from], keys[to]] = [keys[to], keys[from]];
649
1167
  this.setItems(Object.fromEntries(keys.map((key) => [key, items[key]])));
650
1168
  };
1169
+ const insertAfter = (items, after, key, item) => Object.fromEntries(Object.entries(items).flatMap(([k, v]) => (k === after ? [[k, v], [key, item]] : [[k, v]])));
651
1170
  return [
652
1171
  ...this.getHintActions(),
653
- Action.make('add').action(() => { const items = this.getItems(); if (Object.keys(items).length >= this._maxItems)
1172
+ Action.make('add').action(() => { const items = this.getItems(); if (!this._addable || Object.keys(items).length >= this._maxItems)
654
1173
  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))
1174
+ Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!this._deletable || !Object.hasOwn(items, key))
656
1175
  return; delete items[key]; this.setItems(items); }),
1176
+ Action.make('clone').action(({ arguments: args }) => {
1177
+ const items = this.getItems();
1178
+ const key = String(args.item);
1179
+ if (!this._cloneable || !Object.hasOwn(items, key) || Object.keys(items).length >= this._maxItems)
1180
+ return;
1181
+ this.setItems(insertAfter(items, key, randomUUID(), JSON.parse(JSON.stringify(items[key]))));
1182
+ }),
657
1183
  Action.make('moveUp').action(move(-1)),
658
1184
  Action.make('moveDown').action(move(1)),
659
1185
  // Drag and drop: the browser sends the new order, only accepted if it is a permutation of the existing keys.
660
1186
  Action.make('reorder').action(({ arguments: args }) => {
1187
+ if (!this.canDrag())
1188
+ return;
661
1189
  const items = this.getItems();
662
1190
  const keys = Object.keys(items);
663
1191
  const order = Array.isArray(args.items) ? args.items.map(String) : [];
@@ -667,9 +1195,85 @@ export class Repeater extends Field {
667
1195
  }),
668
1196
  ];
669
1197
  }
1198
+ async itemLabelOf(key, index, item) {
1199
+ const label = typeof this._itemLabel === 'function' ? await this._itemLabel({ state: item, key, index }) : this._itemLabel;
1200
+ return label === null || label === undefined || label === '' ? null : String(label);
1201
+ }
670
1202
  async toHtml() {
671
1203
  if (!colorManager)
672
1204
  throw new Error('No color manager: call useColorManager().');
1205
+ if (this.isTable())
1206
+ return this.toTableHtml();
1207
+ const id = this.getId();
1208
+ const key = this.getKey();
1209
+ const items = this.getItems();
1210
+ const keys = Object.keys(items);
1211
+ const schemas = this.getChildSchemas();
1212
+ const tr = (name, replace = {}) => t(`filament-forms::components.repeater.${name}`, replace);
1213
+ const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: key };
1214
+ const expression = (action, args) => `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
1215
+ const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
1216
+ const danger = colorManager.getComponentClasses('icon-button', 'danger');
1217
+ const gray = colorManager.getComponentClasses('icon-button', 'gray');
1218
+ const button = (options) => {
1219
+ 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);
1220
+ 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>`;
1221
+ };
1222
+ const rows = [];
1223
+ for (const [index, itemKey] of keys.entries()) {
1224
+ const isFirst = index === 0, isLast = index === keys.length - 1;
1225
+ const moveButton = (action, label, iconName, direction, disabled) => {
1226
+ const expr = expression(action, { item: itemKey });
1227
+ 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>`;
1228
+ };
1229
+ const content = await schemas[index].toHtml();
1230
+ if (this._simple) {
1231
+ const actions = [
1232
+ ...(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>`] : []),
1233
+ ...(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)] : []),
1234
+ ...(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>`] : []),
1235
+ ...(this._deletable ? [`<li x-on:click.stop>${button({ label: tr('actions.delete.label'), icon: 'm-trash', expression: expression('delete', { item: itemKey }), colors: danger })}</li>`] : []),
1236
+ ];
1237
+ 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>`);
1238
+ continue;
1239
+ }
1240
+ const label = await this.itemLabelOf(itemKey, index, items[itemKey]);
1241
+ const start = [
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
+ ];
1245
+ const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null && /^(\/(?!\/)|https?:\/\/)/.test(link.url))
1246
+ .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>`);
1247
+ const end = [
1248
+ ...links,
1249
+ ...(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>`] : []),
1250
+ ...(this._deletable ? [`<li x-on:click.stop>${button({ label: tr('actions.delete.label'), icon: 'm-trash', expression: expression('delete', { item: itemKey }), colors: danger })}</li>`] : []),
1251
+ ...(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>`] : []),
1252
+ ];
1253
+ const hasHeader = this._itemHeaders && (start.length > 0 || label !== null || end.length > 0);
1254
+ 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>` : '';
1255
+ const itemConfig = { isCollapsed: this._collapsed, collapseKey: this._persistCollapsed ? `filament:repeater:collapsed:${key}-${itemKey}` : null };
1256
+ 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>`);
1257
+ if (!isLast && this._labelBetweenItems)
1258
+ 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>`);
1259
+ }
1260
+ const prefix = this._simple ? 'fi-fo-simple-repeater' : 'fi-fo-repeater';
1261
+ const gridAttributes = this._grid !== null ? new Attributes().grid(this._grid).toHtml() : '';
1262
+ const gridClass = /class="([^"]*)"/.exec(gridAttributes)?.[1] ?? '';
1263
+ const gridStyle = /style="([^"]*)"/.exec(gridAttributes)?.[1] ?? null;
1264
+ 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>` : '';
1265
+ const label = await this.getLabel();
1266
+ const addExpr = expression('add', null);
1267
+ 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>` : '';
1268
+ 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>` : '';
1269
+ const itemLabel = tr('reordering.item');
1270
+ 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('');
1271
+ const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
1272
+ 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>`;
1273
+ return this.wrap(html, { labelAsDiv: true });
1274
+ }
1275
+ async toTableHtml() {
1276
+ const colors = colorManager;
673
1277
  const id = this.getId();
674
1278
  const key = this.getKey();
675
1279
  const livewireKey = this.getLivewireKey() ?? '';
@@ -686,7 +1290,7 @@ export class Repeater extends Field {
686
1290
  const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
687
1291
  const rows = [];
688
1292
  // No reorder column while there is only one row.
689
- const hasReorderColumn = this._reorderable && keys.length > 1;
1293
+ const hasReorderColumn = this.canDrag() && keys.length > 1;
690
1294
  for (const [index, itemKey] of keys.entries()) {
691
1295
  const moveButton = (action, label, iconName, direction, disabled) => {
692
1296
  const expr = expression(action, { item: itemKey });
@@ -695,68 +1299,291 @@ export class Repeater extends Field {
695
1299
  const cells = [];
696
1300
  for (const field of schemas[index].getComponents())
697
1301
  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)
1302
+ // Only local or http(s) URLs: a link built from item data cannot become a `javascript:` URL.
1303
+ const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null && /^(\/(?!\/)|https?:\/\/)/.test(link.url))
699
1304
  .map((link) => `<div x-on:click.stop><a href="${e(link.url)}" target="_blank" x-tooltip="{
700
1305
  content: ${e(js(link.label))},
701
1306
  theme: $store.theme,
702
1307
  allowHTML: false,
703
1308
  }" aria-label="${e(link.label)}" class="${button} fi-size-sm">${icon(link.icon)}</a></div>`);
704
1309
  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>`);
1310
+ 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
1311
  }
707
1312
  const head = this._columns.map((column) => `<th scope="col"${column.getWidth() ? ` style="width: ${e(column.getWidth())};"` : ''}>${e(column.label)}</th>`).join('');
708
1313
  const addExpr = expression('add', null);
709
1314
  const label = await this.getLabel();
710
1315
  const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
711
1316
  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>`;
1317
+ 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
1318
  return this.wrap(table, { labelAsDiv: true });
714
1319
  }
715
1320
  }
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>`);
1321
+ /** 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. */
1322
+ export function phpDateFormatToDayjs(format) {
1323
+ 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' };
1324
+ let out = '';
1325
+ for (let index = 0; index < format.length; index++) {
1326
+ const char = format[index];
1327
+ if (char === '\\' && index + 1 < format.length) {
1328
+ out += `[${format[++index]}]`;
1329
+ continue;
1330
+ }
1331
+ out += Object.hasOwn(tokens, char) ? tokens[char] : /[a-zA-Z]/.test(char) ? `[${char}]` : char;
734
1332
  }
1333
+ return out;
1334
+ }
1335
+ const pad = (n, size = 2) => String(n).padStart(size, '0');
1336
+ /** `YYYY-MM-DD HH:MM:SS` of a wall-clock time in a time zone, from a UTC instant. */
1337
+ function formatInZone(date, timeZone) {
1338
+ 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]));
1339
+ return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
1340
+ }
1341
+ /** UTC instant of a wall-clock `YYYY-MM-DD HH:MM:SS` in a time zone. */
1342
+ function zonedToUtc(value, timeZone) {
1343
+ const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(value);
1344
+ if (!match)
1345
+ return null;
1346
+ const asUtc = Date.UTC(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +(match[6] ?? 0));
1347
+ // Offset of the zone at that moment (twice, to settle around DST changes).
1348
+ let guess = asUtc;
1349
+ for (let i = 0; i < 2; i++) {
1350
+ const shown = Date.parse(formatInZone(new Date(guess), timeZone).replace(' ', 'T') + 'Z');
1351
+ guess = asUtc - (shown - guess);
1352
+ }
1353
+ return new Date(guess);
1354
+ }
1355
+ /** Normalizes a date-like value to `YYYY-MM-DD HH:MM:SS` (or null). */
1356
+ function normalizeDateTime(value) {
1357
+ if (value instanceof Date)
1358
+ return Number.isNaN(value.getTime()) ? null : `${value.getUTCFullYear()}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())} ${pad(value.getUTCHours())}:${pad(value.getUTCMinutes())}:${pad(value.getUTCSeconds())}`;
1359
+ if (typeof value !== 'string' || value === '')
1360
+ return null;
1361
+ const match = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
1362
+ if (match)
1363
+ return `${match[1]} ${match[2] ?? '00'}:${match[3] ?? '00'}:${match[4] ?? '00'}`;
1364
+ const time = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
1365
+ return time ? `1970-01-01 ${time[1]}:${time[2]}:${time[3] ?? '00'}` : null;
735
1366
  }
736
- /** Native `datetime-local` input: the saved state is `YYYY-MM-DD HH:MM:SS`. */
737
- export class DateTimePicker extends Field {
1367
+ /**
1368
+ * Base of the date, time and date-time pickers: native browser input by default, or Filament's JavaScript picker with
1369
+ * `native(false)` (display format, disabled dates, first day of the week, locale). Limits and disabled dates are checked on the server.
1370
+ */
1371
+ class TemporalField extends Field {
738
1372
  _seconds = true;
739
- static make(name) { return configured(new DateTimePicker(name)); }
1373
+ _native = true;
1374
+ _maxDate = null;
1375
+ _minDate = null;
1376
+ _displayFormat = null;
1377
+ _format = null;
1378
+ _firstDayOfWeek = 1;
1379
+ _disabledDates = [];
1380
+ _closeOnDateSelection = false;
1381
+ _locale = null;
1382
+ _timezone = null;
1383
+ _hoursStep = 1;
1384
+ _minutesStep = 1;
1385
+ _secondsStep = 1;
1386
+ _defaultFocusedDate = null;
1387
+ _temporalPlaceholder = null;
740
1388
  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;
1389
+ hasSeconds() { return this.hasTime() && this._seconds; }
1390
+ /** `false`: Filament's JavaScript picker instead of the browser's native input. */
1391
+ native(condition = true) { this._native = condition; return this; }
1392
+ isNative() { return this._native; }
1393
+ /** Display format of the JavaScript picker, in PHP's notation (`'d/m/Y'`, `'M j, Y H:i'`). */
1394
+ displayFormat(format) { this._displayFormat = format; return this; }
1395
+ /** 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). */
1396
+ format(format) { this._format = format; return this; }
1397
+ /** First day of the week in the JavaScript picker: 0 (Sunday) to 6. */
1398
+ firstDayOfWeek(day) { this._firstDayOfWeek = Math.min(6, Math.max(0, Math.floor(day))); return this; }
1399
+ weekStartsOnMonday() { return this.firstDayOfWeek(1); }
1400
+ weekStartsOnSunday() { return this.firstDayOfWeek(0); }
1401
+ /** Dates that cannot be picked (`YYYY-MM-DD`), also rejected on the server. */
1402
+ disabledDates(dates) { this._disabledDates = dates; return this; }
1403
+ closeOnDateSelection(condition = true) { this._closeOnDateSelection = condition; return this; }
1404
+ /** Locale of the JavaScript picker's month and day names (`'fr'`). */
1405
+ locale(locale) { this._locale = locale; return this; }
1406
+ /** Shows and edits the value in this time zone, while it is saved in UTC (date-time pickers). */
1407
+ timezone(timezone) { if (timezone !== null)
1408
+ new Intl.DateTimeFormat('en', { timeZone: timezone }); this._timezone = timezone; return this; }
1409
+ hoursStep(step) { this._hoursStep = step; return this; }
1410
+ minutesStep(step) { this._minutesStep = step; return this; }
1411
+ secondsStep(step) { this._secondsStep = step; return this; }
1412
+ /** Date focused when the JavaScript picker opens on an empty field. */
1413
+ defaultFocusedDate(date) { this._defaultFocusedDate = date; return this; }
1414
+ placeholder(text) { this._temporalPlaceholder = text; return this; }
1415
+ static resolveLimit(date) {
1416
+ const day = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
1417
+ return date === 'today' ? day(0) : date === 'tomorrow' ? day(1) : date === 'yesterday' ? day(-1) : date;
1418
+ }
1419
+ async limit(date) { const value = await evaluate(date, this.context()); return value ? TemporalField.resolveLimit(String(value)) : null; }
1420
+ /** Latest accepted date: `'today'`, a date, or a context function (`({ get }) => get('ends_at')`). */
1421
+ maxDate(date) {
1422
+ this._maxDate = date;
1423
+ 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'}.` });
1424
+ }
1425
+ minDate(date) {
1426
+ this._minDate = date;
1427
+ 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'}.` });
746
1428
  }
1429
+ /** Comparable string of a value: the date only for date pickers, the time only for time pickers. */
1430
+ comparable(value) {
1431
+ const normalized = normalizeDateTime(this.toUtc(value)) ?? String(value);
1432
+ if (!this.hasTime())
1433
+ return normalized.slice(0, 10);
1434
+ if (!this.hasDate())
1435
+ return normalized.slice(11, this._seconds ? 19 : 16);
1436
+ return normalized;
1437
+ }
1438
+ async getValidationRules() {
1439
+ const rules = await super.getValidationRules();
1440
+ if (!this.hasDate())
1441
+ return rules;
1442
+ return [...rules,
1443
+ { 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.` },
1444
+ { 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.` },];
1445
+ }
1446
+ /** Record value to form state, shown in the field's time zone. */
1447
+ hydrateState(value) {
1448
+ const normalized = normalizeDateTime(value);
1449
+ if (normalized === null)
1450
+ return value === undefined ? null : value === '' ? null : typeof value === 'string' ? value : null;
1451
+ let local = normalized;
1452
+ if (this._timezone && this.hasDate() && this.hasTime())
1453
+ local = formatInZone(new Date(normalized.replace(' ', 'T') + 'Z'), this._timezone);
1454
+ return this.stateOf(local);
1455
+ }
1456
+ /** Form state as the input expects it. */
1457
+ stateOf(local) {
1458
+ if (!this._native)
1459
+ return this.hasDate() ? local : `${new Date().toISOString().slice(0, 10)} ${local.slice(11)}`;
1460
+ if (!this.hasTime())
1461
+ return local.slice(0, 10);
1462
+ if (!this.hasDate())
1463
+ return local.slice(11, this._seconds ? 19 : 16);
1464
+ return local.slice(0, this._seconds ? 19 : 16);
1465
+ }
1466
+ /** Wall-clock value of the field's time zone to UTC. */
1467
+ toUtc(value) {
1468
+ if (!this._timezone || !this.hasDate() || !this.hasTime() || typeof value !== 'string')
1469
+ return value;
1470
+ const instant = zonedToUtc(value, this._timezone);
1471
+ return instant ? normalizeDateTime(instant) : value;
1472
+ }
1473
+ /** Saved value: in UTC, in `format()` (or the default format). */
1474
+ dehydrateState(state) {
1475
+ if (state === '' || state === null || state === undefined)
1476
+ return null;
1477
+ const normalized = normalizeDateTime(this.toUtc(typeof state === 'string' ? state.replace('T', ' ') : state));
1478
+ if (normalized === null)
1479
+ return state;
1480
+ if (this._format)
1481
+ return formatPhpDate(normalized, this._format);
1482
+ if (!this.hasTime())
1483
+ return normalized.slice(0, 10);
1484
+ if (!this.hasDate())
1485
+ return normalized.slice(11, this._seconds ? 19 : 16);
1486
+ return normalized;
1487
+ }
1488
+ defaultDisplayFormat() {
1489
+ if (!this.hasTime())
1490
+ return 'M j, Y';
1491
+ const time = this._seconds ? 'H:i:s' : 'H:i';
1492
+ return this.hasDate() ? `M j, Y ${time}` : time;
1493
+ }
1494
+ nativeType() { return !this.hasTime() ? 'date' : !this.hasDate() ? 'time' : 'datetime-local'; }
747
1495
  async toHtml() {
748
1496
  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']);
1497
+ const min = await this.limit(this._minDate), max = await this.limit(this._maxDate);
755
1498
  const wrapper = new Attributes()
756
1499
  .merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
757
1500
  .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>`);
1501
+ const model = this.wireModel();
1502
+ if (this._native) {
1503
+ const limit = (value) => (value === null ? null : !this.hasTime() ? value.slice(0, 10) : !this.hasDate() ? (value.length > 8 ? value.slice(11) : value) : value.replace(' ', 'T'));
1504
+ const input = new Attributes()
1505
+ .merge({
1506
+ 'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
1507
+ max: limit(max), min: limit(min), placeholder: this._temporalPlaceholder,
1508
+ required: (await this.isRequired()) && !isDisabled, step: this.hasTime() && this._seconds ? 1 : null, type: this.nativeType(), [model]: this.getStatePath(),
1509
+ })
1510
+ .class(['fi-input']);
1511
+ return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
1512
+ }
1513
+ const id = this.getId();
1514
+ const hasDate = this.hasDate(), hasTime = this.hasTime(), hasSeconds = this.hasSeconds();
1515
+ const disabledDates = (await evaluate(this._disabledDates, this.context())) ?? [];
1516
+ const jsLimit = (value) => (value === null ? '' : normalizeDateTime(value.length <= 8 ? `1970-01-01 ${value}` : value) ?? value);
1517
+ const livewireKey = this.getLivewireKey() ?? '';
1518
+ const label = await this.getLabel();
1519
+ const tr = (key) => t(`filament-forms::components.date_time_picker.${key}`);
1520
+ const describedBy = [this.getHelperTextId(), this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
1521
+ 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']);
1522
+ const config = `dateTimePickerFormComponent({
1523
+ defaultFocusedDate: ${js(this._defaultFocusedDate)},
1524
+ displayFormat: ${js(phpDateFormatToDayjs(this._displayFormat ?? this.defaultDisplayFormat()))},
1525
+ firstDayOfWeek: ${this._firstDayOfWeek},
1526
+ hasDate: ${hasDate ? 'true' : 'false'},
1527
+ isAutofocused: ${this._autofocus && !isDisabled ? 'true' : 'false'},
1528
+ locale: ${js(this._locale ?? 'en')},
1529
+ shouldCloseOnDateSelection: ${this._closeOnDateSelection ? 'true' : 'false'},
1530
+ state: $wire.$entangle(${js(this.getStatePath())}${this.isLive() ? ', true' : ''}),
1531
+ })`;
1532
+ const keyHash = createHash('md5').update(JSON.stringify([disabledDates, isDisabled, max, min, hasDate, hasTime, hasSeconds])).digest('hex');
1533
+ const events = hasDate
1534
+ ? ` 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()"`
1535
+ : ` 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()"`;
1536
+ const buttonEvents = isDisabled ? (!hasDate ? ' x-on:keydown.enter.prevent.stop="if (false) { isOpen() ? selectDate() : togglePanelVisibility() }"' : '') : [
1537
+ !hasDate ? ' x-on:keydown.enter.prevent.stop="if (true) { isOpen() ? selectDate() : togglePanelVisibility() }"' : '',
1538
+ ' x-on:click="togglePanelVisibility()"',
1539
+ 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()"',
1540
+ ' x-on:keydown.backspace.prevent.stop="clearState()" x-on:keydown.clear.prevent.stop="clearState()" x-on:keydown.delete.prevent.stop="clearState()"',
1541
+ ].join('');
1542
+ 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>` : '';
1543
+ 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}" />`;
1544
+ 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>` : '';
1545
+ 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>`;
1546
+ return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn">${picker}</div></div>`);
1547
+ }
1548
+ }
1549
+ /** Formats `YYYY-MM-DD HH:MM:SS` with a PHP date format (the usual tokens). */
1550
+ function formatPhpDate(value, format) {
1551
+ const [date, time] = value.split(' ');
1552
+ const [year, month, day] = date.split('-').map(Number);
1553
+ const [hour, minute, second] = (time ?? '00:00:00').split(':').map(Number);
1554
+ const tokens = {
1555
+ Y: () => String(year), y: () => pad(year % 100), m: () => pad(month), n: () => String(month), d: () => pad(day), j: () => String(day),
1556
+ H: () => pad(hour), G: () => String(hour), h: () => pad(hour % 12 || 12), g: () => String(hour % 12 || 12), i: () => pad(minute), s: () => pad(second),
1557
+ A: () => (hour < 12 ? 'AM' : 'PM'), a: () => (hour < 12 ? 'am' : 'pm'), U: () => String(Date.UTC(year, month - 1, day, hour, minute, second) / 1000),
1558
+ };
1559
+ let out = '';
1560
+ for (let index = 0; index < format.length; index++) {
1561
+ const char = format[index];
1562
+ if (char === '\\' && index + 1 < format.length) {
1563
+ out += format[++index];
1564
+ continue;
1565
+ }
1566
+ out += Object.hasOwn(tokens, char) ? tokens[char]() : char;
759
1567
  }
1568
+ return out;
1569
+ }
1570
+ /** Date picker: native `<input type="date">` by default; the saved state is `YYYY-MM-DD`. */
1571
+ export class DatePicker extends TemporalField {
1572
+ static make(name) { return configured(new DatePicker(name)); }
1573
+ hasDate() { return true; }
1574
+ hasTime() { return false; }
1575
+ }
1576
+ /** Time picker: native `<input type="time">` by default; the state is `HH:MM:SS` (`HH:MM` with `seconds(false)`). */
1577
+ export class TimePicker extends TemporalField {
1578
+ static make(name) { return configured(new TimePicker(name)); }
1579
+ hasDate() { return false; }
1580
+ hasTime() { return true; }
1581
+ }
1582
+ /** Date-time picker: native `datetime-local` input by default; the saved state is `YYYY-MM-DD HH:MM:SS`. */
1583
+ export class DateTimePicker extends TemporalField {
1584
+ static make(name) { return configured(new DateTimePicker(name)); }
1585
+ hasDate() { return true; }
1586
+ hasTime() { return true; }
760
1587
  }
761
1588
  /** Single choice as radio buttons. */
762
1589
  export class Radio extends Field {
@@ -771,8 +1598,16 @@ export class Radio extends Field {
771
1598
  /** Options side by side. */
772
1599
  inline(condition = true) { this._inline = condition; return this; }
773
1600
  live(condition = true) { this._live = condition; return this; }
1601
+ _disableOptionWhen = null;
1602
+ /** Options that cannot be picked; they are refused on the server too. */
1603
+ disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
1604
+ isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
1605
+ /** "Yes" (`1`) and "No" (`0`) options. */
1606
+ boolean(trueLabel = null, falseLabel = null) {
1607
+ return this.options([['1', trueLabel ?? t('filament-forms::components.radio.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.radio.boolean.false')]]);
1608
+ }
774
1609
  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.` }];
1610
+ 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
1611
  }
777
1612
  async toHtml() {
778
1613
  const id = this.getId();
@@ -783,7 +1618,7 @@ export class Radio extends Field {
783
1618
  .class(['fi-fo-radio', this._inline ? 'fi-inline' : null]);
784
1619
  const items = this._options.map(([value, label]) => {
785
1620
  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() })
1621
+ .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
1622
  .class(['fi-radio-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
788
1623
  const description = this._descriptions[value] ? `<p class="fi-fo-radio-label-description">${e(this._descriptions[value])}</p>` : '';
789
1624
  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 +1632,40 @@ export class Textarea extends Field {
797
1632
  static make(name) { return configured(new Textarea(name)); }
798
1633
  rows(rows) { this._rows = rows; return this; }
799
1634
  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.` }); }
1635
+ _minLength = null;
1636
+ _placeholder = null;
1637
+ _readOnly = false;
1638
+ _autosize = false;
1639
+ _grammarly = true;
1640
+ _trim = false;
1641
+ 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.` }); }
1642
+ /** Exact length. */
1643
+ length(length) { this.minLength(length); return this.maxLength(length); }
1644
+ placeholder(text) { this._placeholder = text; return this; }
1645
+ readOnly(condition = true) { this._readOnly = condition; return this; }
1646
+ /** The textarea grows with its content. */
1647
+ autosize(condition = true) { this._autosize = condition; return this; }
1648
+ /** Stops the Grammarly extension from analyzing the content. */
1649
+ disableGrammarly(condition = true) { this._grammarly = !condition; return this; }
1650
+ /** Removes the spaces around the value before it is validated and saved. */
1651
+ trim(condition = true) { this._trim = condition; return this; }
1652
+ hydrateState(value) { return this._trim && typeof value === 'string' ? value.trim() : value; }
1653
+ /** `trim()`: the submitted value is trimmed before it is validated and saved. */
1654
+ stateForValidation(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
1655
+ dehydrateState(state) { return this._trim && typeof state === 'string' ? state.trim() : state; }
800
1656
  async toHtml() {
801
1657
  const isDisabled = await this.isDisabled();
802
1658
  // Initial height: 1.5rem per row plus padding.
803
1659
  const height = (this._rows ?? 2) * 1.5 + 0.75;
804
1660
  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(),
1661
+ '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',
1662
+ disabled: isDisabled, id: this.getId(), maxlength: this._maxLength, minlength: this._minLength, placeholder: this._placeholder, readonly: this._readOnly,
1663
+ required: (await this.isRequired()) && !isDisabled && !this._readOnly, rows: this._rows, [this.wireModel()]: this.getStatePath(),
807
1664
  });
808
1665
  const wrapper = new Attributes().class(['fi-fo-textarea', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
809
1666
  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
1667
  initialHeight: ${height ?? 'null'},
811
- shouldAutosize: false,
1668
+ shouldAutosize: ${this._autosize ? 'true' : 'false'},
812
1669
  state: $wire.$entangle(${e(js(this.getStatePath()))}),
813
1670
  })" x-model="state" ${textarea.toHtml()}></textarea></div></div></div>`, { wrapperClass: 'fi-fo-textarea-wrp' });
814
1671
  }
@@ -824,9 +1681,28 @@ export class TagsInput extends Field {
824
1681
  suggestions(suggestions) { this._suggestions = suggestions.map(String).slice(0, 1000); return this; }
825
1682
  /** Keys that confirm the current tag, besides Enter (`['Tab', ',']`). */
826
1683
  splitKeys(keys) { this._splitKeys = keys; return this; }
1684
+ _separator = null;
1685
+ _tagPrefix = null;
1686
+ _tagSuffix = null;
1687
+ _reorderable = false;
1688
+ _tagColor = null;
1689
+ _tagRules = [];
1690
+ /** Saves the tags as one string joined with this separator (`'a,b,c'`) instead of a list. */
1691
+ separator(separator = ',') { this._separator = separator; return this; }
1692
+ /** Text shown before / after each tag (not saved). */
1693
+ tagPrefix(prefix) { this._tagPrefix = prefix; return this; }
1694
+ tagSuffix(suffix) { this._tagSuffix = suffix; return this; }
1695
+ /** Tags can be reordered by dragging. */
1696
+ reorderable(condition = true) { this._reorderable = condition; return this; }
1697
+ /** Badge color of the tags (`primary` by default). */
1698
+ color(color) { this._tagColor = color; return this; }
1699
+ /** Rules applied to each tag (e.g. a maximum length); the message names the tag. */
1700
+ 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
1701
  getDefaultState() { return this._default ?? []; }
828
1702
  hydrateState(value) {
829
- if (typeof value === 'string') {
1703
+ if (typeof value === 'string' && this._separator !== null)
1704
+ value = value === '' ? [] : value.split(this._separator);
1705
+ else if (typeof value === 'string') {
830
1706
  try {
831
1707
  value = JSON.parse(value);
832
1708
  }
@@ -834,10 +1710,23 @@ export class TagsInput extends Field {
834
1710
  value = value === '' ? [] : [value];
835
1711
  }
836
1712
  }
837
- return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map(String) : [];
1713
+ return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map((tag) => String(tag).trim()).filter((tag) => tag !== '') : [];
1714
+ }
1715
+ /** Only non-empty strings are saved, whatever shape the browser sends (joined with the separator, if any). */
1716
+ dehydrateState(state) {
1717
+ 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);
1718
+ return this._separator !== null ? tags.join(this._separator) : tags;
1719
+ }
1720
+ async getValidationRules() {
1721
+ if (!this._tagRules.length)
1722
+ return super.getValidationRules();
1723
+ return [...(await super.getValidationRules()), ...this._tagRules.map((rule) => ({
1724
+ ...rule,
1725
+ passes: async (value, ctx) => { for (const tag of Array.isArray(value) ? value : [])
1726
+ if (!(await rule.passes(tag, ctx)))
1727
+ return false; return true; },
1728
+ }))];
838
1729
  }
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
1730
  async toHtml() {
842
1731
  if (!colorManager)
843
1732
  throw new Error('No color manager: call useColorManager().');
@@ -850,14 +1739,15 @@ export class TagsInput extends Field {
850
1739
  const input = new Attributes()
851
1740
  .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
1741
  .class(['fi-input']);
853
- const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', 'primary')].join(' ');
1742
+ const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', this._tagColor ?? 'primary'), this._reorderable && !isDisabled ? 'fi-reorderable' : null].filter(Boolean).join(' ');
1743
+ const reorderable = this._reorderable && !isDisabled;
854
1744
  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
1745
  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
1746
  state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
857
1747
  splitKeys: ${this._splitKeys.length ? e(js(this._splitKeys)) : '[]'},
858
1748
  tagAddedMessage: ${e(js(t('filament-forms::components.tags_input.tag_added')))},
859
1749
  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' });
1750
+ })"><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
1751
  }
862
1752
  }
863
1753
  /** On/off switch. */
@@ -865,14 +1755,32 @@ export class Toggle extends Field {
865
1755
  static make(name) { return configured(new Toggle(name)); }
866
1756
  getDefaultState() { return this._default ?? false; }
867
1757
  hydrateState(value) { return value === true || value === 1 || value === '1'; }
1758
+ _onIcon = null;
1759
+ _offIcon = null;
1760
+ _onColor = null;
1761
+ _offColor = null;
1762
+ _inline = true;
1763
+ /** Icons inside the switch's knob. */
1764
+ onIcon(icon) { this._onIcon = icon; return this; }
1765
+ offIcon(icon) { this._offIcon = icon; return this; }
1766
+ /** Colors when on (`primary` by default) and off (`gray`). */
1767
+ onColor(color) { this._onColor = color; return this; }
1768
+ offColor(color) { this._offColor = color; return this; }
1769
+ /** `false`: the label goes above the switch instead of beside it. */
1770
+ inline(condition = true) { this._inline = condition; return this; }
868
1771
  async toHtml() {
869
1772
  if (!colorManager)
870
1773
  throw new Error('No color manager: call useColorManager().');
871
1774
  const on = this.context().state === true;
872
- const colors = colorManager.getComponentClasses('toggle', 'primary').join(' ');
1775
+ const colors = colorManager.getComponentClasses('toggle', this._onColor ?? 'primary').join(' ');
1776
+ const offColors = this._offColor && this._offColor !== 'gray' ? ` ${colorManager.getComponentClasses('toggle', this._offColor).join(' ')}` : '';
1777
+ const onIconHtml = this._onIcon ? icon(this._onIcon, { size: 'xs' }) : '';
1778
+ const offIconHtml = this._offIcon ? icon(this._offIcon, { size: 'xs' }) : '';
873
1779
  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>` : ''}` });
1780
+ const knob = `<div><div aria-hidden="true">${offIconHtml}</div><div aria-hidden="true">${onIconHtml}</div></div>`;
1781
+ const placeholderKnob = `<div><div aria-hidden="true"></div><div aria-hidden="true">${onIconHtml}</div></div>`;
1782
+ 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>` : ''}`;
1783
+ return this._inline ? this.wrap('', { labelPrefix: control }) : this.wrap(control);
876
1784
  }
877
1785
  }
878
1786
  /** Single choice shown as buttons. */
@@ -895,8 +1803,59 @@ export class ToggleButtons extends Field {
895
1803
  inline(condition = true) { this._inline = condition; return this; }
896
1804
  /** Buttons joined into a single group. */
897
1805
  grouped(condition = true) { this._grouped = condition; return this; }
1806
+ _multiple = false;
1807
+ _tooltips = {};
1808
+ _size = null;
1809
+ _labelsHidden = false;
1810
+ _fullWidth = false;
1811
+ _columns = null;
1812
+ _disableOptionWhen = null;
1813
+ /** Several buttons can be selected: the state is a list of values. */
1814
+ multiple(condition = true) { this._multiple = condition; return this; }
1815
+ /** Tooltip per value. */
1816
+ tooltips(tooltips) { this._tooltips = tooltips; return this; }
1817
+ /** Button size: `xs`, `sm`, `md` (default), `lg` or `xl`. */
1818
+ size(size) { this._size = size; return this; }
1819
+ /** Icons only: the labels stay for screen readers. */
1820
+ hiddenButtonLabels(condition = true) { this._labelsHidden = condition; return this; }
1821
+ /** Buttons stretch to the width of the field. */
1822
+ fullWidth(condition = true) { this._fullWidth = condition; return this; }
1823
+ /** Number of columns the buttons are spread over (from `lg`), when not inline. */
1824
+ columns(columns) { this._columns = columns; return this; }
1825
+ /** Options that cannot be selected; they are refused on the server too. */
1826
+ disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
1827
+ isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
1828
+ /** "Yes" (`1`) and "No" (`0`) buttons, green and red, with check and cross icons. */
1829
+ boolean(trueLabel = null, falseLabel = null) {
1830
+ this.options([['1', trueLabel ?? t('filament-forms::components.toggle_buttons.boolean.true')], ['0', falseLabel ?? t('filament-forms::components.toggle_buttons.boolean.false')]]);
1831
+ this._colors = { 1: 'success', 0: 'danger', ...this._colors };
1832
+ this._icons = { 1: 'o-check', 0: 'o-x-mark', ...this._icons };
1833
+ return this;
1834
+ }
1835
+ getDefaultState() { return this._default ?? (this._multiple ? [] : null); }
1836
+ hydrateState(value) {
1837
+ if (!this._multiple)
1838
+ return value;
1839
+ if (typeof value === 'string') {
1840
+ try {
1841
+ value = JSON.parse(value);
1842
+ }
1843
+ catch {
1844
+ value = value === '' ? [] : [value];
1845
+ }
1846
+ }
1847
+ return (Array.isArray(value) ? value : []).map(String);
1848
+ }
1849
+ /** Multiple: only offered values, in option order. */
1850
+ dehydrateState(state) {
1851
+ if (!this._multiple)
1852
+ return state;
1853
+ const chosen = new Set((Array.isArray(state) ? state : []).map(String));
1854
+ return this._options.map(([value]) => value).filter((value) => chosen.has(value));
1855
+ }
898
1856
  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.` }];
1857
+ const allowed = (v) => this._options.some(([value, label]) => value === String(v) && !this.isOptionDisabled(value, label));
1858
+ 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
1859
  }
901
1860
  async toHtml() {
902
1861
  if (!colorManager)
@@ -907,16 +1866,22 @@ export class ToggleButtons extends Field {
907
1866
  const describedBy = [isRequired ? `${id}-required` : null, this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
908
1867
  const container = new Attributes()
909
1868
  .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]);
1869
+ .class(['fi-fo-toggle-buttons', this._grouped ? 'fi-btn-group' : null, !this._grouped && this._inline ? 'fi-inline' : null, { 'fi-width-full': this._fullWidth }]);
1870
+ if (this._multiple)
1871
+ container.merge({ role: 'group', 'aria-required': null });
1872
+ 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
1873
  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(' ');
1874
+ 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
1875
  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>`;
1876
+ const optionDisabled = this.isOptionDisabled(value, label);
1877
+ 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']);
1878
+ const tooltip = this._tooltips[value] ? ` x-tooltip="${e(`{ content: ${js(this._tooltips[value])}, theme: $store.theme, allowHTML: false }`)}"` : '';
1879
+ const iconSize = this._size === 'xs' || this._size === 'sm' ? 'sm' : undefined;
1880
+ 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
1881
  return this._grouped ? html : `<div class="fi-fo-toggle-buttons-btn-ctn">${html}</div>`;
917
1882
  });
918
1883
  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 });
1884
+ 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
1885
  }
921
1886
  }
922
1887
  // Server-side TipTap schema: the nodes and marks the rich editor's default toolbar can produce.
@@ -944,13 +1909,87 @@ export function richDocumentToHtml(document) {
944
1909
  }
945
1910
  /**
946
1911
  * Rich text editor: the browser works on a TipTap document, the database receives HTML.
947
- * Not supported yet: attachments, custom blocks, mentions.
1912
+ * Not supported yet: custom blocks, merge tags, mentions, text colors.
948
1913
  */
1914
+ /**
1915
+ * Stores a file uploaded by an editor (`componentFileAttachments.<state path>[.<id>]`): type checked on the file's content, size,
1916
+ * then stored; the reference is consumed. Returns the public URL (local or http(s) only).
1917
+ */
1918
+ async function storeEditorAttachment(field, id, options) {
1919
+ if (!options.storage)
1920
+ return null;
1921
+ const livewire = field.livewire;
1922
+ const segments = field.getStatePath().split('.');
1923
+ const holder = readPath(livewire.componentFileAttachments ?? {}, id === null ? segments.slice(0, -1) : segments);
1924
+ const name = id ?? segments[segments.length - 1];
1925
+ if (!holder || typeof holder !== 'object' || !Object.hasOwn(holder, name))
1926
+ return null;
1927
+ const uploads = livewire._runtime?.uploads;
1928
+ const file = uploads?.resolve(holder[name]);
1929
+ delete holder[name];
1930
+ if (!file || !options.types.includes(file.type) || file.size > options.maxSize * 1024)
1931
+ return null;
1932
+ const key = await options.storage.store(file, options.directory);
1933
+ uploads.remove(file.id);
1934
+ const info = await options.storage.info(key);
1935
+ return info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info.url : null;
1936
+ }
1937
+ /** Tools of the rich editor's toolbar, in their default groups. */
1938
+ 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']];
949
1939
  export class RichEditor extends Field {
1940
+ _toolbar = null;
1941
+ _disabledTools = [];
1942
+ _json = false;
1943
+ _richPlaceholder = null;
1944
+ _attachmentsStorage = null;
1945
+ _attachmentsDirectory = null;
1946
+ _attachmentTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
1947
+ _attachmentMaxSize = 12288;
950
1948
  static make(name) { return configured(new RichEditor(name)); }
951
- hydrateState(value) { return isRichDocument(value) ? value : richHtmlToDocument(typeof value === 'string' ? value : ''); }
1949
+ /** Toolbar tools, by group (`[['bold', 'italic', 'link'], ['h2', 'h3'], ['undo', 'redo']]`); see `RICH_EDITOR_TOOLS`. */
1950
+ toolbarButtons(groups) {
1951
+ const list = (groups.length && typeof groups[0] === 'string' ? [groups] : groups);
1952
+ const known = new Set(RICH_EDITOR_TOOLS.flat());
1953
+ const unknown = list.flat().filter((tool) => !known.has(tool));
1954
+ if (unknown.length)
1955
+ throw new Error(`RichEditor: unknown toolbar buttons ${unknown.join(', ')} (available: ${[...known].join(', ')}).`);
1956
+ this._toolbar = list;
1957
+ return this;
1958
+ }
1959
+ /** Removes buttons; with no argument, removes them all. */
1960
+ disableToolbarButtons(tools = []) { if (!tools.length)
1961
+ this._toolbar = []; this._disabledTools = [...this._disabledTools, ...tools]; return this; }
1962
+ disableAllToolbarButtons(condition = true) { if (condition)
1963
+ this._toolbar = []; return this; }
1964
+ /** Saves the content as the editor's JSON document instead of HTML. */
1965
+ json(condition = true) { this._json = condition; return this; }
1966
+ placeholder(text) { this._richPlaceholder = text; return this; }
1967
+ /** Where files attached in the editor are stored (`FileUpload.storage` by default); without storage, attaching is off. */
1968
+ fileAttachmentsStorage(storage) { this._attachmentsStorage = storage; return this; }
1969
+ fileAttachmentsDirectory(directory) { this._attachmentsDirectory = directory; return this; }
1970
+ /** Accepted types of attached files, checked on the file's content (images only: they are inserted as images). */
1971
+ fileAttachmentsAcceptedFileTypes(types) { this._attachmentTypes = types.filter((type) => type.startsWith('image/') && type !== 'image/svg+xml'); return this; }
1972
+ /** Maximum size of attached files, in kilobytes. */
1973
+ fileAttachmentsMaxSize(kilobytes) { this._attachmentMaxSize = kilobytes; return this; }
1974
+ attachmentsStorage() { return this._attachmentsStorage ?? FileUpload.storage; }
1975
+ tools() { return (this._toolbar ?? RICH_EDITOR_TOOLS).map((group) => group.filter((tool) => !this._disabledTools.includes(tool) && (tool !== 'attachFiles' || this.attachmentsStorage() !== null))).filter((group) => group.length); }
1976
+ hydrateState(value) {
1977
+ if (typeof value === 'string' && value.trim().startsWith('{')) {
1978
+ try {
1979
+ const parsed = JSON.parse(value);
1980
+ if (isRichDocument(parsed))
1981
+ return parsed;
1982
+ }
1983
+ catch { /* HTML */ }
1984
+ }
1985
+ return isRichDocument(value) ? value : richHtmlToDocument(typeof value === 'string' ? value : '');
1986
+ }
952
1987
  getDefaultState() { return richHtmlToDocument(typeof this._default === 'string' ? this._default : ''); }
953
- dehydrateState(state) { return richDocumentToHtml(state); }
1988
+ /** HTML (sanitized by the editor's schema), or the JSON document with `json()` (normalized through the same schema). */
1989
+ dehydrateState(state) {
1990
+ const html = richDocumentToHtml(state);
1991
+ return this._json ? (html === null ? null : richHtmlToDocument(html)) : html;
1992
+ }
954
1993
  async getValidationRules() {
955
1994
  const rules = [];
956
1995
  if (await this.isRequired())
@@ -961,8 +2000,18 @@ export class RichEditor extends Field {
961
2000
  getMentionSearchResultsForJs() { return []; }
962
2001
  getMentionLabelsForJs() { return []; }
963
2002
  getCustomBlockPreviewsForJs() { return []; }
964
- getUploadedFileAttachmentTemporaryUrl() { return null; }
965
- saveUploadedFileAttachmentAndGetUrl() { return null; }
2003
+ /**
2004
+ * URL of a file the editor just uploaded: the file is checked (type from its content, size), stored, and its public URL returned.
2005
+ * It stays stored even if the form is not saved.
2006
+ */
2007
+ async getUploadedFileAttachmentTemporaryUrl(args = {}) {
2008
+ const id = String(args?.attachment ?? '');
2009
+ const storage = this.attachmentsStorage();
2010
+ if (!storage || !/^[A-Za-z0-9_-]{1,64}$/.test(id) || !this.tools().flat().includes('attachFiles') || (await this.isDisabled()))
2011
+ return null;
2012
+ return storeEditorAttachment(this, id, { storage, directory: this._attachmentsDirectory, types: this._attachmentTypes, maxSize: this._attachmentMaxSize });
2013
+ }
2014
+ async saveUploadedFileAttachmentAndGetUrl(args = {}) { return this.getUploadedFileAttachmentTemporaryUrl(args); }
966
2015
  /** Toolbar "link" action: a modal (URL, new tab), then commands sent back to the browser editor. */
967
2016
  getActions() {
968
2017
  const tr = (name) => t(`filament-forms::components.rich_editor.actions.link.${name}`);
@@ -990,17 +2039,57 @@ export class RichEditor extends Field {
990
2039
  ];
991
2040
  }
992
2041
  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 });
2042
+ let html = views.render('filament-forms::components.rich-editor', { livewireId: this.livewire.id, key: this.getKey(), statePath: this.getStatePath(), label: await this.getLabel(), js });
2043
+ // Toolbar: the snapshot's buttons, regrouped as configured.
2044
+ const toolbarStart = html.indexOf('data-rich-editor-toolbar="main"');
2045
+ if (toolbarStart >= 0) {
2046
+ const open = html.indexOf('>', toolbarStart) + 1;
2047
+ // The toolbar ends right before the "uploading file" message.
2048
+ const message = html.indexOf('x-show="isUploadingFile"', open);
2049
+ const close = message < 0 ? -1 : html.lastIndexOf('<div', message);
2050
+ if (close < 0)
2051
+ throw new Error('RichEditor: unexpected view markup (toolbar end not found).');
2052
+ const body = html.slice(open, close);
2053
+ const buttons = new Map();
2054
+ for (const match of body.matchAll(/<button data-rich-editor-tool="([A-Za-z0-9]+)"[\s\S]*?<\/button>/g))
2055
+ buttons.set(match[1], match[0]);
2056
+ const groups = this.tools().map((group) => `<div class="fi-fo-rich-editor-toolbar-group">${group.map((tool) => buttons.get(tool) ?? '').join('')}</div>`).join('');
2057
+ const endOfToolbar = body.lastIndexOf('</div>');
2058
+ html = html.slice(0, open) + groups + body.slice(endOfToolbar) + html.slice(close);
2059
+ }
2060
+ const attach = this.tools().flat().includes('attachFiles');
2061
+ const tr = (key, replace = {}) => t(`filament-forms::components.rich_editor.${key}`, replace);
2062
+ html = html.replace('canAttachFiles: true,', `canAttachFiles: ${attach ? 'true' : 'false'},`)
2063
+ .replace('maxFileSize: 12288,', `maxFileSize: ${this._attachmentMaxSize},`)
2064
+ .replace(/maxFileSizeValidationMessage: '[^']*',/, () => `maxFileSizeValidationMessage: ${js(`Uploaded files must not be greater than ${this._attachmentMaxSize} kilobytes.`)},`)
2065
+ .replace(/acceptedFileTypes: JSON\.parse\('[^']*'\),/, () => `acceptedFileTypes: ${js(this._attachmentTypes)},`)
2066
+ .replace(/acceptedFileTypesValidationMessage: '[^']*',/, () => `acceptedFileTypesValidationMessage: ${js(`Uploaded files must be of type: ${this._attachmentTypes.join(', ')}.`)},`)
2067
+ .replace('placeholder: null,', `placeholder: ${js(this._richPlaceholder)},`)
2068
+ .replace('isLiveOnBlur: false,', `isLiveOnBlur: ${this._liveMode === 'blur' ? 'true' : 'false'},`)
2069
+ .replace(/state: \$wire\.\$entangle\(('[^']*'), false\)/, (_m, path) => `state: $wire.$entangle(${path}, ${this._liveMode === 'live' ? 'true' : 'false'})`);
2070
+ void tr;
2071
+ return this.wrap(html, { labelAsDiv: true });
994
2072
  }
995
2073
  }
996
2074
  /** Color picker (hex format). */
997
2075
  export class ColorPicker extends Field {
998
2076
  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.` });
2077
+ _format = 'hex';
2078
+ /** Color format of the state: `#ff0000` (default), `rgb(255, 0, 0)`, `rgba(255, 0, 0, 1)` or `hsl(0, 100%, 50%)`. */
2079
+ format(format) { this._format = format; return this; }
2080
+ hex() { return this.format('hex'); }
2081
+ rgb() { return this.format('rgb'); }
2082
+ rgba() { return this.format('rgba'); }
2083
+ hsl() { return this.format('hsl'); }
2084
+ async getValidationRules() {
2085
+ const patterns = {
2086
+ hex: /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/,
2087
+ rgb: /^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/,
2088
+ rgba: /^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0|1|0?\.\d+)\s*\)$/,
2089
+ hsl: /^hsl\(\s*\d{1,3}(\.\d+)?\s*,\s*\d{1,3}(\.\d+)?%\s*,\s*\d{1,3}(\.\d+)?%\s*\)$/,
2090
+ };
2091
+ const pattern = patterns[this._format];
2092
+ 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
2093
  }
1005
2094
  async toHtml() {
1006
2095
  const isDisabled = await this.isDisabled();
@@ -1024,17 +2113,20 @@ export class ColorPicker extends Field {
1024
2113
  state: $wire.$entangle(${e(js(this.getStatePath()))}),
1025
2114
  })" 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
2115
  '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>`);
2116
+ }" 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
2117
  }
1029
2118
  }
1030
2119
  export class Checkbox extends Field {
2120
+ _inline = true;
1031
2121
  static make(name) { return configured(new Checkbox(name)); }
1032
2122
  getDefaultState() { return this._default ?? false; }
2123
+ /** `false`: the label goes above the checkbox instead of beside it. */
2124
+ inline(condition = true) { this._inline = condition; return this; }
1033
2125
  async toHtml() {
1034
2126
  const input = new Attributes()
1035
- .merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(), 'wire:model': this.getStatePath() })
2127
+ .merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(), [this.wireModel()]: this.getStatePath() })
1036
2128
  .class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
1037
- return this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` });
2129
+ return this._inline ? this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` }) : this.wrap(`<input ${input.toHtml()} />`);
1038
2130
  }
1039
2131
  }
1040
2132
  /** Checkboxes for a list of values (no search or "select all" yet). */
@@ -1045,6 +2137,23 @@ export class CheckboxList extends Field {
1045
2137
  options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
1046
2138
  /** Number of columns the options are spread over (from `lg`). */
1047
2139
  columns(columns) { this._optionColumns = columns; return this; }
2140
+ _descriptions = {};
2141
+ _searchable = false;
2142
+ _bulkToggleable = false;
2143
+ _disableOptionWhen = null;
2144
+ /** Helper text per value, under the option label. */
2145
+ descriptions(descriptions) { this._descriptions = descriptions; return this; }
2146
+ /** A search field filters the options in the browser. */
2147
+ searchable(condition = true) { this._searchable = condition; return this; }
2148
+ _searchPrompt = 'Search';
2149
+ _noSearchResultsMessage = 'No search results.';
2150
+ searchPrompt(prompt) { this._searchPrompt = prompt; return this; }
2151
+ noSearchResultsMessage(message) { this._noSearchResultsMessage = message; return this; }
2152
+ /** "Select all" / "Deselect all" links. */
2153
+ bulkToggleable(condition = true) { this._bulkToggleable = condition; return this; }
2154
+ /** Options that cannot be checked; they are refused on the server too. */
2155
+ disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
2156
+ isOptionDisabled(value, label) { return this._disableOptionWhen ? this._disableOptionWhen(value, label) : false; }
1048
2157
  getDefaultState() { return this._default ?? []; }
1049
2158
  hydrateState(value) {
1050
2159
  let list = value;
@@ -1064,21 +2173,28 @@ export class CheckboxList extends Field {
1064
2173
  return this._options.map(([value]) => value).filter((value) => chosen.has(value));
1065
2174
  }
1066
2175
  async getValidationRules() {
1067
- const known = new Set(this._options.map(([value]) => value));
2176
+ // A disabled option cannot be checked, even by a forged request.
2177
+ const known = new Set(this._options.filter(([value, label]) => !this.isOptionDisabled(value, label)).map(([value]) => value));
1068
2178
  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
2179
  }
1070
2180
  async toHtml() {
1071
2181
  const isDisabled = await this.isDisabled();
1072
2182
  const key = this.getLivewireKey() ?? '';
1073
2183
  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>`;
2184
+ 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']);
2185
+ const description = this._descriptions[value] ? `<p class="fi-fo-checkbox-list-option-description">${e(this._descriptions[value])}</p>` : '';
2186
+ 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
2187
  });
1077
2188
  const columns = this._optionColumns ?? 1;
1078
2189
  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;"';
2190
+ const searchPrompt = this._searchPrompt;
2191
+ 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>` : '';
2192
+ 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>`;
2193
+ 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>` : '';
2194
+ 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
2195
  return this.wrap(`<div role="group" x-load x-load-src="${e(asset('checkbox-list'))}" x-data="checkboxListFormComponent({
1080
2196
  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 });
2197
+ })" 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
2198
  }
1083
2199
  }
1084
2200
  /** Free-form key/value pairs, saved as an object. */
@@ -1090,6 +2206,26 @@ export class KeyValue extends Field {
1090
2206
  keyLabel(label) { this._keyLabel = label; return this; }
1091
2207
  valueLabel(label) { this._valueLabel = label; return this; }
1092
2208
  reorderable(condition = true) { this._reorderable = condition; return this; }
2209
+ _addable = true;
2210
+ _deletable = true;
2211
+ _editableKeys = true;
2212
+ _editableValues = true;
2213
+ _keyPlaceholder = null;
2214
+ _valuePlaceholder = null;
2215
+ _addLabel = null;
2216
+ _deleteLabel = null;
2217
+ /** `false`: no "Add row" button. */
2218
+ addable(condition = true) { this._addable = condition; return this; }
2219
+ /** `false`: rows cannot be deleted. */
2220
+ deletable(condition = true) { this._deletable = condition; return this; }
2221
+ /** `false`: keys cannot be changed. The server keeps the saved keys. */
2222
+ editableKeys(condition = true) { this._editableKeys = condition; return this; }
2223
+ /** `false`: values cannot be changed. */
2224
+ editableValues(condition = true) { this._editableValues = condition; return this; }
2225
+ keyPlaceholder(text) { this._keyPlaceholder = text; return this; }
2226
+ valuePlaceholder(text) { this._valuePlaceholder = text; return this; }
2227
+ addActionLabel(label) { this._addLabel = label; return this; }
2228
+ deleteActionLabel(label) { this._deleteLabel = label; return this; }
1093
2229
  // In the form, the state is a list of `{ key, value }` pairs (what the browser component expects, and it keeps order);
1094
2230
  // it becomes an object again when saved.
1095
2231
  getDefaultState() { return KeyValue.toPairs(this._default); }
@@ -1105,10 +2241,29 @@ export class KeyValue extends Field {
1105
2241
  }
1106
2242
  return object && typeof object === 'object' && !Array.isArray(object) ? Object.entries(object).map(([key, v]) => ({ key, value: v })) : [];
1107
2243
  }
1108
- hydrateState(value) { return KeyValue.toPairs(value); }
2244
+ _originalValue = null;
2245
+ hydrateState(value) { this._originalValue = value; return KeyValue.toPairs(value); }
1109
2246
  /** Flat object of strings: empty keys, nested objects and dangerous keys are dropped; 200 pairs at most. */
1110
2247
  dehydrateState(state) {
1111
2248
  const out = {};
2249
+ // Restrictions set on the field are enforced on the server: a forged state cannot add, delete or edit what the UI forbids.
2250
+ // The saved value: the one hydrated by this instance, else the record's (fields are rebuilt on every request), else the default.
2251
+ const record = this.context().record;
2252
+ const saved = KeyValue.toPairs(this._originalValue ?? (record && typeof record === 'object' ? record[this._name] : null) ?? this._default);
2253
+ if (!this._addable || !this._deletable || !this._editableKeys || !this._editableValues) {
2254
+ const received = Array.isArray(state) ? state.filter((pair) => pair && typeof pair === 'object' && typeof pair.key === 'string') : [];
2255
+ const byKey = new Map(received.map((pair) => [String(pair.key).trim(), pair]));
2256
+ 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 }));
2257
+ if (!this._editableValues)
2258
+ rows = rows.map((row) => ({ ...row, value: saved.find((pair) => pair.key === row.key)?.value ?? null }));
2259
+ if (!this._addable)
2260
+ rows = rows.filter((row) => saved.some((pair) => pair.key === row.key));
2261
+ if (!this._deletable)
2262
+ for (const pair of saved)
2263
+ if (!rows.some((row) => row.key === pair.key))
2264
+ rows.push({ key: pair.key, value: byKey.get(pair.key)?.value ?? pair.value });
2265
+ state = rows;
2266
+ }
1112
2267
  if (!Array.isArray(state))
1113
2268
  return out;
1114
2269
  for (const pair of state.slice(0, 200)) {
@@ -1134,7 +2289,7 @@ export class KeyValue extends Field {
1134
2289
  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
2290
  state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
1136
2291
  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' });
2292
+ })" 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
2293
  }
1139
2294
  }
1140
2295
  /** Developer-written JavaScript expression (e.g. a Slider tooltip formatter), inserted as is: never put user data in it. */
@@ -1153,10 +2308,10 @@ function accessibilityOf(field, options = {}) {
1153
2308
  }
1154
2309
  /** Second `$entangle()` argument: true only for a plain `live()` field (no `onBlur`, no `debounce`). */
1155
2310
  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']];
2311
+ const MARKDOWN_TOOLBAR = [['bold', 'italic', 'strike', 'link'], ['heading'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table', 'attachFiles'], ['undo', 'redo']];
1157
2312
  /**
1158
2313
  * 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()`),
2314
+ * Files can be attached (images, stored on upload) when a storage is set (`fileAttachmentsStorage()` or `FileUpload.storage`); when read-only (`disabled()`),
1160
2315
  * the text is shown escaped, without Markdown conversion.
1161
2316
  */
1162
2317
  export class MarkdownEditor extends Field {
@@ -1191,9 +2346,31 @@ export class MarkdownEditor extends Field {
1191
2346
  async getValidationRules() {
1192
2347
  return [...(await super.getValidationRules()), { name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }];
1193
2348
  }
2349
+ _attachmentsStorage = null;
2350
+ _attachmentsDirectory = null;
2351
+ _attachmentTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
2352
+ _attachmentMaxSize = 12288;
2353
+ /** Where attached files are stored (`FileUpload.storage` by default); without storage, the "Attach files" button is hidden. */
2354
+ fileAttachmentsStorage(storage) { this._attachmentsStorage = storage; return this; }
2355
+ fileAttachmentsDirectory(directory) { this._attachmentsDirectory = directory; return this; }
2356
+ /** Accepted types of attached files (images), checked on the file's content. */
2357
+ fileAttachmentsAcceptedFileTypes(types) { this._attachmentTypes = types.filter((type) => type.startsWith('image/') && type !== 'image/svg+xml'); return this; }
2358
+ /** Maximum size of attached files, in kilobytes. */
2359
+ fileAttachmentsMaxSize(kilobytes) { this._attachmentMaxSize = kilobytes; return this; }
2360
+ canAttachFiles() { return this.hasToolbarButton('attachFiles') && (this._attachmentsStorage ?? FileUpload.storage) !== null; }
2361
+ visibleToolbar() {
2362
+ if (this.canAttachFiles())
2363
+ return this._toolbarButtons;
2364
+ return this._toolbarButtons.map((group) => (Array.isArray(group) ? group.filter((name) => name !== 'attachFiles') : group)).filter((group) => (Array.isArray(group) ? group.length > 0 : group !== 'attachFiles'));
2365
+ }
1194
2366
  getExposedMethods() { return ['getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
1195
2367
  getUploadedFileAttachmentTemporaryUrl() { return null; }
1196
- saveUploadedFileAttachmentAndGetUrl() { return null; }
2368
+ /** Stores the file just uploaded to `componentFileAttachments.<state path>` and returns its URL. */
2369
+ async saveUploadedFileAttachmentAndGetUrl() {
2370
+ if (!this.canAttachFiles() || (await this.isDisabled()))
2371
+ return null;
2372
+ return storeEditorAttachment(this, null, { storage: this._attachmentsStorage ?? FileUpload.storage, directory: this._attachmentsDirectory, types: this._attachmentTypes, maxSize: this._attachmentMaxSize });
2373
+ }
1197
2374
  async toHtml() {
1198
2375
  const id = this.getId();
1199
2376
  const accessibility = accessibilityOf(this);
@@ -1214,7 +2391,7 @@ export class MarkdownEditor extends Field {
1214
2391
  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
2392
  };
1216
2393
  const config = `markdownEditorFormComponent({
1217
- canAttachFiles: false,
2394
+ canAttachFiles: ${this.canAttachFiles() ? 'true' : 'false'},
1218
2395
  isLiveDebounced: ${this._live?.debounce !== null && this._live?.debounce !== undefined ? 'true' : 'false'},
1219
2396
  isLiveOnBlur: ${this._live?.onBlur ? 'true' : 'false'},
1220
2397
  label: ${js(await this.getLabel())},
@@ -1223,10 +2400,26 @@ export class MarkdownEditor extends Field {
1223
2400
  minHeight: ${js(this._minHeight)},
1224
2401
  placeholder: ${js(this._placeholder)},
1225
2402
  state: $wire.$entangle(${js(this.getStatePath())}, ${entangleLive(this._live)}),
1226
- toolbarButtons: ${js(this._toolbarButtons)},
2403
+ toolbarButtons: ${js(this.visibleToolbar())},
1227
2404
  translations: ${js(translations)},
1228
2405
  uploadFileAttachmentUsing: async (file, onSuccess, onError) => {
2406
+ const acceptedTypes = ${js(this._attachmentTypes)}
2407
+ if (acceptedTypes && ! acceptedTypes.includes(file.type)) {
2408
+ return onError(${js(tr('file_attachments_accepted_file_types_message').replace(':values', this._attachmentTypes.join(', ')))})
2409
+ }
2410
+ const maxSize = ${js(this._attachmentMaxSize)}
2411
+ if (maxSize && file.size > +maxSize * 1024) {
2412
+ return onError(${js(tr('file_attachments_max_size_message').replace(':max', String(this._attachmentMaxSize)))})
2413
+ }
2414
+ $wire.upload(${js(`componentFileAttachments.${this.getStatePath()}`)}, file, () => {
2415
+ $saveUploadedFileAttachmentAndGetUrl()
2416
+ .then((url) => {
2417
+ if (! url) {
1229
2418
  return onError()
2419
+ }
2420
+ onSuccess(url)
2421
+ })
2422
+ })
1230
2423
  },
1231
2424
  })`;
1232
2425
  const wrapper = new Attributes().class(['fi-fo-markdown-editor', 'fi-input-wrp', { 'fi-invalid': this.hasError() }]);
@@ -1508,6 +2701,26 @@ export function fillSchemaFromRecord(schema, target, record) {
1508
2701
  writePath(target, segments, c.hydrateState(readPath(record, segments) ?? c.getDefaultState()));
1509
2702
  }
1510
2703
  }
2704
+ /** Fills the fields that load their state from the record's relationships (`loadStateFromRelationshipsUsing()`, `Select::relationship()`…). */
2705
+ export async function loadSchemaRelationships(schema, target, record) {
2706
+ if (!record)
2707
+ return;
2708
+ for (const c of schema.getFlatComponents()) {
2709
+ if (!(c instanceof Field))
2710
+ continue;
2711
+ const loaded = await c.loadStateFromRelationships(record);
2712
+ if (loaded)
2713
+ writePath(target, relativePath(schema, c), c.hydrateState(loaded.state));
2714
+ }
2715
+ }
2716
+ /** Saves the visible fields that write the record's relationships, once the record is created or updated. */
2717
+ export async function saveSchemaRelationships(schema, record) {
2718
+ if (!record)
2719
+ return;
2720
+ for (const c of await schema.getVisibleFlatComponents())
2721
+ if (c instanceof Field && (await c.isDehydrated() || c._dehydrated === false) && (await c.savesRelationships()))
2722
+ await c.saveRelationships(record);
2723
+ }
1511
2724
  /** Validates every visible field of a schema; returns errors by state path. */
1512
2725
  export async function validateSchema(schema) {
1513
2726
  const errors = {};
@@ -1524,11 +2737,21 @@ export async function validateSchema(schema) {
1524
2737
  export async function getSchemaState(schema) {
1525
2738
  const data = {};
1526
2739
  const fields = [];
2740
+ const skipped = [];
1527
2741
  for (const c of await schema.getVisibleFlatComponents()) {
1528
2742
  if (!(c instanceof Field) || !(await c.isDehydrated()))
1529
2743
  continue;
2744
+ // Relationship fields (and the fields of their items) save themselves after the record (see saveSchemaRelationships), unless forced with `dehydrated()`.
2745
+ if (skipped.some((path) => c.getStatePath().startsWith(`${path}.`)))
2746
+ continue;
2747
+ if (c._dehydrated !== true && (await c.savesRelationships())) {
2748
+ skipped.push(c.getStatePath());
2749
+ continue;
2750
+ }
1530
2751
  const state = await c.dehydrateState(c.context().state);
1531
2752
  writePath(data, relativePath(schema, c), state === '' || state === undefined ? null : state);
2753
+ for (const [name, value] of Object.entries(c.getExtraDehydratedState?.() ?? {}))
2754
+ writePath(data, [...relativePath(schema, c).slice(0, -1), name], value);
1532
2755
  fields.push(c);
1533
2756
  }
1534
2757
  // Deepest first: a container sees its children already shaped.
@@ -1716,6 +2939,60 @@ export class Wizard extends Component {
1716
2939
  }
1717
2940
  }
1718
2941
  /** Local disk storage, served under a public URL. File names are random; the extension comes from the detected type. */
2942
+ /**
2943
+ * A file name chosen by the application or the user, made safe for storage: no directory part, only letters, digits, `_` and
2944
+ * `-`, and always the extension detected from the file's content (an uploaded `shell.php` declared as an image is stored
2945
+ * as `shell.png`).
2946
+ */
2947
+ export function safeStorageName(name, extension) {
2948
+ const base = (name.split(/[\\/]/).pop() ?? '').replace(/\.[^.]*$/, '').normalize('NFKD').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 180) || randomUUID();
2949
+ return `${base}.${extension.replace(/[^A-Za-z0-9]/g, '') || 'bin'}`;
2950
+ }
2951
+ /** Width and height of a PNG, GIF, JPEG or WebP image, read from its header; `null` for other files. */
2952
+ export function readImageSize(path) {
2953
+ let buffer;
2954
+ try {
2955
+ const fd = openSync(path, 'r');
2956
+ buffer = Buffer.alloc(1024 * 1024);
2957
+ const read = readSync(fd, buffer, 0, buffer.length, 0);
2958
+ closeSync(fd);
2959
+ buffer = buffer.subarray(0, read);
2960
+ }
2961
+ catch {
2962
+ return null;
2963
+ }
2964
+ if (buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504e47)
2965
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
2966
+ if (buffer.length >= 10 && buffer.toString('ascii', 0, 3) === 'GIF')
2967
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
2968
+ if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
2969
+ const chunk = buffer.toString('ascii', 12, 16);
2970
+ if (chunk === 'VP8X')
2971
+ return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) };
2972
+ if (chunk === 'VP8L') {
2973
+ const bits = buffer.readUInt32LE(21);
2974
+ return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
2975
+ }
2976
+ if (chunk === 'VP8 ')
2977
+ return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
2978
+ return null;
2979
+ }
2980
+ if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
2981
+ let offset = 2;
2982
+ while (offset + 9 < buffer.length) {
2983
+ if (buffer[offset] !== 0xff) {
2984
+ offset++;
2985
+ continue;
2986
+ }
2987
+ const marker = buffer[offset + 1];
2988
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker))
2989
+ return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) };
2990
+ offset += 2 + buffer.readUInt16BE(offset + 2);
2991
+ }
2992
+ }
2993
+ return null;
2994
+ }
2995
+ const ratioOf = (ratio) => { const [width, height] = ratio.split(/[:/]/).map(Number); return width > 0 && height > 0 ? width / height : null; };
1719
2996
  export class LocalFileStorage {
1720
2997
  root;
1721
2998
  baseUrl;
@@ -1725,13 +3002,24 @@ export class LocalFileStorage {
1725
3002
  return null;
1726
3003
  return nodePath.join(this.root, ...key.split('/'));
1727
3004
  }
1728
- async store(file, directory) {
1729
- const key = [directory, `${randomUUID()}.${file.extension}`].filter(Boolean).join('/');
3005
+ async store(file, directory, name = null) {
3006
+ let key = [directory, name === null ? `${randomUUID()}.${file.extension}` : safeStorageName(name, file.extension)].filter(Boolean).join('/');
3007
+ // A chosen name never overwrites another file: a short random suffix is added.
3008
+ if (name !== null && this.resolve(key) && existsSync(this.resolve(key)))
3009
+ key = key.replace(/(\.[A-Za-z0-9]+)$/, `-${randomUUID().slice(0, 8)}$1`);
1730
3010
  const target = this.resolve(key);
1731
3011
  if (!target)
1732
3012
  throw new Error('Invalid upload directory.');
1733
3013
  mkdirSync(nodePath.dirname(target), { recursive: true });
1734
- copyFileSync(file.path, target);
3014
+ try {
3015
+ copyFileSync(file.path, target, fsConstants.COPYFILE_EXCL);
3016
+ }
3017
+ catch (error) {
3018
+ // Another save took this name meanwhile: random suffix.
3019
+ if (error?.code !== 'EEXIST' || name === null)
3020
+ throw error;
3021
+ return this.store(file, directory, `${safeStorageName(name, file.extension).replace(/\.[^.]*$/, '')}-${randomUUID().slice(0, 8)}`);
3022
+ }
1735
3023
  writeFileSync(`${target}.meta.json`, JSON.stringify({ name: file.name, type: file.type }));
1736
3024
  return key;
1737
3025
  }
@@ -1764,7 +3052,107 @@ export class FileUpload extends Field {
1764
3052
  _storeFiles = true;
1765
3053
  _storage = null;
1766
3054
  _getUploadedFileUsing = null;
3055
+ _minSize = null;
3056
+ _ui = {};
3057
+ _avatar = false;
3058
+ _imageEditor = false;
3059
+ _circleCropper = false;
3060
+ _editorAspectRatios = [];
3061
+ _cropAspectRatio = null;
3062
+ _imageAspectRatios = null;
3063
+ _dimensions = null;
3064
+ _deletable = true;
3065
+ _preserveFilenames = false;
3066
+ _fileNameUsing = null;
3067
+ _fileNamesField = null;
3068
+ _storedNames = {};
3069
+ _alignment = null;
3070
+ _placeholder = null;
1767
3071
  static make(name) { return configured(new FileUpload(name)); }
3072
+ /** Minimum file size, in kilobytes. */
3073
+ minSize(kilobytes) { this._minSize = kilobytes; return this; }
3074
+ /** Download button on each saved file. */
3075
+ downloadable(condition = true) { this._ui.isDownloadable = condition; return this; }
3076
+ /** Button opening each saved file in a new tab. */
3077
+ openable(condition = true) { this._ui.isOpenable = condition; return this; }
3078
+ /** `false`: no preview of images. */
3079
+ previewable(condition = true) { this._ui.isPreviewable = condition; return this; }
3080
+ /** `false`: files cannot be pasted from the clipboard. */
3081
+ pasteable(condition = true) { this._ui.isPasteable = condition; return this; }
3082
+ /** New files are added after the existing ones (instead of before). */
3083
+ appendFiles(condition = true) { this._ui.shouldAppendFiles = condition; return this; }
3084
+ /** `false`: saved files cannot be removed (the remove button is hidden, and removing is refused on the server). */
3085
+ deletable(condition = true) { this._deletable = condition; return this; }
3086
+ /** `'grid'` (with `multiple()`), `'compact'`, `'integrated'` or `'circle'`. */
3087
+ panelLayout(layout) { this._ui.panelLayout = layout; return this; }
3088
+ panelAspectRatio(ratio) { this._ui.panelAspectRatio = ratio; return this; }
3089
+ itemPanelAspectRatio(ratio) { this._ui.itemPanelAspectRatio = ratio; return this; }
3090
+ imagePreviewHeight(height) { this._ui.imagePreviewHeight = height; return this; }
3091
+ loadingIndicatorPosition(position) { this._ui.loadingIndicatorPosition = position; return this; }
3092
+ removeUploadedFileButtonPosition(position) { this._ui.removeUploadedFileButtonPosition = position; return this; }
3093
+ uploadButtonPosition(position) { this._ui.uploadButtonPosition = position; return this; }
3094
+ uploadProgressIndicatorPosition(position) { this._ui.uploadProgressIndicatorPosition = position; return this; }
3095
+ /** Message shown while a file uploads. */
3096
+ uploadingMessage(message) { this._ui.uploadingMessage = message; return this; }
3097
+ /** Number of files uploaded at the same time. */
3098
+ maxParallelUploads(count) { this._ui.maxParallelUploads = count; return this; }
3099
+ /** Extension → MIME type map for files the browser cannot type (`{ zip: 'application/zip' }`). */
3100
+ mimeTypeMap(map) { this._ui.mimeTypeMap = map; return this; }
3101
+ /** `false`: images keep their EXIF orientation. */
3102
+ orientImagesFromExif(condition = true) { this._ui.shouldOrientImageFromExif = condition; return this; }
3103
+ alignment(alignment) { this._alignment = alignment; return this; }
3104
+ /** Text of the drop area. */
3105
+ placeholder(text) { this._placeholder = text; return this; }
3106
+ /** Round avatar: one image, cropped to a square and shown as a circle. */
3107
+ 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; }
3108
+ /** Image editor (crop, rotate, flip, zoom) opened from each image, powered by Cropper.js in the browser. */
3109
+ imageEditor(condition = true) { this._imageEditor = condition; return this; }
3110
+ /** Aspect ratios offered by the image editor (`['16:9', '4:3', '1:1']`; `null` for a free crop). */
3111
+ imageEditorAspectRatioOptions(ratios) { this._editorAspectRatios = ratios; return this; }
3112
+ imageEditorAspectRatios(ratios) { return this.imageEditorAspectRatioOptions(ratios); }
3113
+ /** Cropper.js view mode (1, 2 or 3). */
3114
+ imageEditorMode(mode) { this._ui.imageEditorMode = mode; return this; }
3115
+ imageEditorEmptyFillColor(color) { this._ui.imageEditorEmptyFillColor = color; return this; }
3116
+ imageEditorViewportWidth(width) { this._ui.imageEditorViewportWidth = width === null ? null : String(width); return this; }
3117
+ imageEditorViewportHeight(height) { this._ui.imageEditorViewportHeight = height === null ? null : String(height); return this; }
3118
+ _autoOpenEditor = true;
3119
+ /** With `imageEditor()` and a single aspect ratio, opens the editor when an uploaded image has another ratio (on by default). */
3120
+ automaticallyOpenImageEditorForAspectRatio(condition = true) { this._autoOpenEditor = condition; return this; }
3121
+ autoOpenEditorRatio() {
3122
+ if (!this._imageEditor || !this._autoOpenEditor)
3123
+ return null;
3124
+ const ratio = this._cropAspectRatio ?? (this._imageAspectRatios?.length === 1 ? this._imageAspectRatios[0] : null);
3125
+ return ratio === null ? null : ratioOf(ratio);
3126
+ }
3127
+ /** Circle crop in the image editor (avatars). */
3128
+ circleCropper(condition = true) { this._circleCropper = condition; return this; }
3129
+ /** Crops images to this ratio in the browser before upload (`'16:9'`), without opening the editor. */
3130
+ automaticallyCropImagesToAspectRatio(ratio) { this._cropAspectRatio = ratio; return this; }
3131
+ imageCropAspectRatio(ratio) { return this.automaticallyCropImagesToAspectRatio(ratio); }
3132
+ /** Resizes images in the browser before upload (`'cover'`, `'contain'` or `'force'`). */
3133
+ automaticallyResizeImagesMode(mode) { this._ui.automaticallyResizeImagesMode = mode; return this; }
3134
+ automaticallyResizeImagesToWidth(width) { this._ui.automaticallyResizeImagesWidth = width === null ? null : String(width); return this; }
3135
+ automaticallyResizeImagesToHeight(height) { this._ui.automaticallyResizeImagesHeight = height === null ? null : String(height); return this; }
3136
+ automaticallyUpscaleImagesWhenResizing(condition = true) { this._ui.shouldAutomaticallyUpscaleImagesWhenResizing = condition; return this; }
3137
+ imageResizeMode(mode) { return this.automaticallyResizeImagesMode(mode); }
3138
+ imageResizeTargetWidth(width) { return this.automaticallyResizeImagesToWidth(width); }
3139
+ imageResizeTargetHeight(height) { return this.automaticallyResizeImagesToHeight(height); }
3140
+ /** Accepted aspect ratio(s) of uploaded images (`'16:9'` or a list), checked on the server. */
3141
+ imageAspectRatio(ratio) { this._imageAspectRatios = ratio === null ? null : [ratio].flat(); return this; }
3142
+ /** Width, height and ratio bounds of uploaded images (`{ minWidth: 800, maxHeight: 1080 }`), checked on the server. */
3143
+ imageDimensions(dimensions) { this._dimensions = dimensions; return this; }
3144
+ /** Stores files under their original (sanitized) names. See the security notes in the docs. */
3145
+ preserveFilenames(condition = true) { this._preserveFilenames = condition; return this; }
3146
+ /** Name of the stored file, from the temporary file; sanitized, and its extension is the detected one. */
3147
+ getUploadedFileNameForStorageUsing(callback) { this._fileNameUsing = callback; return this; }
3148
+ /** Saves the original file names (keyed by stored file) in another attribute of the record. */
3149
+ storeFileNamesIn(field) { this._fileNamesField = field; return this; }
3150
+ /** Extra saved values, next to the field (the original file names of `storeFileNamesIn()`). */
3151
+ getExtraDehydratedState() {
3152
+ if (this._fileNamesField === null)
3153
+ return {};
3154
+ return { [this._fileNamesField]: this._multiple ? this._storedNames : (Object.values(this._storedNames)[0] ?? null) };
3155
+ }
1768
3156
  multiple(condition = true) { this._multiple = condition; return this; }
1769
3157
  maxFiles(count) { this._maxFiles = count; return this; }
1770
3158
  /** Maximum file size, in kilobytes. */
@@ -1799,6 +3187,23 @@ export class FileUpload extends Field {
1799
3187
  return new Set(Object.values(this.hydrateState(record?.[this.getName()])));
1800
3188
  }
1801
3189
  isTemporary(value) { return value.startsWith('livewire-file:'); }
3190
+ /** Original names saved by `storeFileNamesIn()`, by stored key. */
3191
+ savedFileNames() {
3192
+ if (this._fileNamesField === null)
3193
+ return {};
3194
+ const record = this.container.getRecord();
3195
+ let names = record?.[this._fileNamesField];
3196
+ if (typeof names === 'string') {
3197
+ try {
3198
+ names = JSON.parse(names);
3199
+ }
3200
+ catch {
3201
+ const key = [...this.originalKeys()][0];
3202
+ return key ? { [key]: names } : {};
3203
+ }
3204
+ }
3205
+ return names && typeof names === 'object' && !Array.isArray(names) ? Object.fromEntries(Object.entries(names).filter(([, name]) => typeof name === 'string')) : {};
3206
+ }
1802
3207
  getExposedMethods() { return ['deleteUploadedFile', 'removeUploadedFile', 'reorderUploadedFiles', 'getUploadedFiles']; }
1803
3208
  async getUploadedFiles() {
1804
3209
  const original = this.originalKeys();
@@ -1809,6 +3214,9 @@ export class FileUpload extends Field {
1809
3214
  continue;
1810
3215
  }
1811
3216
  const info = this._getUploadedFileUsing ? await this._getUploadedFileUsing(value, this.context()) : await this.getStorage()?.info(value);
3217
+ const savedName = this.savedFileNames()[value];
3218
+ if (info && savedName)
3219
+ info.name = savedName;
1812
3220
  // Only local or http(s) URLs are sent to the browser.
1813
3221
  files[fileKey] = info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info : null;
1814
3222
  }
@@ -1818,6 +3226,9 @@ export class FileUpload extends Field {
1818
3226
  /** Removes a file from the field. A stored file is only deleted by the application when the form is saved. */
1819
3227
  removeUploadedFile(fileKey) {
1820
3228
  const removed = this.entries().find(([key]) => key === fileKey);
3229
+ // `deletable(false)`: a saved file stays (a file just uploaded can still be removed).
3230
+ if (removed && !this._deletable && !this.isTemporary(removed[1]))
3231
+ return;
1821
3232
  if (removed && this.isTemporary(removed[1]))
1822
3233
  this.uploads?.remove(removed[1].slice('livewire-file:'.length));
1823
3234
  this.writeState(this.entries().filter(([key]) => key !== fileKey));
@@ -1846,6 +3257,23 @@ export class FileUpload extends Field {
1846
3257
  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
3258
  if (this._maxSize !== null)
1848
3259
  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.` });
3260
+ if (this._minSize !== null)
3261
+ 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.` });
3262
+ // Image rules only apply to new uploads, as in Laravel; a file whose size cannot be read fails them.
3263
+ if (this._dimensions) {
3264
+ const d = this._dimensions;
3265
+ const fits = (size) => size !== null
3266
+ && (d.width === undefined || size.width === d.width) && (d.height === undefined || size.height === d.height)
3267
+ && (d.minWidth === undefined || size.width >= d.minWidth) && (d.maxWidth === undefined || size.width <= d.maxWidth)
3268
+ && (d.minHeight === undefined || size.height >= d.minHeight) && (d.maxHeight === undefined || size.height <= d.maxHeight)
3269
+ && (d.ratio === undefined || Math.abs(size.width / size.height - d.ratio) < 0.01)
3270
+ && (d.minRatio === undefined || size.width / size.height >= d.minRatio - 1e-9) && (d.maxRatio === undefined || size.width / size.height <= d.maxRatio + 1e-9);
3271
+ rules.push({ name: 'dimensions', passes: () => temporary().every((file) => file !== null && fits(readImageSize(file.path))), message: (a) => `The ${a} field has invalid image dimensions.` });
3272
+ }
3273
+ if (this._imageAspectRatios) {
3274
+ const ratios = this._imageAspectRatios.map(ratioOf).filter((ratio) => ratio !== null);
3275
+ 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.` });
3276
+ }
1849
3277
  return rules;
1850
3278
  }
1851
3279
  /** Validates the whole state, even when empty (a required field without files must fail). */
@@ -1861,6 +3289,8 @@ export class FileUpload extends Field {
1861
3289
  /** On save: temporary files are stored, unrecognized existing keys are dropped. */
1862
3290
  async dehydrateState() {
1863
3291
  const original = this.originalKeys();
3292
+ const savedNames = this.savedFileNames();
3293
+ this._storedNames = {};
1864
3294
  const out = [];
1865
3295
  for (const [, value] of this.entries()) {
1866
3296
  if (this.isTemporary(value)) {
@@ -1874,74 +3304,115 @@ export class FileUpload extends Field {
1874
3304
  const storage = this.getStorage();
1875
3305
  if (!storage)
1876
3306
  throw new Error('FileUpload: no storage configured (FileUpload.storage).');
1877
- out.push(await storage.store(file, this._directory));
3307
+ const name = this._fileNameUsing ? await this._fileNameUsing(file, this.context()) : this._preserveFilenames ? file.name : null;
3308
+ const stored = await storage.store(file, this._directory, name);
3309
+ this._storedNames[stored] = file.name;
3310
+ out.push(stored);
1878
3311
  this.uploads?.remove(file.id);
1879
3312
  }
1880
- else if (original.has(value))
3313
+ else if (original.has(value)) {
1881
3314
  out.push(this._storeFiles ? value : { key: value });
3315
+ if (savedNames[value])
3316
+ this._storedNames[value] = savedNames[value];
3317
+ }
1882
3318
  }
3319
+ // `deletable(false)`: a saved file the browser dropped from the state is kept.
3320
+ if (!this._deletable)
3321
+ for (const key of original)
3322
+ if (!out.some((item) => item === key || (typeof item === 'object' && 'key' in item && item.key === key))) {
3323
+ out.push(this._storeFiles ? key : { key });
3324
+ if (savedNames[key])
3325
+ this._storedNames[key] = savedNames[key];
3326
+ }
1883
3327
  return this._multiple ? out : (out[0] ?? null);
1884
3328
  }
3329
+ /** Filament's image editor dialog (Cropper.js), rendered once per field. */
3330
+ imageEditorHtml() {
3331
+ const tr = (key, replace = {}) => t(`filament-forms::components.file_upload.editor.${key}`, replace);
3332
+ const explicit = this._imageEditor;
3333
+ const fields = [
3334
+ ['x_position', 'xPositionInput', 'editor.setData({...editor.getData(true), x: +$el.value})'], ['y_position', 'yPositionInput', 'editor.setData({...editor.getData(true), y: +$el.value})'],
3335
+ ['width', 'widthInput', 'editor.setData({...editor.getData(true), width: +$el.value})'], ['height', 'heightInput', 'editor.setData({...editor.getData(true), height: +$el.value})'],
3336
+ ['rotation', 'rotationInput', 'editor.rotateTo(+$el.value)'],
3337
+ ].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('');
3338
+ const groups = [
3339
+ [['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)']],
3340
+ [['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)']],
3341
+ [['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)']],
3342
+ ].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('');
3343
+ const ratioOptions = [...this._editorAspectRatios, ...(this._cropAspectRatio !== null ? [this._cropAspectRatio] : [])].filter((ratio, index, all) => all.indexOf(ratio) === index)
3344
+ .map((ratio) => [ratio === null ? tr('aspect_ratios.no_fixed.label') : ratio.replace('/', ':'), ratio === null ? 'NaN' : (ratioOf(ratio) ?? 'NaN')]);
3345
+ 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>` : '';
3346
+ const success = cls(['fi-btn', ...(colorManager?.getComponentClasses('button', 'success') ?? [])]);
3347
+ const cancel = `<button type="button" x-on:click.prevent="pond.imageEditEditor.oncancel" class="fi-btn">${e(tr('actions.cancel.label'))}</button>`;
3348
+ const save = `<button type="button" x-on:click.prevent="saveEditor" class="${success}">${e(tr('actions.save.label'))}</button>`;
3349
+ 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>` : '';
3350
+ 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>`;
3351
+ }
1885
3352
  async toHtml() {
1886
3353
  const isDisabled = await this.isDisabled();
1887
3354
  const id = this.getId();
1888
3355
  const label = (await this.getLabel()).toLowerCase();
1889
3356
  const tr = (key) => t(`filament-forms::components.file_upload.${key}`);
3357
+ const ui = (key, fallback) => (this._ui[key] === undefined ? fallback : this._ui[key]);
3358
+ const hasEditor = (this._imageEditor || this._cropAspectRatio !== null || this._circleCropper) && !isDisabled;
1890
3359
  const config = `fileUploadFormComponent({
1891
3360
  acceptedFileTypes: ${this._acceptedFileTypes ? js(this._acceptedFileTypes) : 'null'},
1892
- automaticallyCropImagesAspectRatio: null,
1893
- automaticallyOpenImageEditorForAspectRatio: null,
1894
- automaticallyResizeImagesMode: null,
1895
- automaticallyResizeImagesHeight: null,
1896
- automaticallyResizeImagesWidth: null,
3361
+ automaticallyCropImagesAspectRatio: ${js(this._cropAspectRatio)},
3362
+ automaticallyOpenImageEditorForAspectRatio: ${js(this.autoOpenEditorRatio())},
3363
+ automaticallyResizeImagesMode: ${js(ui('automaticallyResizeImagesMode', null))},
3364
+ automaticallyResizeImagesHeight: ${js(ui('automaticallyResizeImagesHeight', null))},
3365
+ automaticallyResizeImagesWidth: ${js(ui('automaticallyResizeImagesWidth', null))},
1897
3366
  canEditSvgs: true,
1898
3367
  confirmSvgEditingMessage: ${js(tr('editor.svg.messages.confirmation'))},
1899
3368
  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,
3369
+ hasCircleCropper: ${js(this._circleCropper)},
3370
+ hasImageEditor: ${js(hasEditor)},
3371
+ imageEditorEmptyFillColor: ${js(ui('imageEditorEmptyFillColor', null))},
3372
+ imageEditorMode: ${js(ui('imageEditorMode', 1))},
3373
+ imageEditorViewportHeight: ${js(ui('imageEditorViewportHeight', null))},
3374
+ imageEditorViewportWidth: ${js(ui('imageEditorViewportWidth', null))},
3375
+ imagePreviewHeight: ${js(ui('imagePreviewHeight', null))},
3376
+ isAvatar: ${js(this._avatar)},
3377
+ isDeletable: ${js(this._deletable)},
1909
3378
  isDisabled: ${isDisabled ? 'true' : 'false'},
1910
- isDownloadable: false,
1911
- isImageEditorExplicitlyEnabled: false,
3379
+ isDownloadable: ${js(ui('isDownloadable', false))},
3380
+ isImageEditorExplicitlyEnabled: ${js(this._imageEditor)},
1912
3381
  isMultiple: ${this._multiple ? 'true' : 'false'},
1913
- isOpenable: false,
1914
- isPasteable: true,
1915
- isPreviewable: true,
3382
+ isOpenable: ${js(ui('isOpenable', false))},
3383
+ isPasteable: ${js(ui('isPasteable', true))},
3384
+ isPreviewable: ${js(ui('isPreviewable', true))},
1916
3385
  isReorderable: ${this._reorderable ? 'true' : 'false'},
1917
3386
  isSvgEditingConfirmed: false,
1918
- itemPanelAspectRatio: null,
1919
- loadingIndicatorPosition: 'right',
3387
+ itemPanelAspectRatio: ${js(ui('itemPanelAspectRatio', null))},
3388
+ loadingIndicatorPosition: ${js(ui('loadingIndicatorPosition', 'right'))},
1920
3389
  locale: 'en',
1921
3390
  maxFiles: ${this._maxFiles ?? 'null'},
1922
3391
  maxFilesValidationMessage: ${this._maxFiles !== null ? js(`The ${label} field must not have more than ${this._maxFiles} items.`) : 'null'},
1923
- maxParallelUploads: null,
3392
+ maxParallelUploads: ${js(ui('maxParallelUploads', null))},
1924
3393
  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,
3394
+ mimeTypeMap: ${ui('mimeTypeMap', null) ? js(ui('mimeTypeMap', null)) : '[]'},
3395
+ minSize: ${this._minSize !== null ? js(`${this._minSize}KB`) : 'null'},
3396
+ panelAspectRatio: ${js(ui('panelAspectRatio', null))},
3397
+ panelLayout: ${js(ui('panelLayout', 'compact'))},
3398
+ placeholder: ${js(this._placeholder)},
3399
+ removeUploadedFileButtonPosition: ${js(ui('removeUploadedFileButtonPosition', 'left'))},
3400
+ shouldAppendFiles: ${js(ui('shouldAppendFiles', false))},
3401
+ shouldAutomaticallyUpscaleImagesWhenResizing: ${js(ui('shouldAutomaticallyUpscaleImagesWhenResizing', true))},
3402
+ shouldOrientImageFromExif: ${js(ui('shouldOrientImageFromExif', true))},
3403
+ shouldTransformImage: ${js(Boolean(this._cropAspectRatio || ui('automaticallyResizeImagesHeight', null) || ui('automaticallyResizeImagesWidth', null)))},
1935
3404
  state: $wire.$entangle(${js(this.getStatePath())}, false),
1936
3405
  statePath: ${js(this.getStatePath())},
1937
- uploadButtonPosition: 'right',
1938
- uploadingMessage: ${js(t('filament::components/button.messages.uploading_file'))},
3406
+ uploadButtonPosition: ${js(ui('uploadButtonPosition', 'right'))},
3407
+ uploadingMessage: ${js(ui('uploadingMessage', t('filament::components/button.messages.uploading_file')))},
1939
3408
  downloadActionLabel: ${js(tr('actions.download.label'))},
1940
3409
  openActionLabel: ${js(tr('actions.open.label'))},
1941
- uploadProgressIndicatorPosition: 'right',
3410
+ uploadProgressIndicatorPosition: ${js(ui('uploadProgressIndicatorPosition', 'right'))},
1942
3411
  })`;
1943
- const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([this._acceptedFileTypes, this._maxFiles, this._multiple, isDisabled])).digest('hex')}`;
3412
+ 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
3413
  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 });
3414
+ 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
3415
  }
1947
3416
  }
3417
+ // Modal wizards of actions (`Action.steps()`): the wizard is not contained, the last step submits the action.
3418
+ Action.stepsRenderer = (steps, submit, cancel) => Wizard.make(steps).contained(false).submitAction(submit).cancelAction(cancel);