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