@navette-js/forms 0.0.0-stage → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/lib/builder.d.ts +128 -0
- package/lib/builder.js +306 -0
- package/lib/index.d.ts +866 -0
- package/lib/index.js +1947 -0
- package/package.json +61 -4
- package/views/asset.ejs +1 -0
- package/views/components/field-wrapper.ejs +39 -0
- package/views/components/rich-editor.ejs +471 -0
- package/views/components/text-input.ejs +32 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1947 @@
|
|
|
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';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { t } from '@navette-js/support/i18n';
|
|
5
|
+
import { Component, Icon as SchemaIcon, Schema, Text, childSchema, renderInlineItems, slug } from '@navette-js/schemas';
|
|
6
|
+
import { Action } from '@navette-js/actions';
|
|
7
|
+
import { loadingIndicator } from '@navette-js/support';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { generateHTML, generateJSON } from '@tiptap/html/server';
|
|
10
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
11
|
+
import TextAlign from '@tiptap/extension-text-align';
|
|
12
|
+
import Subscript from '@tiptap/extension-subscript';
|
|
13
|
+
import Superscript from '@tiptap/extension-superscript';
|
|
14
|
+
import Highlight from '@tiptap/extension-highlight';
|
|
15
|
+
import Image from '@tiptap/extension-image';
|
|
16
|
+
import { TableKit } from '@tiptap/extension-table';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import nodePath from 'node:path';
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
views.namespace('filament-forms', fileURLToPath(new URL('../views', import.meta.url)));
|
|
21
|
+
let colorManager = null;
|
|
22
|
+
export const useColorManager = (manager) => { colorManager = manager; };
|
|
23
|
+
const asset = (name) => views.render('filament-forms::asset', { path: name }).trim();
|
|
24
|
+
/** Number for an HTML attribute: beyond exact integers, a float with 14 significant digits (`1.844674407371E+19`). */
|
|
25
|
+
const phpNumber = (n) => {
|
|
26
|
+
if (n === null || Number.isSafeInteger(n) || Math.abs(n) < 1e15)
|
|
27
|
+
return n;
|
|
28
|
+
const [mantissa, exponent] = n.toPrecision(14).split('e');
|
|
29
|
+
return `${mantissa.replace(/\.?0+$/, '')}E${exponent}`;
|
|
30
|
+
};
|
|
31
|
+
const blank = (v) => v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
|
|
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
|
+
export class Field extends Component {
|
|
34
|
+
_label = null;
|
|
35
|
+
_required = false;
|
|
36
|
+
_disabled = false;
|
|
37
|
+
_autofocus = false;
|
|
38
|
+
_default = null;
|
|
39
|
+
_rules = [];
|
|
40
|
+
_afterStateUpdated = null;
|
|
41
|
+
_dehydrated = null;
|
|
42
|
+
_hiddenLabel = false;
|
|
43
|
+
_helperText = null;
|
|
44
|
+
constructor(name) { super(); this._name = name; }
|
|
45
|
+
hasStatePath() { return true; }
|
|
46
|
+
getName() { return this._name; }
|
|
47
|
+
getId() { return this.getKey(); }
|
|
48
|
+
label(label) { this._label = label; return this; }
|
|
49
|
+
required(condition = true) { this._required = condition; return this; }
|
|
50
|
+
disabled(condition = true) { this._disabled = condition; return this; }
|
|
51
|
+
autofocus(condition = true) { this._autofocus = condition; return this; }
|
|
52
|
+
default(value) { this._default = value; return this; }
|
|
53
|
+
/** Helper text shown under the field. */
|
|
54
|
+
helperText(text) { this._helperText = text; return this; }
|
|
55
|
+
/** Helper text id, linked to the control through `aria-describedby`. */
|
|
56
|
+
getHelperTextId() { return this._helperText !== null ? `${this.getId()}-helper-text` : null; }
|
|
57
|
+
/** Keeps the label for screen readers only. */
|
|
58
|
+
hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
|
|
59
|
+
_hint = null;
|
|
60
|
+
_hintColor = null;
|
|
61
|
+
_hintIcon = null;
|
|
62
|
+
_hintIconTooltip = null;
|
|
63
|
+
_hintActions = [];
|
|
64
|
+
/**
|
|
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.
|
|
67
|
+
*/
|
|
68
|
+
hint(hint) { this._hint = hint; return this; }
|
|
69
|
+
/** Color of the hint and its icon (by default `gray` for the text, `primary` for the icon). */
|
|
70
|
+
hintColor(color) { this._hintColor = color; return this; }
|
|
71
|
+
/** Hint icon, with an optional tooltip (also used as its accessible name). */
|
|
72
|
+
hintIcon(name, tooltip) {
|
|
73
|
+
this._hintIcon = name;
|
|
74
|
+
if (arguments.length >= 2)
|
|
75
|
+
this._hintIconTooltip = tooltip ?? null;
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
hintIconTooltip(tooltip) { this._hintIconTooltip = tooltip; return this; }
|
|
79
|
+
/** Action shown after the hint (small link), mounted on the field. */
|
|
80
|
+
hintAction(action) { return this.hintActions([action]); }
|
|
81
|
+
hintActions(actions) { this._hintActions = [...this._hintActions, ...actions]; return this; }
|
|
82
|
+
getHintActions() { return this._hintActions; }
|
|
83
|
+
/** Actions the field exposes for mounting (`schemaComponent` = field key): its hint actions. */
|
|
84
|
+
getActions() { return this.getHintActions(); }
|
|
85
|
+
/** Row after the label: hint, hint icon and hint actions. */
|
|
86
|
+
async renderAfterLabel() {
|
|
87
|
+
const ctx = this.context();
|
|
88
|
+
const hint = await evaluate(this._hint, ctx);
|
|
89
|
+
const color = await evaluate(this._hintColor, ctx);
|
|
90
|
+
const iconName = await evaluate(this._hintIcon, ctx);
|
|
91
|
+
const items = [];
|
|
92
|
+
if (hint !== null && hint !== undefined && (hint instanceof HtmlString ? hint.html : hint) !== '')
|
|
93
|
+
items.push(Text.make(hint).color(color));
|
|
94
|
+
if (iconName)
|
|
95
|
+
items.push(SchemaIcon.make(iconName).tooltip(await evaluate(this._hintIconTooltip, ctx)).color(color));
|
|
96
|
+
items.push(...this._hintActions);
|
|
97
|
+
if (!items.length)
|
|
98
|
+
return '';
|
|
99
|
+
// Row components take the field's path, and the field's schema path as their container path.
|
|
100
|
+
return renderInlineItems(this, items, { alignment: 'end', scope: { path: this.getStatePath(), containerPath: this.container.getStatePath() }, mountKey: this.getKey(), actionView: 'link' });
|
|
101
|
+
}
|
|
102
|
+
/** Runs on the server when the field state changes (`live()` field). `set(name, value)` changes another field of the same form. */
|
|
103
|
+
afterStateUpdated(callback) { this._afterStateUpdated = callback; return this; }
|
|
104
|
+
hasAfterStateUpdated() { return this._afterStateUpdated !== null; }
|
|
105
|
+
async callAfterStateUpdated(set) { await this._afterStateUpdated?.({ ...this.context(), set }); }
|
|
106
|
+
/** Forces the field into the submitted data, even when disabled (`.disabled().dehydrated()`). */
|
|
107
|
+
dehydrated(condition = true) { this._dehydrated = condition; return this; }
|
|
108
|
+
/** By default, a disabled field is neither validated nor saved. */
|
|
109
|
+
async isDehydrated() { return this._dehydrated ?? !(await this.isDisabled()); }
|
|
110
|
+
getDefaultState() { return typeof this._default === 'function' ? this._default() : this._default; }
|
|
111
|
+
/** Shapes a record value as it enters the form (hydration). */
|
|
112
|
+
hydrateState(value) { return value; }
|
|
113
|
+
rule(rule) { this._rules.push(rule); return this; }
|
|
114
|
+
/** `unique` rule: the value must not exist in the column of the same name; `ignoreRecord` excludes the record being edited. */
|
|
115
|
+
unique(options = {}) {
|
|
116
|
+
return this.rule({
|
|
117
|
+
name: 'unique',
|
|
118
|
+
passes: async (value, ctx) => {
|
|
119
|
+
const host = ctx.livewire;
|
|
120
|
+
const adapter = host.getDataAdapter?.();
|
|
121
|
+
if (!adapter)
|
|
122
|
+
throw new Error('unique(): the host component provides no data adapter (getDataAdapter).');
|
|
123
|
+
const ignored = options.ignoreRecord ? (ctx.record ? adapter.getKey(ctx.record) : (host.getRecordKey?.() ?? null)) : null;
|
|
124
|
+
return !(await adapter.exists(options.column ?? this._name, value, ignored));
|
|
125
|
+
},
|
|
126
|
+
message: (a) => `The ${a} has already been taken.`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async getLabel() { return (await evaluate(this._label, this.context())) ?? headline(this._name); }
|
|
130
|
+
isRequired() { return evaluate(this._required, this.context()); }
|
|
131
|
+
isDisabled() { return evaluate(this._disabled, this.context()); }
|
|
132
|
+
getErrors() { return this.livewire._errors[this.getStatePath()] ?? []; }
|
|
133
|
+
hasError() { return this.getErrors().length > 0; }
|
|
134
|
+
/** Validation rules: `required` first, then the field's own. */
|
|
135
|
+
async getValidationRules() {
|
|
136
|
+
const rules = [];
|
|
137
|
+
if (await this.isRequired())
|
|
138
|
+
rules.push({ name: 'required', passes: (v) => !blank(v) && v !== false, message: (a) => `The ${a} field is required.` });
|
|
139
|
+
return [...rules, ...this._rules];
|
|
140
|
+
}
|
|
141
|
+
async validate() {
|
|
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.
|
|
144
|
+
if ((await this.isDisabled()) && this._dehydrated !== true)
|
|
145
|
+
return [];
|
|
146
|
+
// An empty string is treated as missing.
|
|
147
|
+
const value = this.context().state === '' ? null : this.context().state;
|
|
148
|
+
const attribute = (await this.getLabel()).toLowerCase();
|
|
149
|
+
for (const rule of await this.getValidationRules()) {
|
|
150
|
+
if (rule.name !== 'required' && blank(value))
|
|
151
|
+
continue;
|
|
152
|
+
if (!(await rule.passes(value, this.context())))
|
|
153
|
+
return [rule.message(attribute)];
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
/** Value written to the data to save (composite fields only leave a skeleton, filled by their children). */
|
|
158
|
+
dehydrateState(state) { return state; }
|
|
159
|
+
/** Final shaping of the saved value, once child fields are dehydrated (e.g. a Builder's item list). */
|
|
160
|
+
finalizeDehydratedState(state) { return state; }
|
|
161
|
+
async wrap(html, options = {}) {
|
|
162
|
+
const errors = this.getErrors();
|
|
163
|
+
// FusedGroup field: the wrapper is reduced to a screen-reader label.
|
|
164
|
+
if (this.container.getFieldWrapperView?.() === 'filament-forms::plain-field-wrapper') {
|
|
165
|
+
const label = await this.getLabel();
|
|
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>`;
|
|
167
|
+
return `<div data-field-wrapper class="fi-fo-field">${labelHtml}${options.labelPrefix ?? ''}${html}${options.after ?? ''}</div>`;
|
|
168
|
+
}
|
|
169
|
+
// Helper text: a small inline schema under the control.
|
|
170
|
+
if (this._helperText !== null)
|
|
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>`;
|
|
172
|
+
return views.render('filament-forms::components.field-wrapper', {
|
|
173
|
+
id: this.getId(), label: await this.getLabel(), required: (await this.isRequired()) && !(await this.isDisabled()),
|
|
174
|
+
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
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export class TextInput extends Field {
|
|
179
|
+
_type = null;
|
|
180
|
+
_isPassword = false;
|
|
181
|
+
_isRevealable = false;
|
|
182
|
+
_autocomplete = null;
|
|
183
|
+
_maxLength = null;
|
|
184
|
+
_minLength = null;
|
|
185
|
+
_placeholder = null;
|
|
186
|
+
_numeric = null;
|
|
187
|
+
_minValue = null;
|
|
188
|
+
_maxValue = null;
|
|
189
|
+
_prefix = null;
|
|
190
|
+
_live = null;
|
|
191
|
+
static make(name) { return configured(new TextInput(name)); }
|
|
192
|
+
/** Decimal number: `type="number"`, `step="any"`. */
|
|
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.` }); }
|
|
194
|
+
/** Integer: `type="number"`, `step="1"`. */
|
|
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.` }); }
|
|
196
|
+
_step = null;
|
|
197
|
+
/** Input `step` of a numeric field, overriding the one implied by `numeric()` or `integer()`. */
|
|
198
|
+
step(step) { this._step = step; return this; }
|
|
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}.` }); }
|
|
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}.` }); }
|
|
201
|
+
/** Label shown before the input (e.g. `$`). */
|
|
202
|
+
prefix(label) { this._prefix = label; return this; }
|
|
203
|
+
_suffix = null;
|
|
204
|
+
/** Label shown after the input ("hours", "kg"…). */
|
|
205
|
+
suffix(label) { this._suffix = label; return this; }
|
|
206
|
+
/** Sends the state to the server on every keystroke, or on blur with `onBlur`. */
|
|
207
|
+
live(options = {}) { this._live = options.onBlur ? 'blur' : 'live'; return this; }
|
|
208
|
+
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
|
+
password(condition = true) { this._isPassword = condition; return this; }
|
|
217
|
+
revealable(condition = true) { this._isRevealable = condition; return this; }
|
|
218
|
+
/** `true` → "on", `false` → "off", a string as is. */
|
|
219
|
+
autocomplete(value = true) { this._autocomplete = value === true ? 'on' : value === false ? 'off' : value; return this; }
|
|
220
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
221
|
+
_mask = null;
|
|
222
|
+
/** Alpine input mask (`(999) 999-9999`): guides typing in the browser, with no validation value. */
|
|
223
|
+
mask(pattern) { this._mask = pattern; return this; }
|
|
224
|
+
/** Phone number: digits, spaces and common punctuation. */
|
|
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.` }); }
|
|
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. */
|
|
228
|
+
url() { this._type = 'url'; return this.rule({ name: 'url', passes: (v) => { try {
|
|
229
|
+
return ['http:', 'https:'].includes(new URL(String(v)).protocol);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
} }, message: (a) => `The ${a} field must be a valid URL.` }); }
|
|
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.` }); }
|
|
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.` }); }
|
|
236
|
+
async toHtml() {
|
|
237
|
+
const isDisabled = await this.isDisabled();
|
|
238
|
+
const revealable = this._isPassword && this._isRevealable;
|
|
239
|
+
const type = revealable ? 'password' : this._mask !== null ? 'text' : (this._type ?? (this._isPassword ? 'password' : this._numeric ? 'number' : 'text'));
|
|
240
|
+
const model = `wire:model${this._live ? '.live' : ''}${this._live === 'blur' ? '.blur' : ''}`;
|
|
241
|
+
const input = new Attributes()
|
|
242
|
+
.merge({
|
|
243
|
+
'aria-invalid': this.hasError() ? 'true' : null,
|
|
244
|
+
'aria-describedby': this.getHelperTextId(),
|
|
245
|
+
autocomplete: this._autocomplete, autofocus: this._autofocus, disabled: isDisabled, id: this.getId(),
|
|
246
|
+
inputmode: this._numeric === 'decimal' ? 'decimal' : this._numeric === 'integer' ? 'numeric' : null,
|
|
247
|
+
max: phpNumber(this._maxValue), min: phpNumber(this._minValue),
|
|
248
|
+
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,
|
|
250
|
+
// Modifiers (`.live`, `.blur`) also apply to `wire:model.fill`.
|
|
251
|
+
[revealable ? model.replace('wire:model', 'wire:model.fill') : model]: this.getStatePath(),
|
|
252
|
+
'x-bind:type': revealable ? "isPasswordRevealed ? 'text' : 'password'" : null,
|
|
253
|
+
'x-mask': this._mask,
|
|
254
|
+
})
|
|
255
|
+
.class(['fi-input', { 'fi-revealable': revealable }]);
|
|
256
|
+
const wrapper = new Attributes()
|
|
257
|
+
.merge({
|
|
258
|
+
'x-data': revealable ? 'passwordRevealFormComponent()' : this._mask !== null ? '{}' : null,
|
|
259
|
+
'x-load': revealable ? '' : null,
|
|
260
|
+
'x-load-src': revealable ? views.render('filament-forms::asset', { path: 'text-input/password-reveal' }).trim() : null,
|
|
261
|
+
'x-on:focus-input.stop': "$el.querySelector('input')?.focus()",
|
|
262
|
+
})
|
|
263
|
+
.class(['fi-fo-text-input', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
264
|
+
return this.wrap(views.render('filament-forms::components.text-input', {
|
|
265
|
+
input: input.toHtml(), wrapper: wrapper.toHtml(), revealable, prefix: this._prefix, suffix: this._suffix,
|
|
266
|
+
showLabel: t('filament-forms::components.text_input.actions.show_password.label'),
|
|
267
|
+
hideLabel: t('filament-forms::components.text_input.actions.hide_password.label'),
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
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
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Dropdown. Native (`<select>`) by default; `searchable()` switches to the JavaScript select,
|
|
312
|
+
* which queries the server through the exposed methods (`getSearchResultsForJs`…).
|
|
313
|
+
*/
|
|
314
|
+
export class Select extends Field {
|
|
315
|
+
_options = [];
|
|
316
|
+
_placeholder = null;
|
|
317
|
+
_searchable = false;
|
|
318
|
+
_dynamic = null;
|
|
319
|
+
_searchOnServer = null;
|
|
320
|
+
_live = false;
|
|
321
|
+
_hasNoInitialOptions = false;
|
|
322
|
+
_multiple = false;
|
|
323
|
+
_optionLabelsUsing = null;
|
|
324
|
+
_disableOptionWhen = null;
|
|
325
|
+
_searchResultsUsing = null;
|
|
326
|
+
_optionLabelUsing = null;
|
|
327
|
+
static make(name) { return configured(new Select(name)); }
|
|
328
|
+
/** Search results computed by the server on every keystroke (no preloaded option list). */
|
|
329
|
+
getSearchResultsUsing(resolver) { this._searchResultsUsing = resolver; this._searchable = true; return this; }
|
|
330
|
+
/** Label of a value; also used to validate that a searched value exists. */
|
|
331
|
+
getOptionLabelUsing(resolver) { this._optionLabelUsing = resolver; return this; }
|
|
332
|
+
/**
|
|
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).
|
|
336
|
+
*/
|
|
337
|
+
options(options, config = {}) { this._options = options; this._dynamic = config.dynamic ?? null; this._searchOnServer = config.searchOnServer ?? null; return this; }
|
|
338
|
+
/** Disables an option depending on the context (e.g. already picked in another row). */
|
|
339
|
+
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
340
|
+
/** Sends the state to the server as soon as it changes. */
|
|
341
|
+
live(condition = true) { this._live = condition; return this; }
|
|
342
|
+
placeholder(label) { this._placeholder = label; return this; }
|
|
343
|
+
_selectablePlaceholder = true;
|
|
344
|
+
/** `false`: the native select has no empty option. */
|
|
345
|
+
selectablePlaceholder(condition = true) { this._selectablePlaceholder = condition; return this; }
|
|
346
|
+
searchable(condition = true) { this._searchable = condition; return this; }
|
|
347
|
+
/** Multiple selection: the state is a list of values; always uses the JavaScript select. */
|
|
348
|
+
multiple(condition = true) { this._multiple = condition; if (condition)
|
|
349
|
+
this._searchable = true; return this; }
|
|
350
|
+
isMultiple() { return this._multiple; }
|
|
351
|
+
/** Labels of several values at once (multiple selection with server search); also used to validate the values. */
|
|
352
|
+
getOptionLabelsUsing(resolver) { this._optionLabelsUsing = resolver; return this; }
|
|
353
|
+
getDefaultState() { return this._default ?? (this._multiple ? [] : null); }
|
|
354
|
+
/** Sanitized selected values: unique strings. */
|
|
355
|
+
selectedValues() { const state = this.context().state; return [...new Set((Array.isArray(state) ? state : []).filter((v) => typeof v === 'string' || typeof v === 'number').map(String))]; }
|
|
356
|
+
async labelsOf(values) {
|
|
357
|
+
if (!values.length)
|
|
358
|
+
return [];
|
|
359
|
+
const known = this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
|
|
360
|
+
const map = new Map((Array.isArray(known) ? known : Object.entries(known)).map(([value, label]) => [String(value), String(label)]));
|
|
361
|
+
return values.filter((value) => map.has(value)).map((value) => [value, map.get(value)]);
|
|
362
|
+
}
|
|
363
|
+
/** No-op: options are always loaded with the form. */
|
|
364
|
+
preload() { return this; }
|
|
365
|
+
hydrateState(value) {
|
|
366
|
+
if (this._multiple)
|
|
367
|
+
return (Array.isArray(value) ? value : []).map(String);
|
|
368
|
+
return value === null || value === undefined || value === '' ? null : String(value);
|
|
369
|
+
}
|
|
370
|
+
dehydrateState(state) { return this._multiple ? this.selectedValues() : state; }
|
|
371
|
+
async resolveOptions() {
|
|
372
|
+
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)]);
|
|
374
|
+
}
|
|
375
|
+
async getValidationRules() {
|
|
376
|
+
// With server search, a value is valid if the server can label it; otherwise it must be among the options.
|
|
377
|
+
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)));
|
|
380
|
+
return [...(await super.getValidationRules()), { name: 'in', passes, message: (a) => `The selected ${a} is invalid.` }];
|
|
381
|
+
}
|
|
382
|
+
// Methods exposed to the select's JavaScript (called through callSchemaComponentMethod).
|
|
383
|
+
getExposedMethods() { return ['getOptionLabel', 'getSearchResultsForJs', 'getOptionsForJs', 'getOptionLabelsForJs']; }
|
|
384
|
+
static forJs(options) { return options.map(([value, label]) => ({ label, value, isDisabled: false })); }
|
|
385
|
+
async getOptionsForJs() { return this.withDisabled(Select.forJs(await this.resolveOptions())); }
|
|
386
|
+
withDisabled(options) {
|
|
387
|
+
if (!this._disableOptionWhen)
|
|
388
|
+
return options;
|
|
389
|
+
const ctx = this.context();
|
|
390
|
+
return options.map((option) => ({ ...option, isDisabled: this._disableOptionWhen(option.value, ctx) }));
|
|
391
|
+
}
|
|
392
|
+
async getSearchResultsForJs(search = '') {
|
|
393
|
+
if (this._searchResultsUsing) {
|
|
394
|
+
const found = await this._searchResultsUsing(String(search ?? '').slice(0, 1000), this.context());
|
|
395
|
+
return Select.forJs((Array.isArray(found) ? found : Object.entries(found)).map(([value, label]) => [String(value), String(label)]));
|
|
396
|
+
}
|
|
397
|
+
const needle = String(search ?? '').toLowerCase();
|
|
398
|
+
return Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle)));
|
|
399
|
+
}
|
|
400
|
+
async getOptionLabel() {
|
|
401
|
+
const state = this.context().state;
|
|
402
|
+
if (state === null || state === undefined || state === '')
|
|
403
|
+
return null;
|
|
404
|
+
if (this._optionLabelUsing)
|
|
405
|
+
return this._optionLabelUsing(String(state), this.context());
|
|
406
|
+
return (await this.resolveOptions()).find(([value]) => value === String(state))?.[1] ?? null;
|
|
407
|
+
}
|
|
408
|
+
async getOptionLabelsForJs() { return this._multiple ? (await this.labelsOf(this.selectedValues())).map(([value, label]) => ({ label, value })) : []; }
|
|
409
|
+
_createOptionForm = null;
|
|
410
|
+
_createOptionUsing = null;
|
|
411
|
+
_createOptionModalHeading = null;
|
|
412
|
+
_modifyCreateOptionAction = null;
|
|
413
|
+
_editOptionForm = null;
|
|
414
|
+
_updateOptionUsing = null;
|
|
415
|
+
_fillEditOptionForm = null;
|
|
416
|
+
_editOptionModalHeading = null;
|
|
417
|
+
_modifyEditOptionAction = null;
|
|
418
|
+
/**
|
|
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.
|
|
422
|
+
*/
|
|
423
|
+
createOptionForm(schema) { this._createOptionForm = schema; return this; }
|
|
424
|
+
/** Saves the option from the validated modal data; returns the value (key) of the created option. */
|
|
425
|
+
createOptionUsing(callback) { this._createOptionUsing = callback; return this; }
|
|
426
|
+
createOptionModalHeading(heading) { this._createOptionModalHeading = heading; return this; }
|
|
427
|
+
/** Customizes the create action (label, icon, modal width…). */
|
|
428
|
+
createOptionAction(modify) { this._modifyCreateOptionAction = modify; return this; }
|
|
429
|
+
/**
|
|
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()`.
|
|
432
|
+
*/
|
|
433
|
+
editOptionForm(schema) { this._editOptionForm = schema; this._live = true; return this; }
|
|
434
|
+
updateOptionUsing(callback) { this._updateOptionUsing = callback; return this; }
|
|
435
|
+
/** Initial data of the "Edit" modal (the context carries the current field state in `state`). */
|
|
436
|
+
fillEditOptionActionFormUsing(callback) { this._fillEditOptionForm = callback; return this; }
|
|
437
|
+
editOptionModalHeading(heading) { this._editOptionModalHeading = heading; return this; }
|
|
438
|
+
editOptionAction(modify) { this._modifyEditOptionAction = modify; return this; }
|
|
439
|
+
/** Same form for creating and editing. */
|
|
440
|
+
manageOptionForm(schema) { return this.createOptionForm(schema).editOptionForm(schema); }
|
|
441
|
+
setState(value) { writePath(this.livewire, this.getStatePath().split('.'), value); }
|
|
442
|
+
getCreateOptionAction() {
|
|
443
|
+
if (!this._createOptionForm)
|
|
444
|
+
return null;
|
|
445
|
+
const tr = (key) => t(`filament-forms::components.select.actions.create_option.${key}`);
|
|
446
|
+
const action = Action.make('createOption').label(tr('label')).icon('m-plus').iconButton().color('gray')
|
|
447
|
+
.hidden(() => this.isDisabled())
|
|
448
|
+
.modalHeading(this._createOptionModalHeading ?? tr('modal.heading'))
|
|
449
|
+
.modalSubmitActionLabel(tr('modal.actions.create.label'))
|
|
450
|
+
.schema(this._createOptionForm)
|
|
451
|
+
.action(async ({ data }) => {
|
|
452
|
+
if (!this._createOptionUsing)
|
|
453
|
+
throw new Error(`Select [${this.getStatePath()}]: createOptionUsing() is required with createOptionForm().`);
|
|
454
|
+
const created = await this._createOptionUsing(data, this.context());
|
|
455
|
+
if (created === null || created === undefined || created === '')
|
|
456
|
+
return;
|
|
457
|
+
this.setState(this._multiple ? [...new Set([...this.selectedValues(), String(created)])] : String(created));
|
|
458
|
+
await this.callAfterStateUpdated((name, value) => writePath(this.livewire, [...this.container.getStatePath().split('.').filter(Boolean), ...name.split('.')], value));
|
|
459
|
+
});
|
|
460
|
+
// Multiple selection: "Create & create another" keeps the modal open on an empty form.
|
|
461
|
+
if (this._multiple)
|
|
462
|
+
action.extraModalFooterActions([Action.make('createAnother').label(tr('modal.actions.create_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)]);
|
|
463
|
+
return this._modifyCreateOptionAction?.(action) ?? action;
|
|
464
|
+
}
|
|
465
|
+
getEditOptionAction() {
|
|
466
|
+
if (!this._editOptionForm || this._multiple)
|
|
467
|
+
return null;
|
|
468
|
+
const tr = (key) => t(`filament-forms::components.select.actions.edit_option.${key}`);
|
|
469
|
+
const filled = () => { const state = this.context().state; return state !== null && state !== undefined && state !== ''; };
|
|
470
|
+
const action = Action.make('editOption').label(tr('label')).icon('m-pencil-square').iconButton().color('gray')
|
|
471
|
+
.hidden(async () => (await this.isDisabled()) || !filled())
|
|
472
|
+
.modalHeading(this._editOptionModalHeading ?? tr('modal.heading'))
|
|
473
|
+
.modalSubmitActionLabel(tr('modal.actions.save.label'))
|
|
474
|
+
.schema(this._editOptionForm)
|
|
475
|
+
.fillForm(async () => (await this._fillEditOptionForm?.(this.context())) ?? {})
|
|
476
|
+
.action(async ({ data }) => {
|
|
477
|
+
if (!this._updateOptionUsing)
|
|
478
|
+
throw new Error(`Select [${this.getStatePath()}]: updateOptionUsing() is required with editOptionForm().`);
|
|
479
|
+
await this._updateOptionUsing(data, this.context());
|
|
480
|
+
});
|
|
481
|
+
return this._modifyEditOptionAction?.(action) ?? action;
|
|
482
|
+
}
|
|
483
|
+
/** 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. */
|
|
486
|
+
async suffixHtml() {
|
|
487
|
+
const ctx = this.context();
|
|
488
|
+
const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.getKey() };
|
|
489
|
+
const buttons = [];
|
|
490
|
+
for (const action of [this.getCreateOptionAction(), this.getEditOptionAction()]) {
|
|
491
|
+
if (action && (await action.isVisible(ctx)))
|
|
492
|
+
buttons.push((await action.toLinkHtml(ctx, context, '')).replace(' wire:key=""', ''));
|
|
493
|
+
}
|
|
494
|
+
return buttons.length ? `<div class="fi-input-wrp-suffix"><div class="fi-input-wrp-actions">${buttons.join('')}</div></div>` : '';
|
|
495
|
+
}
|
|
496
|
+
async toHtml() {
|
|
497
|
+
const isDisabled = await this.isDisabled();
|
|
498
|
+
const resolved = await this.resolveOptions();
|
|
499
|
+
const suffix = await this.suffixHtml();
|
|
500
|
+
if (this._searchable) {
|
|
501
|
+
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
502
|
+
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 });
|
|
504
|
+
}
|
|
505
|
+
const select = new Attributes()
|
|
506
|
+
.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
|
+
.class(['fi-select-input']);
|
|
508
|
+
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' });
|
|
511
|
+
}
|
|
512
|
+
/** JavaScript select rendering. */
|
|
513
|
+
async toJsHtml(options, isDisabled, suffix = '') {
|
|
514
|
+
const state = this.context().state;
|
|
515
|
+
const hasState = !this._multiple && state !== null && state !== undefined && state !== '';
|
|
516
|
+
const selected = this._multiple ? await this.labelsOf(this.selectedValues()) : [];
|
|
517
|
+
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
518
|
+
// Required field: a screen-reader instruction ("Select an option.") is linked to the select.
|
|
519
|
+
const describedBy = [isRequired ? `${this.getId()}-required` : null, this.hasError() ? `${this.getId()}-error` : null].filter(Boolean).join(' ') || null;
|
|
520
|
+
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));
|
|
522
|
+
// Dynamic options come from a function, so they may change between requests.
|
|
523
|
+
const hasDynamicOptions = this._dynamic ?? typeof this._options === 'function';
|
|
524
|
+
const tr = (key, replace = {}) => t(`filament-forms::components.select.${key}`, replace);
|
|
525
|
+
const config = `selectFormComponent({
|
|
526
|
+
canOptionLabelsWrap: true,
|
|
527
|
+
canSelectPlaceholder: true,
|
|
528
|
+
clearButtonLabel: ${js(tr('actions.clear.label'))},
|
|
529
|
+
errorMessage: ${js(tr('error_message'))},
|
|
530
|
+
hasDynamicOptions: ${hasDynamicOptions ? 'true' : 'false'},
|
|
531
|
+
hasDynamicSearchResults: ${this._searchResultsUsing || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
|
|
532
|
+
hasInitialNoOptionsMessage: ${this._searchResultsUsing || this._hasNoInitialOptions ? 'false' : 'true'},
|
|
533
|
+
id: ${js(this.getId())},
|
|
534
|
+
initialOptionLabel: ${js(hasState ? await this.getOptionLabel() : null)},
|
|
535
|
+
initialOptionLabels: ${selected.length ? js(selected.map(([value, label]) => ({ label, value, isDisabled: false }))) : '[]'},
|
|
536
|
+
initialState: ${this._multiple ? (selected.length ? js(selected.map(([value]) => value)) : '[]') : js(hasState ? String(state) : null)},
|
|
537
|
+
isAutofocused: ${this._autofocus ? 'true' : 'false'},
|
|
538
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
539
|
+
isHtmlAllowed: false,
|
|
540
|
+
isMultiple: ${this._multiple ? 'true' : 'false'},
|
|
541
|
+
isReorderable: false,
|
|
542
|
+
isSearchable: true,
|
|
543
|
+
key: ${js(this.getKey())},
|
|
544
|
+
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'))},
|
|
550
|
+
options: ${forJs.length ? js(forJs) : '[]'},
|
|
551
|
+
optionsLimit: 50,
|
|
552
|
+
placeholder: ${js(this._placeholder ?? tr('placeholder'))},
|
|
553
|
+
position: null,
|
|
554
|
+
removeButtonLabel: ${js(tr('actions.remove_option.label'))},
|
|
555
|
+
searchDebounce: 1000,
|
|
556
|
+
searchingMessage: ${js(tr('searching_message'))},
|
|
557
|
+
searchLabel: ${js(tr('search_label'))},
|
|
558
|
+
searchPrompt: ${js(tr('search_prompt'))},
|
|
559
|
+
searchableOptionFields: ${js(['label'])},
|
|
560
|
+
state: $wire.$entangle(${js(this.getStatePath())}, ${this._live ? 'true' : 'false'}),
|
|
561
|
+
statePath: ${js(this.getStatePath())},
|
|
562
|
+
})`;
|
|
563
|
+
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
|
+
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="{
|
|
566
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
567
|
+
init() {
|
|
568
|
+
const container = $el.nextElementSibling
|
|
569
|
+
container.dispatchEvent(
|
|
570
|
+
new CustomEvent('set-select-property', {
|
|
571
|
+
detail: { isDisabled: this.isDisabled },
|
|
572
|
+
}),
|
|
573
|
+
)
|
|
574
|
+
},
|
|
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>`;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/** Column of a table Repeater: label and width. */
|
|
579
|
+
export class TableColumn {
|
|
580
|
+
label;
|
|
581
|
+
_width = null;
|
|
582
|
+
constructor(label) { this.label = label; }
|
|
583
|
+
static make(label) { return configured(new TableColumn(label)); }
|
|
584
|
+
width(width) { this._width = typeof width === 'number' ? `${width}px` : width; return this; }
|
|
585
|
+
getWidth() { return this._width; }
|
|
586
|
+
}
|
|
587
|
+
const ITEM_KEY = /^[A-Za-z0-9_-]{1,64}$/;
|
|
588
|
+
const isItemKey = (key) => ITEM_KEY.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
|
|
589
|
+
/**
|
|
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`.
|
|
593
|
+
*/
|
|
594
|
+
export class Repeater extends Field {
|
|
595
|
+
_schema = () => [];
|
|
596
|
+
_columns = [];
|
|
597
|
+
_defaultItems = 0;
|
|
598
|
+
_maxItems = 500;
|
|
599
|
+
_itemLinks = [];
|
|
600
|
+
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; }
|
|
603
|
+
/** Table display, one column per field. */
|
|
604
|
+
table(columns) { this._columns = columns; return this; }
|
|
605
|
+
defaultItems(count) { this._defaultItems = count; return this; }
|
|
606
|
+
maxItems(count) { this._maxItems = count; return this; }
|
|
607
|
+
_reorderable = true;
|
|
608
|
+
/** `false`: items keep their order (e.g. a relationship without an order column). */
|
|
609
|
+
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
610
|
+
/** Link shown on each row (an item action pointing to a URL). */
|
|
611
|
+
extraItemLink(resolver) { this._itemLinks.push(resolver); return this; }
|
|
612
|
+
/** Well-formed items only: keys come from the browser, so only simple keys are kept. */
|
|
613
|
+
getItems() {
|
|
614
|
+
const state = this.context().state;
|
|
615
|
+
if (!state || typeof state !== 'object' || Array.isArray(state))
|
|
616
|
+
return {};
|
|
617
|
+
return Object.fromEntries(Object.entries(state).filter(([key, item]) => isItemKey(key) && item !== null && typeof item === 'object' && !Array.isArray(item)).slice(0, this._maxItems));
|
|
618
|
+
}
|
|
619
|
+
setItems(items) { writePath(this.livewire, this.getStatePath().split('.'), items); }
|
|
620
|
+
itemDefaults() { return Object.fromEntries(this._schema().map((field) => [field.getName(), field.getDefaultState()])); }
|
|
621
|
+
getDefaultState() { return Object.fromEntries(Array.from({ length: this._defaultItems }, () => [randomUUID(), this.itemDefaults()])); }
|
|
622
|
+
/** Relationship records → `record-<id>` items, limited to the declared fields. */
|
|
623
|
+
hydrateState(value) {
|
|
624
|
+
if (!Array.isArray(value))
|
|
625
|
+
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())]))]));
|
|
628
|
+
}
|
|
629
|
+
dehydrateState() { return Object.fromEntries(Object.keys(this.getItems()).map((key) => [key, {}])); }
|
|
630
|
+
getChildSchemas() {
|
|
631
|
+
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()));
|
|
633
|
+
}
|
|
634
|
+
async getValidationRules() {
|
|
635
|
+
const rules = [];
|
|
636
|
+
if (await this.isRequired())
|
|
637
|
+
rules.push({ name: 'required', passes: () => Object.keys(this.getItems()).length > 0, message: (a) => `The ${a} field is required.` });
|
|
638
|
+
return [...rules, ...this._rules];
|
|
639
|
+
}
|
|
640
|
+
getActions() {
|
|
641
|
+
const move = (offset) => ({ arguments: args }) => {
|
|
642
|
+
const items = this.getItems();
|
|
643
|
+
const keys = Object.keys(items);
|
|
644
|
+
const from = keys.indexOf(String(args.item));
|
|
645
|
+
const to = from + offset;
|
|
646
|
+
if (from < 0 || to < 0 || to >= keys.length)
|
|
647
|
+
return;
|
|
648
|
+
[keys[from], keys[to]] = [keys[to], keys[from]];
|
|
649
|
+
this.setItems(Object.fromEntries(keys.map((key) => [key, items[key]])));
|
|
650
|
+
};
|
|
651
|
+
return [
|
|
652
|
+
...this.getHintActions(),
|
|
653
|
+
Action.make('add').action(() => { const items = this.getItems(); if (Object.keys(items).length >= this._maxItems)
|
|
654
|
+
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))
|
|
656
|
+
return; delete items[key]; this.setItems(items); }),
|
|
657
|
+
Action.make('moveUp').action(move(-1)),
|
|
658
|
+
Action.make('moveDown').action(move(1)),
|
|
659
|
+
// Drag and drop: the browser sends the new order, only accepted if it is a permutation of the existing keys.
|
|
660
|
+
Action.make('reorder').action(({ arguments: args }) => {
|
|
661
|
+
const items = this.getItems();
|
|
662
|
+
const keys = Object.keys(items);
|
|
663
|
+
const order = Array.isArray(args.items) ? args.items.map(String) : [];
|
|
664
|
+
if (order.length !== keys.length || new Set(order).size !== keys.length || !order.every((key) => Object.hasOwn(items, key)))
|
|
665
|
+
return;
|
|
666
|
+
this.setItems(Object.fromEntries(order.map((key) => [key, items[key]])));
|
|
667
|
+
}),
|
|
668
|
+
];
|
|
669
|
+
}
|
|
670
|
+
async toHtml() {
|
|
671
|
+
if (!colorManager)
|
|
672
|
+
throw new Error('No color manager: call useColorManager().');
|
|
673
|
+
const id = this.getId();
|
|
674
|
+
const key = this.getKey();
|
|
675
|
+
const livewireKey = this.getLivewireKey() ?? '';
|
|
676
|
+
const items = this.getItems();
|
|
677
|
+
const keys = Object.keys(items);
|
|
678
|
+
const schemas = this.getChildSchemas();
|
|
679
|
+
const tr = (name, replace = {}) => t(`filament-forms::components.repeater.${name}`, replace);
|
|
680
|
+
const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: key };
|
|
681
|
+
const expression = (action, args) => `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
|
|
682
|
+
const button = 'fi-ac-icon-btn-action fi-icon-btn';
|
|
683
|
+
const loading = (target) => `${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': target }))}`;
|
|
684
|
+
const actionIcon = (name, target) => icon(name, { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': target }) });
|
|
685
|
+
const itemLabel = tr('reordering.item');
|
|
686
|
+
const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
|
|
687
|
+
const rows = [];
|
|
688
|
+
// No reorder column while there is only one row.
|
|
689
|
+
const hasReorderColumn = this._reorderable && keys.length > 1;
|
|
690
|
+
for (const [index, itemKey] of keys.entries()) {
|
|
691
|
+
const moveButton = (action, label, iconName, direction, disabled) => {
|
|
692
|
+
const expr = expression(action, { item: itemKey });
|
|
693
|
+
return `<div x-on:click.stop class="fi-sr-only"><button title="${e(label)}" ${disabled ? 'aria-disabled="true" ' : ''}aria-label="${e(label)}" ${disabled ? 'disabled="disabled" ' : ''}tabindex="-1" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${button}${disabled ? ' fi-disabled' : ''} fi-size-sm" data-reorder-hidden="data-reorder-hidden" data-reorder-direction="${direction}" data-reorder-action="${e(expr)}" aria-describedby="${e(descriptionId(itemKey))}" wire:click="${e(expr)}">${actionIcon(iconName, expr)}${loading(expr)}</button></div>`;
|
|
694
|
+
};
|
|
695
|
+
const cells = [];
|
|
696
|
+
for (const field of schemas[index].getComponents())
|
|
697
|
+
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)
|
|
699
|
+
.map((link) => `<div x-on:click.stop><a href="${e(link.url)}" target="_blank" x-tooltip="{
|
|
700
|
+
content: ${e(js(link.label))},
|
|
701
|
+
theme: $store.theme,
|
|
702
|
+
allowHTML: false,
|
|
703
|
+
}" aria-label="${e(link.label)}" class="${button} fi-size-sm">${icon(link.icon)}</a></div>`);
|
|
704
|
+
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>`);
|
|
706
|
+
}
|
|
707
|
+
const head = this._columns.map((column) => `<th scope="col"${column.getWidth() ? ` style="width: ${e(column.getWidth())};"` : ''}>${e(column.label)}</th>`).join('');
|
|
708
|
+
const addExpr = expression('add', null);
|
|
709
|
+
const label = await this.getLabel();
|
|
710
|
+
const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
|
|
711
|
+
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>`;
|
|
713
|
+
return this.wrap(table, { labelAsDiv: true });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
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>`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/** Native `datetime-local` input: the saved state is `YYYY-MM-DD HH:MM:SS`. */
|
|
737
|
+
export class DateTimePicker extends Field {
|
|
738
|
+
_seconds = true;
|
|
739
|
+
static make(name) { return configured(new DateTimePicker(name)); }
|
|
740
|
+
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;
|
|
746
|
+
}
|
|
747
|
+
async toHtml() {
|
|
748
|
+
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']);
|
|
755
|
+
const wrapper = new Attributes()
|
|
756
|
+
.merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
|
|
757
|
+
.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>`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/** Single choice as radio buttons. */
|
|
762
|
+
export class Radio extends Field {
|
|
763
|
+
_options = [];
|
|
764
|
+
_descriptions = {};
|
|
765
|
+
_inline = false;
|
|
766
|
+
_live = false;
|
|
767
|
+
static make(name) { return configured(new Radio(name)); }
|
|
768
|
+
options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
|
|
769
|
+
/** Helper text per value, under the option label. */
|
|
770
|
+
descriptions(descriptions) { this._descriptions = descriptions; return this; }
|
|
771
|
+
/** Options side by side. */
|
|
772
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
773
|
+
live(condition = true) { this._live = condition; return this; }
|
|
774
|
+
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.` }];
|
|
776
|
+
}
|
|
777
|
+
async toHtml() {
|
|
778
|
+
const id = this.getId();
|
|
779
|
+
const isDisabled = await this.isDisabled();
|
|
780
|
+
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
781
|
+
const container = new Attributes()
|
|
782
|
+
.merge({ 'aria-describedby': this.hasError() ? `${id}-error` : null, 'aria-labelledby': `${id}-label`, 'aria-required': isRequired ? 'true' : null, role: 'radiogroup' })
|
|
783
|
+
.class(['fi-fo-radio', this._inline ? 'fi-inline' : null]);
|
|
784
|
+
const items = this._options.map(([value, label]) => {
|
|
785
|
+
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() })
|
|
787
|
+
.class(['fi-radio-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
788
|
+
const description = this._descriptions[value] ? `<p class="fi-fo-radio-label-description">${e(this._descriptions[value])}</p>` : '';
|
|
789
|
+
return `<label class="fi-fo-radio-label"><input ${input.toHtml()} /><div class="fi-fo-radio-label-text"><p>${e(label)}</p>${description}</div></label>`;
|
|
790
|
+
});
|
|
791
|
+
return this.wrap(`<div ${container.toHtml()}>${items.join('')}</div>`, { labelAsDiv: true });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
export class Textarea extends Field {
|
|
795
|
+
_rows = null;
|
|
796
|
+
_maxLength = null;
|
|
797
|
+
static make(name) { return configured(new Textarea(name)); }
|
|
798
|
+
rows(rows) { this._rows = rows; return this; }
|
|
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.` }); }
|
|
800
|
+
async toHtml() {
|
|
801
|
+
const isDisabled = await this.isDisabled();
|
|
802
|
+
// Initial height: 1.5rem per row plus padding.
|
|
803
|
+
const height = (this._rows ?? 2) * 1.5 + 0.75;
|
|
804
|
+
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(),
|
|
807
|
+
});
|
|
808
|
+
const wrapper = new Attributes().class(['fi-fo-textarea', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
809
|
+
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
|
+
initialHeight: ${height ?? 'null'},
|
|
811
|
+
shouldAutosize: false,
|
|
812
|
+
state: $wire.$entangle(${e(js(this.getStatePath()))}),
|
|
813
|
+
})" x-model="state" ${textarea.toHtml()}></textarea></div></div></div>`, { wrapperClass: 'fi-fo-textarea-wrp' });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
/** Tag list input: the state is an array of strings. */
|
|
817
|
+
export class TagsInput extends Field {
|
|
818
|
+
_placeholder = null;
|
|
819
|
+
_suggestions = [];
|
|
820
|
+
_splitKeys = [];
|
|
821
|
+
static make(name) { return configured(new TagsInput(name)); }
|
|
822
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
823
|
+
/** Values suggested while typing. */
|
|
824
|
+
suggestions(suggestions) { this._suggestions = suggestions.map(String).slice(0, 1000); return this; }
|
|
825
|
+
/** Keys that confirm the current tag, besides Enter (`['Tab', ',']`). */
|
|
826
|
+
splitKeys(keys) { this._splitKeys = keys; return this; }
|
|
827
|
+
getDefaultState() { return this._default ?? []; }
|
|
828
|
+
hydrateState(value) {
|
|
829
|
+
if (typeof value === 'string') {
|
|
830
|
+
try {
|
|
831
|
+
value = JSON.parse(value);
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
value = value === '' ? [] : [value];
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map(String) : [];
|
|
838
|
+
}
|
|
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
|
+
async toHtml() {
|
|
842
|
+
if (!colorManager)
|
|
843
|
+
throw new Error('No color manager: call useColorManager().');
|
|
844
|
+
const id = this.getId();
|
|
845
|
+
const isDisabled = await this.isDisabled();
|
|
846
|
+
const label = await this.getLabel();
|
|
847
|
+
const wrapper = new Attributes()
|
|
848
|
+
.merge({ 'aria-label': label, role: 'group', 'x-on:focus-input.stop': "$el.querySelector('input')?.focus()" })
|
|
849
|
+
.class(['fi-fo-tags-input', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
850
|
+
const input = new Attributes()
|
|
851
|
+
.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
|
+
.class(['fi-input']);
|
|
853
|
+
const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', 'primary')].join(' ');
|
|
854
|
+
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
|
+
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
|
+
state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
|
|
857
|
+
splitKeys: ${this._splitKeys.length ? e(js(this._splitKeys)) : '[]'},
|
|
858
|
+
tagAddedMessage: ${e(js(t('filament-forms::components.tags_input.tag_added')))},
|
|
859
|
+
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' });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/** On/off switch. */
|
|
864
|
+
export class Toggle extends Field {
|
|
865
|
+
static make(name) { return configured(new Toggle(name)); }
|
|
866
|
+
getDefaultState() { return this._default ?? false; }
|
|
867
|
+
hydrateState(value) { return value === true || value === 1 || value === '1'; }
|
|
868
|
+
async toHtml() {
|
|
869
|
+
if (!colorManager)
|
|
870
|
+
throw new Error('No color manager: call useColorManager().');
|
|
871
|
+
const on = this.context().state === true;
|
|
872
|
+
const colors = colorManager.getComponentClasses('toggle', 'primary').join(' ');
|
|
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']);
|
|
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>` : ''}` });
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/** Single choice shown as buttons. */
|
|
879
|
+
export class ToggleButtons extends Field {
|
|
880
|
+
_options = [];
|
|
881
|
+
_inline = false;
|
|
882
|
+
_grouped = false;
|
|
883
|
+
_colors = {};
|
|
884
|
+
_icons = {};
|
|
885
|
+
static make(name) { return configured(new ToggleButtons(name)); }
|
|
886
|
+
_live = false;
|
|
887
|
+
/** Sends the state to the server as soon as it changes. */
|
|
888
|
+
live(condition = true) { this._live = condition; return this; }
|
|
889
|
+
/** Color per value (e.g. from an enum). */
|
|
890
|
+
colors(colors) { this._colors = colors; return this; }
|
|
891
|
+
/** Icon per value. */
|
|
892
|
+
icons(icons) { this._icons = icons; return this; }
|
|
893
|
+
options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
|
|
894
|
+
/** Buttons side by side. */
|
|
895
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
896
|
+
/** Buttons joined into a single group. */
|
|
897
|
+
grouped(condition = true) { this._grouped = condition; return this; }
|
|
898
|
+
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.` }];
|
|
900
|
+
}
|
|
901
|
+
async toHtml() {
|
|
902
|
+
if (!colorManager)
|
|
903
|
+
throw new Error('No color manager: call useColorManager().');
|
|
904
|
+
const id = this.getId();
|
|
905
|
+
const isDisabled = await this.isDisabled();
|
|
906
|
+
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
907
|
+
const describedBy = [isRequired ? `${id}-required` : null, this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
|
|
908
|
+
const container = new Attributes()
|
|
909
|
+
.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]);
|
|
911
|
+
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(' ');
|
|
913
|
+
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>`;
|
|
916
|
+
return this._grouped ? html : `<div class="fi-fo-toggle-buttons-btn-ctn">${html}</div>`;
|
|
917
|
+
});
|
|
918
|
+
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 });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
// Server-side TipTap schema: the nodes and marks the rich editor's default toolbar can produce.
|
|
923
|
+
// Not supported yet: grids, custom blocks, mentions, merge tags, text color, "lead" and "small".
|
|
924
|
+
const RICH_EXTENSIONS = [StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'start' }), Subscript, Superscript, Highlight, Image, TableKit];
|
|
925
|
+
const isRichDocument = (value) => !!value && typeof value === 'object' && value.type === 'doc';
|
|
926
|
+
const richText = (node) => (typeof node?.text === 'string' ? node.text : '') + (Array.isArray(node?.content) ? node.content.map(richText).join('') : '');
|
|
927
|
+
const hasRichContent = (node) => node?.type === 'image' || node?.type === 'horizontalRule' || (Array.isArray(node?.content) && node.content.some(hasRichContent));
|
|
928
|
+
/** Stored HTML → TipTap document. Parsing drops anything the schema does not know (scripts, event attributes, `javascript:` links). */
|
|
929
|
+
export function richHtmlToDocument(html) { return generateJSON(html ?? '', RICH_EXTENSIONS); }
|
|
930
|
+
/**
|
|
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.
|
|
933
|
+
*/
|
|
934
|
+
export function richDocumentToHtml(document) {
|
|
935
|
+
if (!isRichDocument(document))
|
|
936
|
+
return null;
|
|
937
|
+
try {
|
|
938
|
+
const clean = generateJSON(generateHTML(document, RICH_EXTENSIONS), RICH_EXTENSIONS);
|
|
939
|
+
return richText(clean).trim() === '' && !hasRichContent(clean) ? null : generateHTML(clean, RICH_EXTENSIONS);
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Rich text editor: the browser works on a TipTap document, the database receives HTML.
|
|
947
|
+
* Not supported yet: attachments, custom blocks, mentions.
|
|
948
|
+
*/
|
|
949
|
+
export class RichEditor extends Field {
|
|
950
|
+
static make(name) { return configured(new RichEditor(name)); }
|
|
951
|
+
hydrateState(value) { return isRichDocument(value) ? value : richHtmlToDocument(typeof value === 'string' ? value : ''); }
|
|
952
|
+
getDefaultState() { return richHtmlToDocument(typeof this._default === 'string' ? this._default : ''); }
|
|
953
|
+
dehydrateState(state) { return richDocumentToHtml(state); }
|
|
954
|
+
async getValidationRules() {
|
|
955
|
+
const rules = [];
|
|
956
|
+
if (await this.isRequired())
|
|
957
|
+
rules.push({ name: 'required', passes: (value) => richDocumentToHtml(value) !== null, message: (a) => `The ${a} field is required.` });
|
|
958
|
+
return [...rules, ...this._rules];
|
|
959
|
+
}
|
|
960
|
+
getExposedMethods() { return ['getMentionSearchResultsForJs', 'getMentionLabelsForJs', 'getCustomBlockPreviewsForJs', 'getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
|
|
961
|
+
getMentionSearchResultsForJs() { return []; }
|
|
962
|
+
getMentionLabelsForJs() { return []; }
|
|
963
|
+
getCustomBlockPreviewsForJs() { return []; }
|
|
964
|
+
getUploadedFileAttachmentTemporaryUrl() { return null; }
|
|
965
|
+
saveUploadedFileAttachmentAndGetUrl() { return null; }
|
|
966
|
+
/** Toolbar "link" action: a modal (URL, new tab), then commands sent back to the browser editor. */
|
|
967
|
+
getActions() {
|
|
968
|
+
const tr = (name) => t(`filament-forms::components.rich_editor.actions.link.${name}`);
|
|
969
|
+
const key = this.getKey();
|
|
970
|
+
return [
|
|
971
|
+
...this.getHintActions(),
|
|
972
|
+
Action.make('link').label(tr('label')).modalHeading(tr('modal.heading')).modalWidth('lg')
|
|
973
|
+
.fillForm(({ arguments: args }) => ({ url: typeof args.url === 'string' ? args.url : null, shouldOpenInNewTab: args.shouldOpenInNewTab === true }))
|
|
974
|
+
.schema(() => [
|
|
975
|
+
TextInput.make('url').label(tr('modal.form.url.label'))
|
|
976
|
+
// Only URLs that cannot execute code are accepted.
|
|
977
|
+
.rule({ name: 'url', passes: (value) => /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(String(value)), message: (a) => `The ${a} field must be a valid URL.` }),
|
|
978
|
+
Checkbox.make('shouldOpenInNewTab').label(tr('modal.form.should_open_in_new_tab.label')),
|
|
979
|
+
])
|
|
980
|
+
.action(({ livewire, arguments: args, data }) => {
|
|
981
|
+
const selection = (args.editorSelection ?? null);
|
|
982
|
+
const single = selection?.head === selection?.anchor;
|
|
983
|
+
const url = typeof data.url === 'string' && data.url !== '' ? data.url : null;
|
|
984
|
+
const commands = [
|
|
985
|
+
...(single ? [{ name: 'extendMarkRange', arguments: ['link'] }] : []),
|
|
986
|
+
url ? { name: 'setLink', arguments: [{ href: url, target: data.shouldOpenInNewTab ? '_blank' : null }] } : { name: 'unsetLink', arguments: [] },
|
|
987
|
+
];
|
|
988
|
+
livewire.dispatch('run-rich-editor-commands', { awaitSchemaComponent: key, livewireId: livewire.id, key, editorSelection: selection, commands });
|
|
989
|
+
}),
|
|
990
|
+
];
|
|
991
|
+
}
|
|
992
|
+
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 });
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/** Color picker (hex format). */
|
|
997
|
+
export class ColorPicker extends Field {
|
|
998
|
+
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.` });
|
|
1004
|
+
}
|
|
1005
|
+
async toHtml() {
|
|
1006
|
+
const isDisabled = await this.isDisabled();
|
|
1007
|
+
const state = this.context().state;
|
|
1008
|
+
const id = this.getId();
|
|
1009
|
+
const key = this.getLivewireKey() ?? '';
|
|
1010
|
+
// Key hash over the serialized `[isDisabled]` options.
|
|
1011
|
+
const hash = createHash('md5').update(`a:1:{i:0;b:${isDisabled ? 1 : 0};}`).digest('hex');
|
|
1012
|
+
const input = new Attributes().merge({
|
|
1013
|
+
'aria-controls': `${id}-panel`, 'aria-expanded': 'false', 'aria-haspopup': 'dialog', 'aria-invalid': this.hasError() ? 'true' : null, autocomplete: 'off',
|
|
1014
|
+
disabled: isDisabled, id, required: (await this.isRequired()) && !isDisabled, role: 'combobox', type: 'text', 'x-model': 'state',
|
|
1015
|
+
}).class(['fi-input']);
|
|
1016
|
+
const wrapper = new Attributes().merge({ 'x-on:focus-input.stop': "$el.querySelector('input')?.focus()" }).class(['fi-fo-color-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
1017
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div x-load x-load-src="${e(asset('color-picker'))}" x-data="colorPickerFormComponent({
|
|
1018
|
+
isAutofocused: false,
|
|
1019
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1020
|
+
isLive: false,
|
|
1021
|
+
isLiveDebounced: false,
|
|
1022
|
+
isLiveOnBlur: false,
|
|
1023
|
+
liveDebounce: null,
|
|
1024
|
+
state: $wire.$entangle(${e(js(this.getStatePath()))}),
|
|
1025
|
+
})" x-on:dropdown-escape="if (isOpen()) { closePanelAndRestoreFocus(); $event.preventDefault() }" x-on:keydown.esc="isOpen() && $event.stopPropagation()" x-on:focusout="if (isOpen() && ! $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
|
+
'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>`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
export class Checkbox extends Field {
|
|
1031
|
+
static make(name) { return configured(new Checkbox(name)); }
|
|
1032
|
+
getDefaultState() { return this._default ?? false; }
|
|
1033
|
+
async toHtml() {
|
|
1034
|
+
const input = new Attributes()
|
|
1035
|
+
.merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(), 'wire:model': this.getStatePath() })
|
|
1036
|
+
.class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
1037
|
+
return this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/** Checkboxes for a list of values (no search or "select all" yet). */
|
|
1041
|
+
export class CheckboxList extends Field {
|
|
1042
|
+
_options = [];
|
|
1043
|
+
_optionColumns = null;
|
|
1044
|
+
static make(name) { return configured(new CheckboxList(name)); }
|
|
1045
|
+
options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
|
|
1046
|
+
/** Number of columns the options are spread over (from `lg`). */
|
|
1047
|
+
columns(columns) { this._optionColumns = columns; return this; }
|
|
1048
|
+
getDefaultState() { return this._default ?? []; }
|
|
1049
|
+
hydrateState(value) {
|
|
1050
|
+
let list = value;
|
|
1051
|
+
if (typeof value === 'string') {
|
|
1052
|
+
try {
|
|
1053
|
+
list = JSON.parse(value);
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
list = [];
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return (Array.isArray(list) ? list : []).map(String);
|
|
1060
|
+
}
|
|
1061
|
+
/** Only offered values are kept, without duplicates, in option order. */
|
|
1062
|
+
dehydrateState(state) {
|
|
1063
|
+
const chosen = new Set((Array.isArray(state) ? state : []).map(String));
|
|
1064
|
+
return this._options.map(([value]) => value).filter((value) => chosen.has(value));
|
|
1065
|
+
}
|
|
1066
|
+
async getValidationRules() {
|
|
1067
|
+
const known = new Set(this._options.map(([value]) => value));
|
|
1068
|
+
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
|
+
}
|
|
1070
|
+
async toHtml() {
|
|
1071
|
+
const isDisabled = await this.isDisabled();
|
|
1072
|
+
const key = this.getLivewireKey() ?? '';
|
|
1073
|
+
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>`;
|
|
1076
|
+
});
|
|
1077
|
+
const columns = this._optionColumns ?? 1;
|
|
1078
|
+
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;"';
|
|
1079
|
+
return this.wrap(`<div role="group" x-load x-load-src="${e(asset('checkbox-list'))}" x-data="checkboxListFormComponent({
|
|
1080
|
+
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 });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
/** Free-form key/value pairs, saved as an object. */
|
|
1085
|
+
export class KeyValue extends Field {
|
|
1086
|
+
_keyLabel = null;
|
|
1087
|
+
_valueLabel = null;
|
|
1088
|
+
_reorderable = false;
|
|
1089
|
+
static make(name) { return configured(new KeyValue(name)); }
|
|
1090
|
+
keyLabel(label) { this._keyLabel = label; return this; }
|
|
1091
|
+
valueLabel(label) { this._valueLabel = label; return this; }
|
|
1092
|
+
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
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.
|
|
1095
|
+
getDefaultState() { return KeyValue.toPairs(this._default); }
|
|
1096
|
+
static toPairs(value) {
|
|
1097
|
+
let object = value;
|
|
1098
|
+
if (typeof value === 'string') {
|
|
1099
|
+
try {
|
|
1100
|
+
object = JSON.parse(value);
|
|
1101
|
+
}
|
|
1102
|
+
catch {
|
|
1103
|
+
object = {};
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return object && typeof object === 'object' && !Array.isArray(object) ? Object.entries(object).map(([key, v]) => ({ key, value: v })) : [];
|
|
1107
|
+
}
|
|
1108
|
+
hydrateState(value) { return KeyValue.toPairs(value); }
|
|
1109
|
+
/** Flat object of strings: empty keys, nested objects and dangerous keys are dropped; 200 pairs at most. */
|
|
1110
|
+
dehydrateState(state) {
|
|
1111
|
+
const out = {};
|
|
1112
|
+
if (!Array.isArray(state))
|
|
1113
|
+
return out;
|
|
1114
|
+
for (const pair of state.slice(0, 200)) {
|
|
1115
|
+
if (!pair || typeof pair !== 'object' || typeof pair.key !== 'string')
|
|
1116
|
+
continue;
|
|
1117
|
+
const value = pair.value;
|
|
1118
|
+
const name = pair.key.trim().slice(0, 255);
|
|
1119
|
+
if (!name || name === '__proto__' || name === 'constructor' || name === 'prototype')
|
|
1120
|
+
continue;
|
|
1121
|
+
out[name] = value === null || value === undefined || typeof value === 'object' ? null : String(value).slice(0, 5000);
|
|
1122
|
+
}
|
|
1123
|
+
return out;
|
|
1124
|
+
}
|
|
1125
|
+
async toHtml() {
|
|
1126
|
+
const id = this.getId();
|
|
1127
|
+
const tr = (key) => t(`filament-forms::components.key_value.${key}`);
|
|
1128
|
+
const keyLabel = this._keyLabel ?? tr('fields.key.label'), valueLabel = this._valueLabel ?? tr('fields.value.label');
|
|
1129
|
+
const reordering = { row: tr('reordering.row'), position: tr('reordering.position'), moved: tr('reordering.moved') };
|
|
1130
|
+
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([keyLabel, valueLabel, this._reorderable])).digest('hex')}`;
|
|
1131
|
+
const iconButton = 'type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled"';
|
|
1132
|
+
const dangerClasses = ['fi-ac-icon-btn-action', 'fi-icon-btn', 'fi-size-sm', ...(colorManager?.getComponentClasses('icon-button', 'danger') ?? [])].join(' ');
|
|
1133
|
+
const reorderCell = this._reorderable ? `<td class="fi-has-action"><span class="fi-sr-only" data-reorder-description x-bind:id="$id('key-value-row') + '-' + row.id" x-text="rowDescription(row, index)"></span><div x-sortable-handle class="fi-fo-key-value-table-row-sortable-handle"><button title="${e(tr('actions.reorder.label'))}" aria-label="${e(tr('actions.reorder.label'))}" ${iconButton} class="fi-ac-icon-btn-action fi-icon-btn fi-size-sm" x-bind:tabindex="rows.length > 1 ? -1 : 0" x-bind:aria-hidden="rows.length > 1 ? 'true' : null">${icon('m-arrows-up-down')}</button></div><div class="fi-sr-only"><button type="button" tabindex="-1" data-reorder-hidden data-reorder-direction="up" x-on:click.stop="moveRow(row.id, index - 1)" x-bind:disabled="index === 0" x-bind:aria-describedby="$id('key-value-row') + '-' + row.id">${e(tr('actions.move_up.label'))}</button><button type="button" tabindex="-1" data-reorder-hidden data-reorder-direction="down" x-on:click.stop="moveRow(row.id, index + 1)" x-bind:disabled="index === rows.length - 1" x-bind:aria-describedby="$id('key-value-row') + '-' + row.id">${e(tr('actions.move_down.label'))}</button></div></td>` : '';
|
|
1134
|
+
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
|
+
state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
|
|
1136
|
+
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' });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
/** Developer-written JavaScript expression (e.g. a Slider tooltip formatter), inserted as is: never put user data in it. */
|
|
1141
|
+
export class RawJs {
|
|
1142
|
+
js;
|
|
1143
|
+
constructor(js) { this.js = js; }
|
|
1144
|
+
static make(js) { return new RawJs(js); }
|
|
1145
|
+
toString() { return this.js; }
|
|
1146
|
+
}
|
|
1147
|
+
/** Simple CSS length (`10rem`, `300px`, `50%`); anything else is rejected, since it ends up in a `style` attribute. */
|
|
1148
|
+
const cssLength = (value) => (value !== null && /^\d+(\.\d+)?(px|rem|em|%|vh|vw)$/.test(value.trim()) ? value.trim() : null);
|
|
1149
|
+
/** Field accessibility attributes: helper text and error message. */
|
|
1150
|
+
function accessibilityOf(field, options = {}) {
|
|
1151
|
+
const ids = [field.getHelperTextId(), field.hasError() ? `${field.getId()}-error` : null].filter(Boolean);
|
|
1152
|
+
return { 'aria-describedby': ids.length ? ids.join(' ') : null, 'aria-invalid': options.withInvalid !== false && field.hasError() ? 'true' : null };
|
|
1153
|
+
}
|
|
1154
|
+
/** Second `$entangle()` argument: true only for a plain `live()` field (no `onBlur`, no `debounce`). */
|
|
1155
|
+
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']];
|
|
1157
|
+
/**
|
|
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.
|
|
1161
|
+
*/
|
|
1162
|
+
export class MarkdownEditor extends Field {
|
|
1163
|
+
_toolbarButtons = MARKDOWN_TOOLBAR;
|
|
1164
|
+
_minHeight = '10rem';
|
|
1165
|
+
_maxHeight = null;
|
|
1166
|
+
_placeholder = null;
|
|
1167
|
+
_live = null;
|
|
1168
|
+
static make(name) { return configured(new MarkdownEditor(name)); }
|
|
1169
|
+
/** Toolbar buttons, grouped (`[['bold', 'italic'], ['undo', 'redo']]`) or flat. */
|
|
1170
|
+
toolbarButtons(buttons) { this._toolbarButtons = buttons; return this; }
|
|
1171
|
+
/** Removes buttons; with no argument, removes them all. */
|
|
1172
|
+
disableToolbarButtons(names = []) {
|
|
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))) : [];
|
|
1174
|
+
return this;
|
|
1175
|
+
}
|
|
1176
|
+
disableAllToolbarButtons(condition = true) { if (condition)
|
|
1177
|
+
this._toolbarButtons = []; return this; }
|
|
1178
|
+
/** Adds buttons (in a separate group). */
|
|
1179
|
+
enableToolbarButtons(names) { this._toolbarButtons = [...this._toolbarButtons, names]; return this; }
|
|
1180
|
+
getToolbarButtons() { return this._toolbarButtons; }
|
|
1181
|
+
hasToolbarButton(name) { return this._toolbarButtons.some((group) => (Array.isArray(group) ? group.includes(name) : group === name)); }
|
|
1182
|
+
/** Minimum height of the editing area (`10rem` by default). */
|
|
1183
|
+
minHeight(height) { this._minHeight = cssLength(height); return this; }
|
|
1184
|
+
maxHeight(height) { this._maxHeight = cssLength(height); return this; }
|
|
1185
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
1186
|
+
/** Sends the state to the server while typing (`debounce` in milliseconds), or on blur with `onBlur`. */
|
|
1187
|
+
live(options = {}) { this._live = { onBlur: options.onBlur === true, debounce: options.debounce ?? null }; return this; }
|
|
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.` }); }
|
|
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.` }); }
|
|
1190
|
+
hydrateState(value) { return typeof value === 'string' ? value : null; }
|
|
1191
|
+
async getValidationRules() {
|
|
1192
|
+
return [...(await super.getValidationRules()), { name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }];
|
|
1193
|
+
}
|
|
1194
|
+
getExposedMethods() { return ['getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
|
|
1195
|
+
getUploadedFileAttachmentTemporaryUrl() { return null; }
|
|
1196
|
+
saveUploadedFileAttachmentAndGetUrl() { return null; }
|
|
1197
|
+
async toHtml() {
|
|
1198
|
+
const id = this.getId();
|
|
1199
|
+
const accessibility = accessibilityOf(this);
|
|
1200
|
+
if (await this.isDisabled()) {
|
|
1201
|
+
const state = this.context().state;
|
|
1202
|
+
const attributes = new Attributes().merge({ 'aria-labelledby': `${id}-label`, 'aria-describedby': accessibility['aria-describedby'], id, role: 'group', tabindex: this._maxHeight ? '0' : null })
|
|
1203
|
+
.style([this._minHeight ? `--min-height: ${this._minHeight}` : null, this._maxHeight ? `--max-height: ${this._maxHeight}` : null])
|
|
1204
|
+
.class(['fi-fo-markdown-editor', 'fi-disabled', 'fi-prose']);
|
|
1205
|
+
const content = typeof state === 'string' && state.trim() !== ''
|
|
1206
|
+
? state.split(/\n{2,}/).map((paragraph) => `<p>${e(paragraph.trim()).replace(/\n/g, '<br />')}</p>`).join('')
|
|
1207
|
+
: `<span class="fi-fo-markdown-editor-placeholder">${e(this._placeholder ?? '')}</span>`;
|
|
1208
|
+
return this.wrap(`<div ${attributes.toHtml()}>${content}</div>`, { labelAsDiv: true });
|
|
1209
|
+
}
|
|
1210
|
+
const tr = (key) => t(`filament-forms::components.markdown_editor.${key}`);
|
|
1211
|
+
const translations = {
|
|
1212
|
+
file_attachments_accepted_file_types_message: tr('file_attachments_accepted_file_types_message'),
|
|
1213
|
+
file_attachments_max_size_message: tr('file_attachments_max_size_message'),
|
|
1214
|
+
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
|
+
};
|
|
1216
|
+
const config = `markdownEditorFormComponent({
|
|
1217
|
+
canAttachFiles: false,
|
|
1218
|
+
isLiveDebounced: ${this._live?.debounce !== null && this._live?.debounce !== undefined ? 'true' : 'false'},
|
|
1219
|
+
isLiveOnBlur: ${this._live?.onBlur ? 'true' : 'false'},
|
|
1220
|
+
label: ${js(await this.getLabel())},
|
|
1221
|
+
liveDebounce: ${js(this._live?.debounce ?? null)},
|
|
1222
|
+
maxHeight: ${js(this._maxHeight)},
|
|
1223
|
+
minHeight: ${js(this._minHeight)},
|
|
1224
|
+
placeholder: ${js(this._placeholder)},
|
|
1225
|
+
state: $wire.$entangle(${js(this.getStatePath())}, ${entangleLive(this._live)}),
|
|
1226
|
+
toolbarButtons: ${js(this._toolbarButtons)},
|
|
1227
|
+
translations: ${js(translations)},
|
|
1228
|
+
uploadFileAttachmentUsing: async (file, onSuccess, onError) => {
|
|
1229
|
+
return onError()
|
|
1230
|
+
},
|
|
1231
|
+
})`;
|
|
1232
|
+
const wrapper = new Attributes().class(['fi-fo-markdown-editor', 'fi-input-wrp', { 'fi-invalid': this.hasError() }]);
|
|
1233
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div hidden data-field-accessibility ${new Attributes().merge(accessibility).toHtml()}></div><div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" x-load x-load-src="${e(asset('markdown-editor'))}" x-data="${e(config)}" wire:ignore><textarea x-ref="editor" x-cloak></textarea></div></div></div>`, { labelAsDiv: true });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
const CODE_LANGUAGES = ['cpp', 'css', 'go', 'html', 'java', 'javascript', 'json', 'markdown', 'php', 'python', 'sql', 'xml', 'yaml'];
|
|
1237
|
+
/** Code editor (CodeMirror): highlighting from `language()`, line wrapping with `wrap()`. */
|
|
1238
|
+
export class CodeEditor extends Field {
|
|
1239
|
+
_language = null;
|
|
1240
|
+
_canWrap = false;
|
|
1241
|
+
_live = null;
|
|
1242
|
+
static make(name) { return configured(new CodeEditor(name)); }
|
|
1243
|
+
/** Language: `cpp`, `css`, `go`, `html`, `java`, `javascript`, `json`, `markdown`, `php`, `python`, `sql`, `xml`, `yaml`. */
|
|
1244
|
+
language(language) {
|
|
1245
|
+
if (language !== null && !CODE_LANGUAGES.includes(language))
|
|
1246
|
+
throw new Error(`CodeEditor: unknown language "${language}".`);
|
|
1247
|
+
this._language = language;
|
|
1248
|
+
return this;
|
|
1249
|
+
}
|
|
1250
|
+
getLanguage() { return this._language; }
|
|
1251
|
+
/**
|
|
1252
|
+
* Wraps long lines. Called with an HTML string, it is the inherited field wrapper,
|
|
1253
|
+
* which the component's rendering uses.
|
|
1254
|
+
*/
|
|
1255
|
+
wrap(condition, options) {
|
|
1256
|
+
if (typeof condition === 'string')
|
|
1257
|
+
return super.wrap(condition, options);
|
|
1258
|
+
this._canWrap = condition ?? true;
|
|
1259
|
+
return this;
|
|
1260
|
+
}
|
|
1261
|
+
canWrap() { return this._canWrap; }
|
|
1262
|
+
live(options = {}) { this._live = { onBlur: options.onBlur === true, debounce: options.debounce ?? null }; return this; }
|
|
1263
|
+
hydrateState(value) { return typeof value === 'string' ? value : value === null || value === undefined ? null : JSON.stringify(value); }
|
|
1264
|
+
async getValidationRules() {
|
|
1265
|
+
return [...(await super.getValidationRules()), { name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }];
|
|
1266
|
+
}
|
|
1267
|
+
async toHtml() {
|
|
1268
|
+
const id = this.getId();
|
|
1269
|
+
const isDisabled = await this.isDisabled();
|
|
1270
|
+
const config = `codeEditorFormComponent({
|
|
1271
|
+
canWrap: ${this._canWrap ? 'true' : 'false'},
|
|
1272
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1273
|
+
isLive: ${this._live !== null ? 'true' : 'false'},
|
|
1274
|
+
isLiveDebounced: ${this._live?.debounce !== null && this._live?.debounce !== undefined ? 'true' : 'false'},
|
|
1275
|
+
isLiveOnBlur: ${this._live?.onBlur ? 'true' : 'false'},
|
|
1276
|
+
label: ${js(await this.getLabel())},
|
|
1277
|
+
liveDebounce: ${js(this._live?.debounce ?? null)},
|
|
1278
|
+
language: ${js(this._language)},
|
|
1279
|
+
state: $wire.$entangle(${js(this.getStatePath())}, ${entangleLive(this._live)}),
|
|
1280
|
+
})`;
|
|
1281
|
+
// Key hash over the serialized `[isDisabled, language]` options.
|
|
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');
|
|
1283
|
+
const wrapper = new Attributes().class(['fi-fo-code-editor', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
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 });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
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.
|
|
1290
|
+
*/
|
|
1291
|
+
export class Slider extends Field {
|
|
1292
|
+
_minValue = 0;
|
|
1293
|
+
_maxValue = 100;
|
|
1294
|
+
_step = null;
|
|
1295
|
+
_minDifference = null;
|
|
1296
|
+
_maxDifference = null;
|
|
1297
|
+
_rangePadding = null;
|
|
1298
|
+
_fillTrack = null;
|
|
1299
|
+
_vertical = false;
|
|
1300
|
+
_rtl = null;
|
|
1301
|
+
_behavior = 'tap';
|
|
1302
|
+
_tooltips = false;
|
|
1303
|
+
_pipsMode = null;
|
|
1304
|
+
_pipsDensity = null;
|
|
1305
|
+
_pipsFormatter = null;
|
|
1306
|
+
_pipsValues = null;
|
|
1307
|
+
_steppedPips = false;
|
|
1308
|
+
_pipsFilter = null;
|
|
1309
|
+
_nonLinearPoints = null;
|
|
1310
|
+
_decimalPlaces = null;
|
|
1311
|
+
_live = null;
|
|
1312
|
+
constructor(name) { super(name); this._required = true; }
|
|
1313
|
+
static make(name) { return configured(new Slider(name)); }
|
|
1314
|
+
range(minValue, maxValue) { this._minValue = minValue; this._maxValue = maxValue; return this; }
|
|
1315
|
+
minValue(value) { this._minValue = value; return this; }
|
|
1316
|
+
maxValue(value) { this._maxValue = value; return this; }
|
|
1317
|
+
/** Step between values; `1` requires an integer, otherwise the value must be a multiple of the step. */
|
|
1318
|
+
step(step) { this._step = step; return this; }
|
|
1319
|
+
/** Minimum / maximum gap between two handles. */
|
|
1320
|
+
minDifference(difference) { this._minDifference = difference; return this; }
|
|
1321
|
+
maxDifference(difference) { this._maxDifference = difference; return this; }
|
|
1322
|
+
/** Forbidden margin at the ends: a number, or `[start, end]`. */
|
|
1323
|
+
rangePadding(padding) { this._rangePadding = padding; return this; }
|
|
1324
|
+
/** Colored track segments, one boolean per segment (`[true, false]` by default). */
|
|
1325
|
+
fillTrack(fill = [true, false]) { this._fillTrack = fill; return this; }
|
|
1326
|
+
vertical(condition = true) { this._vertical = condition; return this; }
|
|
1327
|
+
/** Right to left (top to bottom for a vertical slider, the default when vertical). */
|
|
1328
|
+
rtl(condition = true) { this._rtl = condition; return this; }
|
|
1329
|
+
/** Handle behavior (`tap`, `drag`, `fixed`, `snap`, `unconstrained`…); `null`: none. */
|
|
1330
|
+
behavior(behavior = null) { this._behavior = behavior; return this; }
|
|
1331
|
+
/** Handle tooltips: a boolean, one boolean per handle, or a `RawJs` formatting `$value`. */
|
|
1332
|
+
tooltips(tooltips = true) { this._tooltips = tooltips; return this; }
|
|
1333
|
+
/** Pips: `range` (default), `steps`, `positions`, `count`, `values`. */
|
|
1334
|
+
pips(mode = 'range', density = null) { this._pipsMode = mode; this._pipsDensity = density; return this; }
|
|
1335
|
+
pipsMode(mode) { this._pipsMode = mode; return this; }
|
|
1336
|
+
pipsDensity(density) { this._pipsDensity = density; return this; }
|
|
1337
|
+
/** Pip formatter (a `RawJs` receiving `$value`). */
|
|
1338
|
+
pipsFormatter(formatter) { this._pipsFormatter = formatter; return this; }
|
|
1339
|
+
pipsValues(values) { this._pipsValues = values; return this; }
|
|
1340
|
+
steppedPips(condition = true) { this._steppedPips = condition; return this; }
|
|
1341
|
+
/** Pip filter (a `RawJs` receiving `$value`). */
|
|
1342
|
+
pipsFilter(filter) { this._pipsFilter = filter; return this; }
|
|
1343
|
+
/** Non-linear scale (`{ '10%': 500, '50%': 4000 }`). */
|
|
1344
|
+
nonLinearPoints(points) { this._nonLinearPoints = points; return this; }
|
|
1345
|
+
/** Decimal places of the saved value. */
|
|
1346
|
+
decimalPlaces(places) { this._decimalPlaces = places; return this; }
|
|
1347
|
+
live(options = {}) { this._live = { onBlur: options.onBlur === true, debounce: options.debounce ?? null }; return this; }
|
|
1348
|
+
getMinValueWithPadding() { const padding = this._rangePadding; return this._minValue + (typeof padding === 'number' ? padding : Array.isArray(padding) ? (padding[0] ?? 0) : 0); }
|
|
1349
|
+
getMaxValueWithPadding() { const padding = this._rangePadding; return this._maxValue - (typeof padding === 'number' ? padding : Array.isArray(padding) ? (padding[1] ?? 0) : 0); }
|
|
1350
|
+
isMultiple() { return Array.isArray(this.context().state); }
|
|
1351
|
+
getDefaultState() { return this._default ?? this._minValue; }
|
|
1352
|
+
cast(value) {
|
|
1353
|
+
if (typeof value !== 'number' && (typeof value !== 'string' || value.trim() === '' || !Number.isFinite(Number(value))))
|
|
1354
|
+
return value;
|
|
1355
|
+
const number = Number(value);
|
|
1356
|
+
return this._decimalPlaces === null ? number : Number(number.toFixed(this._decimalPlaces));
|
|
1357
|
+
}
|
|
1358
|
+
hydrateState(value) { return Array.isArray(value) ? value.map((item) => this.cast(item)) : this.cast(value); }
|
|
1359
|
+
dehydrateState(state) { return Array.isArray(state) ? state.map((item) => this.cast(item)) : this.cast(state); }
|
|
1360
|
+
async getValidationRules() {
|
|
1361
|
+
const min = this.getMinValueWithPadding(), max = this.getMaxValueWithPadding(), step = this._step;
|
|
1362
|
+
const isNumber = (v) => (typeof v === 'number' || typeof v === 'string') && String(v).trim() !== '' && Number.isFinite(Number(v));
|
|
1363
|
+
const onStep = (v) => step === null || (step === 1 ? Number.isInteger(Number(v)) : Math.abs(Number(v) / step - Math.round(Number(v) / step)) < 1e-9);
|
|
1364
|
+
const each = (test) => (v) => (Array.isArray(v) ? v.length > 0 && v.every(test) : test(v));
|
|
1365
|
+
return [
|
|
1366
|
+
...(await super.getValidationRules()),
|
|
1367
|
+
{ name: 'numeric', passes: each(isNumber), message: (a) => `The ${a} field must be a number.` },
|
|
1368
|
+
{ name: 'min', passes: each((v) => Number(v) >= min), message: (a) => `The ${a} field must be at least ${min}.` },
|
|
1369
|
+
{ name: 'max', passes: each((v) => Number(v) <= max), message: (a) => `The ${a} field must not be greater than ${max}.` },
|
|
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}.`) },
|
|
1371
|
+
];
|
|
1372
|
+
}
|
|
1373
|
+
/** A `RawJs` becomes a `{ to: ($value) => … }` formatter object. */
|
|
1374
|
+
static formatters(value) {
|
|
1375
|
+
if (value instanceof RawJs)
|
|
1376
|
+
return `{ to: ($value) => ${value.js} }`;
|
|
1377
|
+
if (Array.isArray(value))
|
|
1378
|
+
return `[${value.map((item) => Slider.formatters(item)).join(', ')}]`;
|
|
1379
|
+
return js(value);
|
|
1380
|
+
}
|
|
1381
|
+
async toHtml() {
|
|
1382
|
+
const id = this.getId();
|
|
1383
|
+
const isDisabled = await this.isDisabled();
|
|
1384
|
+
const hasTooltips = Array.isArray(this._tooltips) ? this._tooltips.some((tooltip) => tooltip !== false) : this._tooltips !== false;
|
|
1385
|
+
const behavior = this._behavior === null || (Array.isArray(this._behavior) && !this._behavior.length) ? 'none' : [].concat(this._behavior).join('-');
|
|
1386
|
+
const config = `sliderFormComponent({
|
|
1387
|
+
arePipsStepped: ${this._steppedPips ? 'true' : 'false'},
|
|
1388
|
+
behavior: ${js(behavior)},
|
|
1389
|
+
decimalPlaces: ${js(this._decimalPlaces)},
|
|
1390
|
+
fillTrack: ${js(this._fillTrack)},
|
|
1391
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1392
|
+
isRtl: ${(this._rtl ?? this._vertical) ? 'true' : 'false'},
|
|
1393
|
+
isVertical: ${this._vertical ? 'true' : 'false'},
|
|
1394
|
+
maxDifference: ${js(this._maxDifference)},
|
|
1395
|
+
minDifference: ${js(this._minDifference)},
|
|
1396
|
+
maxValue: ${js(this._maxValue)},
|
|
1397
|
+
minValue: ${js(this._minValue)},
|
|
1398
|
+
nonLinearPoints: ${js(this._nonLinearPoints)},
|
|
1399
|
+
pipsDensity: ${js(this._pipsDensity)},
|
|
1400
|
+
pipsFilter: ${this._pipsFilter ? `($value) => ${this._pipsFilter.js}` : 'null'},
|
|
1401
|
+
pipsFormatter: ${Slider.formatters(this._pipsFormatter)},
|
|
1402
|
+
pipsMode: ${js(this._pipsMode)},
|
|
1403
|
+
pipsValues: ${js(this._pipsValues)},
|
|
1404
|
+
rangePadding: ${js(this._rangePadding)},
|
|
1405
|
+
state: $wire.$entangle(${js(this.getStatePath())}, ${this._live !== null ? 'true' : 'false'}),
|
|
1406
|
+
step: ${js(this._step)},
|
|
1407
|
+
tooltips: ${Slider.formatters(this._tooltips)},
|
|
1408
|
+
})`;
|
|
1409
|
+
const accessibility = accessibilityOf(this, { withInvalid: !this.isMultiple() });
|
|
1410
|
+
const attributes = new Attributes().merge({ 'aria-labelledby': `${id}-label`, id, role: 'group', ...this._extraAttributes })
|
|
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 }]);
|
|
1412
|
+
// Key hash over the serialized `[isDisabled]` options.
|
|
1413
|
+
const hash = createHash('md5').update(`a:1:{i:0;b:${isDisabled ? 1 : 0};}`).digest('hex');
|
|
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 });
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
/** Hidden field: an `<input type="hidden">` bound to the state, with no label or grid slot. */
|
|
1418
|
+
export class Hidden extends Field {
|
|
1419
|
+
constructor(name) { super(name); this._columnSpan = { default: 'hidden' }; }
|
|
1420
|
+
static make(name) { return configured(new Hidden(name)); }
|
|
1421
|
+
async toHtml() {
|
|
1422
|
+
const attributes = new Attributes().merge({ id: this.getId(), type: 'hidden', 'wire:model': this.getStatePath(), ...this._extraAttributes }).class(['fi-fo-hidden']);
|
|
1423
|
+
return `<input ${attributes.toHtml()} />`;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/** One-time code: one box per digit; the state is a digit string of exactly `length()` characters. */
|
|
1427
|
+
export class OneTimeCodeInput extends Field {
|
|
1428
|
+
_length = 6;
|
|
1429
|
+
_submitOnCompletion = false;
|
|
1430
|
+
_readOnly = false;
|
|
1431
|
+
constructor(name) {
|
|
1432
|
+
super(name);
|
|
1433
|
+
// `numeric` rather than `integer`, which would reject leading zeros; then `digits:<length>`.
|
|
1434
|
+
this.rule({ name: 'numeric', passes: (v) => /^\d+$/.test(String(v)), message: (a) => `The ${a} field must be a number.` });
|
|
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.` });
|
|
1436
|
+
}
|
|
1437
|
+
static make(name) { return configured(new OneTimeCodeInput(name)); }
|
|
1438
|
+
/** Number of digits (6 by default). */
|
|
1439
|
+
length(length) { this._length = Math.max(1, Math.min(32, Math.floor(length))); return this; }
|
|
1440
|
+
getLength() { return this._length; }
|
|
1441
|
+
/** Submits the form as soon as the last digit is typed. */
|
|
1442
|
+
submitOnCompletion(condition = true) { this._submitOnCompletion = condition; return this; }
|
|
1443
|
+
readOnly(condition = true) { this._readOnly = condition; return this; }
|
|
1444
|
+
hydrateState(value) { return value === null || value === undefined ? null : String(value); }
|
|
1445
|
+
async toHtml() {
|
|
1446
|
+
const isDisabled = await this.isDisabled();
|
|
1447
|
+
const accessibility = accessibilityOf(this);
|
|
1448
|
+
const label = await this.getLabel();
|
|
1449
|
+
const container = new Attributes().merge({ ...this._extraAttributes, 'aria-describedby': accessibility['aria-describedby'], 'aria-label': label !== '' ? label : null }).class(['fi-one-time-code-input-ctn']);
|
|
1450
|
+
const first = new Attributes().merge({ ...accessibility, autofocus: this._autofocus, disabled: isDisabled, id: this.getId(), readonly: this._readOnly, required: await this.isRequired() }).class(['fi-one-time-code-input-digit']);
|
|
1451
|
+
const digit = new Attributes().merge(accessibility);
|
|
1452
|
+
const inputs = Array.from({ length: this._length }, (_, index) => (index === 0
|
|
1453
|
+
? `<input autocomplete="one-time-code" inputmode="numeric" type="text" ${first.toHtml()} />`
|
|
1454
|
+
: `<input aria-label="${e(t('filament::components/input/one-time-code.aria_label', { position: index + 1, count: this._length }))}" autocomplete="off" inputmode="numeric" type="text" ${digit.toHtml()}${isDisabled ? ' disabled' : ''}${this._readOnly ? ' readonly' : ''} class="fi-one-time-code-input-digit" />`));
|
|
1455
|
+
return this.wrap(`<div x-data="${e(`{ code: $wire.$entangle(${js(this.getStatePath())}, false), }`)}"><div x-data="filamentOneTimeCodeInput({ shouldSubmitOnCompletion: ${this._submitOnCompletion ? 'true' : 'false'} })" x-modelable="state" x-model="code" dir="ltr" role="group" ${container.toHtml()}>${inputs.join('')}</div></div>`);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Read-only text in a form. Nothing is saved. An `HtmlString` is inserted as is.
|
|
1460
|
+
*/
|
|
1461
|
+
export class Placeholder extends Component {
|
|
1462
|
+
_label = null;
|
|
1463
|
+
_content = null;
|
|
1464
|
+
_hiddenLabel = false;
|
|
1465
|
+
constructor(name) { super(); this._name = name; }
|
|
1466
|
+
static make(name) { return configured(new Placeholder(name)); }
|
|
1467
|
+
hasStatePath() { return true; }
|
|
1468
|
+
label(label) { this._label = label; return this; }
|
|
1469
|
+
hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
|
|
1470
|
+
content(content) { this._content = content; return this; }
|
|
1471
|
+
async toHtml() {
|
|
1472
|
+
const ctx = this.context();
|
|
1473
|
+
const label = e((await evaluate(this._label, ctx)) ?? headline(this._name));
|
|
1474
|
+
const content = await evaluate(this._content, ctx);
|
|
1475
|
+
const html = content instanceof HtmlString ? content.html : e(content);
|
|
1476
|
+
const labelCol = this._hiddenLabel ? `<div class="fi-in-entry-label fi-sr-only" role="term">${label}</div>` : `<div class="fi-in-entry-label-col"><div class="fi-in-entry-label-ctn"><div class="fi-in-entry-label" role="term">${label}</div></div></div>`;
|
|
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>`;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
// Nested state: a field inside a stateful group (e.g. `address.city`) is read and written at its path relative to the schema.
|
|
1481
|
+
function relativePath(schema, field) {
|
|
1482
|
+
const base = schema.getStatePath();
|
|
1483
|
+
const full = field.getStatePath();
|
|
1484
|
+
return (base && full.startsWith(`${base}.`) ? full.slice(base.length + 1) : full).split('.');
|
|
1485
|
+
}
|
|
1486
|
+
const readPath = (source, segments) => segments.reduce((node, key) => (node === null || node === undefined ? undefined : node[key]), source);
|
|
1487
|
+
function writePath(target, segments, value) {
|
|
1488
|
+
let node = target;
|
|
1489
|
+
for (const key of segments.slice(0, -1)) {
|
|
1490
|
+
if (node[key] === null || typeof node[key] !== 'object')
|
|
1491
|
+
node[key] = {};
|
|
1492
|
+
node = node[key];
|
|
1493
|
+
}
|
|
1494
|
+
node[segments[segments.length - 1]] = value;
|
|
1495
|
+
}
|
|
1496
|
+
/** Fills the default state of every field in a schema. */
|
|
1497
|
+
export function fillSchema(schema, target) {
|
|
1498
|
+
for (const c of schema.getFlatComponents())
|
|
1499
|
+
if (c instanceof Field)
|
|
1500
|
+
writePath(target, relativePath(schema, c), c.getDefaultState());
|
|
1501
|
+
}
|
|
1502
|
+
/** Fills the form state from a record, field by field. */
|
|
1503
|
+
export function fillSchemaFromRecord(schema, target, record) {
|
|
1504
|
+
for (const c of schema.getFlatComponents()) {
|
|
1505
|
+
if (!(c instanceof Field))
|
|
1506
|
+
continue;
|
|
1507
|
+
const segments = relativePath(schema, c);
|
|
1508
|
+
writePath(target, segments, c.hydrateState(readPath(record, segments) ?? c.getDefaultState()));
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
/** Validates every visible field of a schema; returns errors by state path. */
|
|
1512
|
+
export async function validateSchema(schema) {
|
|
1513
|
+
const errors = {};
|
|
1514
|
+
for (const c of await schema.getVisibleFlatComponents()) {
|
|
1515
|
+
if (!(c instanceof Field))
|
|
1516
|
+
continue;
|
|
1517
|
+
const messages = await c.validate();
|
|
1518
|
+
if (messages.length)
|
|
1519
|
+
errors[c.getStatePath()] = messages;
|
|
1520
|
+
}
|
|
1521
|
+
return errors;
|
|
1522
|
+
}
|
|
1523
|
+
/** State to save: only declared, visible, dehydrated fields; an empty string becomes `null`. */
|
|
1524
|
+
export async function getSchemaState(schema) {
|
|
1525
|
+
const data = {};
|
|
1526
|
+
const fields = [];
|
|
1527
|
+
for (const c of await schema.getVisibleFlatComponents()) {
|
|
1528
|
+
if (!(c instanceof Field) || !(await c.isDehydrated()))
|
|
1529
|
+
continue;
|
|
1530
|
+
const state = await c.dehydrateState(c.context().state);
|
|
1531
|
+
writePath(data, relativePath(schema, c), state === '' || state === undefined ? null : state);
|
|
1532
|
+
fields.push(c);
|
|
1533
|
+
}
|
|
1534
|
+
// Deepest first: a container sees its children already shaped.
|
|
1535
|
+
for (const c of fields.reverse()) {
|
|
1536
|
+
const path = relativePath(schema, c);
|
|
1537
|
+
const current = readPath(data, path);
|
|
1538
|
+
const final = c.finalizeDehydratedState(current);
|
|
1539
|
+
if (final !== current)
|
|
1540
|
+
writePath(data, path, final);
|
|
1541
|
+
}
|
|
1542
|
+
return data;
|
|
1543
|
+
}
|
|
1544
|
+
/** Throw from a step's `beforeValidation` / `afterValidation` to stay on the current step. */
|
|
1545
|
+
export class Halt extends Error {
|
|
1546
|
+
constructor() { super('Halt'); }
|
|
1547
|
+
}
|
|
1548
|
+
/** A wizard step: a label, an optional description and icon, and its own schema. */
|
|
1549
|
+
export class Step extends Component {
|
|
1550
|
+
children = [];
|
|
1551
|
+
_columns = {};
|
|
1552
|
+
label;
|
|
1553
|
+
_description = null;
|
|
1554
|
+
_icon = null;
|
|
1555
|
+
_completedIcon = null;
|
|
1556
|
+
_beforeValidation = null;
|
|
1557
|
+
_afterValidation = null;
|
|
1558
|
+
constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::wizard-step`; }
|
|
1559
|
+
static make(label) { return configured(new Step(label)); }
|
|
1560
|
+
schema(components) { this.children = components; return this; }
|
|
1561
|
+
columns(columns = 2) { this._columns = columns; return this; }
|
|
1562
|
+
description(description) { this._description = description; return this; }
|
|
1563
|
+
icon(name) { this._icon = name; return this; }
|
|
1564
|
+
completedIcon(name) { this._completedIcon = name; return this; }
|
|
1565
|
+
/** Runs before the step is validated, on "Next"; throw `Halt` to stay on the step. */
|
|
1566
|
+
beforeValidation(callback) { this._beforeValidation = callback; return this; }
|
|
1567
|
+
/** Runs after a successful validation, before moving to the next step; throw `Halt` to stay on the step. */
|
|
1568
|
+
afterValidation(callback) { this._afterValidation = callback; return this; }
|
|
1569
|
+
getLabel() { return this.label; }
|
|
1570
|
+
getDescription() { return this._description; }
|
|
1571
|
+
getIcon() { return this._icon; }
|
|
1572
|
+
getCompletedIcon() { return this._completedIcon; }
|
|
1573
|
+
async callBeforeValidation() { await this._beforeValidation?.(this.context()); }
|
|
1574
|
+
async callAfterValidation() { await this._afterValidation?.(this.context()); }
|
|
1575
|
+
getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
|
|
1576
|
+
async toHtml() { return this.getChildSchemas()[0].toHtml(); }
|
|
1577
|
+
/** Step panel: a `<form>` when the wizard has a submit handler (Enter goes to the next step, or saves on the last one). */
|
|
1578
|
+
async toStepHtml(submitHandler, hasHeader) {
|
|
1579
|
+
const key = this.getKey();
|
|
1580
|
+
const tag = submitHandler ? 'form' : 'div';
|
|
1581
|
+
const attributes = new Attributes().merge({
|
|
1582
|
+
'aria-labelledby': hasHeader ? `${key}-tab` : null,
|
|
1583
|
+
'aria-label': hasHeader ? null : this.label,
|
|
1584
|
+
id: key,
|
|
1585
|
+
role: 'group',
|
|
1586
|
+
'x-data': `filamentSchemaComponent(${js(this.getAlpineScopeConfiguration())})`,
|
|
1587
|
+
}).class(['fi-sc-wizard-step']);
|
|
1588
|
+
return `<${tag} x-bind:tabindex="$el.querySelector('[autofocus]') ? '-1' : '0'" x-bind:class="{
|
|
1589
|
+
'fi-active': step === ${e(js(key))},
|
|
1590
|
+
}" x-on:expand="
|
|
1591
|
+
if (! isStepAccessible(${e(js(key))})) {
|
|
1592
|
+
return
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
step = ${e(js(key))} "${submitHandler ? ` x-on:submit.prevent="isLastStep() ? ${e(submitHandler)} : requestNextStep()"` : ''} x-cloak x-ref="step-${e(key)}" ${attributes.toHtml()}>${await this.toHtml()}${submitHandler ? '<input type="submit" hidden />' : ''}</${tag}>`;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
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()`.
|
|
1601
|
+
*/
|
|
1602
|
+
export class Wizard extends Component {
|
|
1603
|
+
steps;
|
|
1604
|
+
_label = null;
|
|
1605
|
+
_skippable = false;
|
|
1606
|
+
_startStep = 1;
|
|
1607
|
+
_contained = true;
|
|
1608
|
+
_headerHidden = false;
|
|
1609
|
+
_cancelAction = null;
|
|
1610
|
+
_submitAction = null;
|
|
1611
|
+
_alpineSubmitHandler = null;
|
|
1612
|
+
_stepQueryStringKey = null;
|
|
1613
|
+
_modifyNext = null;
|
|
1614
|
+
_modifyPrevious = null;
|
|
1615
|
+
constructor(steps = []) { super(); this.steps = steps; }
|
|
1616
|
+
static make(steps = []) { return configured(new Wizard(steps)); }
|
|
1617
|
+
schema(steps) { this.steps = steps; return this; }
|
|
1618
|
+
label(label) { this._label = label; return this; }
|
|
1619
|
+
/** Steps can be visited in any order, without validation. */
|
|
1620
|
+
skippable(condition = true) { this._skippable = condition; return this; }
|
|
1621
|
+
/** Step shown on open (1-based). */
|
|
1622
|
+
startOnStep(step) { this._startStep = step; return this; }
|
|
1623
|
+
/** `false`: no card around the wizard (as on a resource page). */
|
|
1624
|
+
contained(condition = true) { this._contained = condition; return this; }
|
|
1625
|
+
hiddenHeader(condition = true) { this._headerHidden = condition; return this; }
|
|
1626
|
+
/** Action shown instead of "Back" on the first step. */
|
|
1627
|
+
cancelAction(action) { this._cancelAction = action; return this; }
|
|
1628
|
+
/** Action shown instead of "Next" on the last step. */
|
|
1629
|
+
submitAction(action) { this._submitAction = action; return this; }
|
|
1630
|
+
/** Alpine expression run when the last step is submitted from the keyboard (e.g. `$wire.create()`). */
|
|
1631
|
+
alpineSubmitHandler(handler) { this._alpineSubmitHandler = handler; return this; }
|
|
1632
|
+
/** Keeps the current step in the URL (`?step=…`). */
|
|
1633
|
+
persistStepInQueryString(key = 'step') { this._stepQueryStringKey = key; return this; }
|
|
1634
|
+
nextAction(modify) { this._modifyNext = modify; return this; }
|
|
1635
|
+
previousAction(modify) { this._modifyPrevious = modify; return this; }
|
|
1636
|
+
getKey() {
|
|
1637
|
+
const statePath = this.container.getStatePath();
|
|
1638
|
+
const own = statePath ? `${statePath}::wizard` : 'wizard';
|
|
1639
|
+
return `${this.container.getKey()}.${this._label ? `${slug(this._label)}::${own}` : own}`;
|
|
1640
|
+
}
|
|
1641
|
+
getExposedMethods() { return ['nextStep', 'previousStep']; }
|
|
1642
|
+
/** "Next" re-renders the component so the step's validation errors show up. */
|
|
1643
|
+
isRenderlessMethod() { return false; }
|
|
1644
|
+
getChildSchemas() { return [childSchema(this, this.steps)]; }
|
|
1645
|
+
getSteps() { this.getChildSchemas(); return this.steps; }
|
|
1646
|
+
/** Called by the browser on "Next": validates the current step, then announces the next one. */
|
|
1647
|
+
async nextStep(currentStepIndex = null, currentStepKey = null) {
|
|
1648
|
+
const steps = this.getSteps();
|
|
1649
|
+
if (!Number.isInteger(currentStepIndex) || currentStepIndex < 0 || currentStepIndex >= steps.length - 1)
|
|
1650
|
+
return;
|
|
1651
|
+
const current = steps[currentStepIndex];
|
|
1652
|
+
if (currentStepKey !== null && current.getKey() !== currentStepKey)
|
|
1653
|
+
return;
|
|
1654
|
+
const livewire = this.livewire;
|
|
1655
|
+
if (!this._skippable) {
|
|
1656
|
+
try {
|
|
1657
|
+
await current.callBeforeValidation();
|
|
1658
|
+
const errors = await validateSchema(current.getChildSchemas()[0]);
|
|
1659
|
+
livewire._errors = errors;
|
|
1660
|
+
if (Object.keys(errors).length) {
|
|
1661
|
+
livewire.dispatch('form-validation-error', { livewireId: livewire.id });
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
await current.callAfterValidation();
|
|
1665
|
+
}
|
|
1666
|
+
catch (error) {
|
|
1667
|
+
if (error instanceof Halt)
|
|
1668
|
+
return;
|
|
1669
|
+
throw error;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
livewire.dispatch('next-wizard-step', { key: this.getKey(), step: steps[currentStepIndex + 1].getKey(), currentStep: current.getKey() });
|
|
1673
|
+
}
|
|
1674
|
+
/** Going back happens in the browser; this exposed method is a no-op the front end still calls. */
|
|
1675
|
+
previousStep() { }
|
|
1676
|
+
async toHtml() {
|
|
1677
|
+
const steps = this.getSteps();
|
|
1678
|
+
const key = this.getKey();
|
|
1679
|
+
const ctx = this.context();
|
|
1680
|
+
const tr = (name) => t(`filament-schemas::components.wizard.${name}`);
|
|
1681
|
+
const previous = Action.make('previous').label(tr('actions.previous_step.label')).color('gray');
|
|
1682
|
+
const previousAction = this._modifyPrevious?.(previous) ?? previous;
|
|
1683
|
+
const next = Action.make('next').label(tr('actions.next_step.label')).iconPosition('after');
|
|
1684
|
+
if (steps.length > 1)
|
|
1685
|
+
next.livewireTarget(steps.slice(0, -1).map((step, index) => `callSchemaComponentMethod(${js(key)}, 'nextStep', ${js({ currentStepIndex: index, currentStepKey: step.getKey() })})`).join(', '));
|
|
1686
|
+
const nextAction = this._modifyNext?.(next) ?? next;
|
|
1687
|
+
const [previousDisabled, nextDisabled] = [await previousAction.isDisabled(ctx), await nextAction.isDisabled(ctx)];
|
|
1688
|
+
const visibleKeys = [];
|
|
1689
|
+
for (const step of steps)
|
|
1690
|
+
if (await step.isVisible())
|
|
1691
|
+
visibleKeys.push(step.getKey());
|
|
1692
|
+
const header = [];
|
|
1693
|
+
steps.forEach((step, index) => {
|
|
1694
|
+
const stepKey = step.getKey();
|
|
1695
|
+
const shown = new Attributes({ 'x-cloak': 'x-cloak', 'x-show': `getStepIndex(step) <= ${index}` });
|
|
1696
|
+
const stepIcon = step.getIcon()
|
|
1697
|
+
? icon(step.getIcon(), { size: 'lg', attributes: shown })
|
|
1698
|
+
: `<span x-show="getStepIndex(step) <= ${index}" aria-hidden="true" class="fi-sc-wizard-header-step-number">${String(index + 1).padStart(2, '0')}</span>`;
|
|
1699
|
+
header.push(`<li class="fi-sc-wizard-header-step" x-bind:class="{
|
|
1700
|
+
'fi-active': getStepIndex(step) === ${index},
|
|
1701
|
+
'fi-completed': getStepIndex(step) > ${index},
|
|
1702
|
+
}"><button type="button" id="${e(stepKey)}-tab" aria-controls="${e(stepKey)}" x-bind:aria-current="getStepIndex(step) === ${index} ? 'step' : null" x-on:click="step = ${e(js(stepKey))}" x-bind:disabled="! isStepAccessible(${e(js(stepKey))}) || ${previousDisabled ? 'true' : 'false'}" class="fi-sc-wizard-header-step-btn"><div class="fi-sc-wizard-header-step-icon-ctn">${icon(step.getCompletedIcon() ?? 'o-check', { size: 'lg', attributes: new Attributes({ 'x-cloak': 'x-cloak', 'x-show': `getStepIndex(step) > ${index}` }) })}${stepIcon}</div><div class="fi-sc-wizard-header-step-text"><span class="fi-sc-wizard-header-step-label">${e(step.getLabel())}</span>${step.getDescription() ? `<span class="fi-sc-wizard-header-step-description">${e(step.getDescription())}</span>` : ''}<span class="fi-sr-only" x-text="getStepIndex(step) > ${index} ? ${e(js(tr('header.step.statuses.completed')))} : (getStepIndex(step) === ${index} ? '' : ${e(js(tr('header.step.statuses.upcoming')))})"></span></div></button>${index < steps.length - 1 ? '<svg fill="none" preserveAspectRatio="none" viewBox="0 0 22 80" aria-hidden="true" class="fi-sc-wizard-header-step-separator"><path d="M0 -2L20 40L0 82" stroke-linejoin="round" stroke="currentcolor" vector-effect="non-scaling-stroke"></path></svg>' : ''}</li>`);
|
|
1703
|
+
});
|
|
1704
|
+
const panels = [];
|
|
1705
|
+
for (const step of steps)
|
|
1706
|
+
panels.push(await step.toStepHtml(this._alpineSubmitHandler, !this._headerHidden));
|
|
1707
|
+
const outer = new Attributes().merge({ id: key }).class(['fi-sc-wizard', { 'fi-contained': this._contained, 'fi-sc-wizard-header-hidden': this._headerHidden }]);
|
|
1708
|
+
return `<div x-load x-load-src="${e(views.render('filament-schemas::asset', { path: 'wizard' }).trim())}" x-data="wizardSchemaComponent({
|
|
1709
|
+
isSkippable: ${this._skippable ? 'true' : 'false'},
|
|
1710
|
+
isStepPersistedInQueryString: ${this._stepQueryStringKey ? 'true' : 'false'},
|
|
1711
|
+
livewireId: ${e(js(this.livewire.id))},
|
|
1712
|
+
schemaKey: ${e(js(this.container.getKey().split('.')[0]))},
|
|
1713
|
+
startStep: ${this._startStep},
|
|
1714
|
+
stepQueryStringKey: ${e(js(this._stepQueryStringKey))},
|
|
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>`;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/** Local disk storage, served under a public URL. File names are random; the extension comes from the detected type. */
|
|
1719
|
+
export class LocalFileStorage {
|
|
1720
|
+
root;
|
|
1721
|
+
baseUrl;
|
|
1722
|
+
constructor(root, baseUrl) { this.root = root; this.baseUrl = baseUrl.replace(/\/$/, ''); }
|
|
1723
|
+
resolve(key) {
|
|
1724
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,250}$/.test(key) || key.split('/').some((part) => part === '..' || part === '.'))
|
|
1725
|
+
return null;
|
|
1726
|
+
return nodePath.join(this.root, ...key.split('/'));
|
|
1727
|
+
}
|
|
1728
|
+
async store(file, directory) {
|
|
1729
|
+
const key = [directory, `${randomUUID()}.${file.extension}`].filter(Boolean).join('/');
|
|
1730
|
+
const target = this.resolve(key);
|
|
1731
|
+
if (!target)
|
|
1732
|
+
throw new Error('Invalid upload directory.');
|
|
1733
|
+
mkdirSync(nodePath.dirname(target), { recursive: true });
|
|
1734
|
+
copyFileSync(file.path, target);
|
|
1735
|
+
writeFileSync(`${target}.meta.json`, JSON.stringify({ name: file.name, type: file.type }));
|
|
1736
|
+
return key;
|
|
1737
|
+
}
|
|
1738
|
+
async info(key) {
|
|
1739
|
+
const target = this.resolve(key);
|
|
1740
|
+
if (!target || !existsSync(target))
|
|
1741
|
+
return null;
|
|
1742
|
+
let meta = {};
|
|
1743
|
+
try {
|
|
1744
|
+
meta = JSON.parse(readFileSync(`${target}.meta.json`, 'utf8'));
|
|
1745
|
+
}
|
|
1746
|
+
catch { /* file dropped in without metadata */ }
|
|
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('/')}` };
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
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.
|
|
1754
|
+
*/
|
|
1755
|
+
export class FileUpload extends Field {
|
|
1756
|
+
/** Application-wide default storage. */
|
|
1757
|
+
static storage = null;
|
|
1758
|
+
_multiple = false;
|
|
1759
|
+
_maxFiles = null;
|
|
1760
|
+
_maxSize = null;
|
|
1761
|
+
_reorderable = false;
|
|
1762
|
+
_acceptedFileTypes = null;
|
|
1763
|
+
_directory = null;
|
|
1764
|
+
_storeFiles = true;
|
|
1765
|
+
_storage = null;
|
|
1766
|
+
_getUploadedFileUsing = null;
|
|
1767
|
+
static make(name) { return configured(new FileUpload(name)); }
|
|
1768
|
+
multiple(condition = true) { this._multiple = condition; return this; }
|
|
1769
|
+
maxFiles(count) { this._maxFiles = count; return this; }
|
|
1770
|
+
/** Maximum file size, in kilobytes. */
|
|
1771
|
+
maxSize(kilobytes) { this._maxSize = kilobytes; return this; }
|
|
1772
|
+
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
1773
|
+
/** Accepted MIME types (`image/jpeg`, `image/*`…), checked against the received file's content. */
|
|
1774
|
+
acceptedFileTypes(types) { this._acceptedFileTypes = types; return this; }
|
|
1775
|
+
image() { return this.acceptedFileTypes(['image/*']); }
|
|
1776
|
+
directory(directory) { this._directory = directory; return this; }
|
|
1777
|
+
storage(storage) { this._storage = storage; return this; }
|
|
1778
|
+
/** `false`: the field stores nothing itself; the data receives `FileUploadEntry` items for the adapter to process (media). */
|
|
1779
|
+
storeFiles(condition = true) { this._storeFiles = condition; return this; }
|
|
1780
|
+
/** Info about an already saved file (name, size, type, URL), when it does not come from the storage. */
|
|
1781
|
+
getUploadedFileUsing(resolver) { this._getUploadedFileUsing = resolver; return this; }
|
|
1782
|
+
getDefaultState() { return {}; }
|
|
1783
|
+
hydrateState(value) {
|
|
1784
|
+
const keys = Array.isArray(value) ? value : value === null || value === undefined || value === '' ? [] : typeof value === 'object' ? Object.values(value) : [value];
|
|
1785
|
+
return Object.fromEntries(keys.filter((key) => typeof key === 'string' || typeof key === 'number').map((key) => [randomUUID(), String(key)]));
|
|
1786
|
+
}
|
|
1787
|
+
get uploads() { return this.livewire._runtime?.uploads ?? null; }
|
|
1788
|
+
getStorage() { return this._storage ?? FileUpload.storage; }
|
|
1789
|
+
/** Sanitized state entries: safe file key, string value. */
|
|
1790
|
+
entries() {
|
|
1791
|
+
const state = this.context().state;
|
|
1792
|
+
if (!state || typeof state !== 'object' || Array.isArray(state))
|
|
1793
|
+
return [];
|
|
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);
|
|
1795
|
+
}
|
|
1796
|
+
/** Stored keys the current record already owns: the only ones accepted back from the browser. */
|
|
1797
|
+
originalKeys() {
|
|
1798
|
+
const record = this.container.getRecord();
|
|
1799
|
+
return new Set(Object.values(this.hydrateState(record?.[this.getName()])));
|
|
1800
|
+
}
|
|
1801
|
+
isTemporary(value) { return value.startsWith('livewire-file:'); }
|
|
1802
|
+
getExposedMethods() { return ['deleteUploadedFile', 'removeUploadedFile', 'reorderUploadedFiles', 'getUploadedFiles']; }
|
|
1803
|
+
async getUploadedFiles() {
|
|
1804
|
+
const original = this.originalKeys();
|
|
1805
|
+
const files = {};
|
|
1806
|
+
for (const [fileKey, value] of this.entries()) {
|
|
1807
|
+
if (this.isTemporary(value) || !original.has(value)) {
|
|
1808
|
+
files[fileKey] = null;
|
|
1809
|
+
continue;
|
|
1810
|
+
}
|
|
1811
|
+
const info = this._getUploadedFileUsing ? await this._getUploadedFileUsing(value, this.context()) : await this.getStorage()?.info(value);
|
|
1812
|
+
// Only local or http(s) URLs are sent to the browser.
|
|
1813
|
+
files[fileKey] = info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info : null;
|
|
1814
|
+
}
|
|
1815
|
+
return files;
|
|
1816
|
+
}
|
|
1817
|
+
writeState(entries) { writePath(this.livewire, this.getStatePath().split('.'), Object.fromEntries(entries)); }
|
|
1818
|
+
/** Removes a file from the field. A stored file is only deleted by the application when the form is saved. */
|
|
1819
|
+
removeUploadedFile(fileKey) {
|
|
1820
|
+
const removed = this.entries().find(([key]) => key === fileKey);
|
|
1821
|
+
if (removed && this.isTemporary(removed[1]))
|
|
1822
|
+
this.uploads?.remove(removed[1].slice('livewire-file:'.length));
|
|
1823
|
+
this.writeState(this.entries().filter(([key]) => key !== fileKey));
|
|
1824
|
+
}
|
|
1825
|
+
deleteUploadedFile(fileKey) { this.removeUploadedFile(fileKey); }
|
|
1826
|
+
reorderUploadedFiles(fileKeys) {
|
|
1827
|
+
if (!this._reorderable || !Array.isArray(fileKeys))
|
|
1828
|
+
return;
|
|
1829
|
+
const entries = new Map(this.entries());
|
|
1830
|
+
const ordered = fileKeys.map(String).filter((key) => entries.has(key));
|
|
1831
|
+
this.writeState([...ordered.map((key) => [key, entries.get(key)]), ...[...entries].filter(([key]) => !ordered.includes(key))]);
|
|
1832
|
+
}
|
|
1833
|
+
async getValidationRules() {
|
|
1834
|
+
const entries = () => this.entries();
|
|
1835
|
+
const temporary = () => entries().filter(([, value]) => this.isTemporary(value)).map(([, value]) => this.uploads?.resolve(value) ?? null);
|
|
1836
|
+
const accepts = (type) => (this._acceptedFileTypes ?? []).some((accepted) => accepted === type || (accepted.endsWith('/*') && type.startsWith(accepted.slice(0, -1))));
|
|
1837
|
+
const rules = [];
|
|
1838
|
+
if (await this.isRequired())
|
|
1839
|
+
rules.push({ name: 'required', passes: () => entries().length > 0, message: (a) => `The ${a} field is required.` });
|
|
1840
|
+
rules.push({ name: 'file', passes: () => temporary().every((file) => file !== null), message: (a) => `The ${a} field must be a file.` });
|
|
1841
|
+
if (!this._multiple)
|
|
1842
|
+
rules.push({ name: 'max_files', passes: () => entries().length <= 1, message: (a) => `The ${a} field must not have more than 1 items.` });
|
|
1843
|
+
if (this._maxFiles !== null)
|
|
1844
|
+
rules.push({ name: 'max_files', passes: () => entries().length <= this._maxFiles, message: (a) => `The ${a} field must not have more than ${this._maxFiles} items.` });
|
|
1845
|
+
if (this._acceptedFileTypes)
|
|
1846
|
+
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
|
+
if (this._maxSize !== null)
|
|
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.` });
|
|
1849
|
+
return rules;
|
|
1850
|
+
}
|
|
1851
|
+
/** Validates the whole state, even when empty (a required field without files must fail). */
|
|
1852
|
+
async validate() {
|
|
1853
|
+
if (!(await this.isDehydrated()))
|
|
1854
|
+
return [];
|
|
1855
|
+
const attribute = (await this.getLabel()).toLowerCase();
|
|
1856
|
+
for (const rule of await this.getValidationRules())
|
|
1857
|
+
if (!(await rule.passes(this.context().state, this.context())))
|
|
1858
|
+
return [rule.message(attribute)];
|
|
1859
|
+
return [];
|
|
1860
|
+
}
|
|
1861
|
+
/** On save: temporary files are stored, unrecognized existing keys are dropped. */
|
|
1862
|
+
async dehydrateState() {
|
|
1863
|
+
const original = this.originalKeys();
|
|
1864
|
+
const out = [];
|
|
1865
|
+
for (const [, value] of this.entries()) {
|
|
1866
|
+
if (this.isTemporary(value)) {
|
|
1867
|
+
const file = this.uploads?.resolve(value);
|
|
1868
|
+
if (!file)
|
|
1869
|
+
continue;
|
|
1870
|
+
if (!this._storeFiles) {
|
|
1871
|
+
out.push({ file });
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
const storage = this.getStorage();
|
|
1875
|
+
if (!storage)
|
|
1876
|
+
throw new Error('FileUpload: no storage configured (FileUpload.storage).');
|
|
1877
|
+
out.push(await storage.store(file, this._directory));
|
|
1878
|
+
this.uploads?.remove(file.id);
|
|
1879
|
+
}
|
|
1880
|
+
else if (original.has(value))
|
|
1881
|
+
out.push(this._storeFiles ? value : { key: value });
|
|
1882
|
+
}
|
|
1883
|
+
return this._multiple ? out : (out[0] ?? null);
|
|
1884
|
+
}
|
|
1885
|
+
async toHtml() {
|
|
1886
|
+
const isDisabled = await this.isDisabled();
|
|
1887
|
+
const id = this.getId();
|
|
1888
|
+
const label = (await this.getLabel()).toLowerCase();
|
|
1889
|
+
const tr = (key) => t(`filament-forms::components.file_upload.${key}`);
|
|
1890
|
+
const config = `fileUploadFormComponent({
|
|
1891
|
+
acceptedFileTypes: ${this._acceptedFileTypes ? js(this._acceptedFileTypes) : 'null'},
|
|
1892
|
+
automaticallyCropImagesAspectRatio: null,
|
|
1893
|
+
automaticallyOpenImageEditorForAspectRatio: null,
|
|
1894
|
+
automaticallyResizeImagesMode: null,
|
|
1895
|
+
automaticallyResizeImagesHeight: null,
|
|
1896
|
+
automaticallyResizeImagesWidth: null,
|
|
1897
|
+
canEditSvgs: true,
|
|
1898
|
+
confirmSvgEditingMessage: ${js(tr('editor.svg.messages.confirmation'))},
|
|
1899
|
+
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,
|
|
1909
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1910
|
+
isDownloadable: false,
|
|
1911
|
+
isImageEditorExplicitlyEnabled: false,
|
|
1912
|
+
isMultiple: ${this._multiple ? 'true' : 'false'},
|
|
1913
|
+
isOpenable: false,
|
|
1914
|
+
isPasteable: true,
|
|
1915
|
+
isPreviewable: true,
|
|
1916
|
+
isReorderable: ${this._reorderable ? 'true' : 'false'},
|
|
1917
|
+
isSvgEditingConfirmed: false,
|
|
1918
|
+
itemPanelAspectRatio: null,
|
|
1919
|
+
loadingIndicatorPosition: 'right',
|
|
1920
|
+
locale: 'en',
|
|
1921
|
+
maxFiles: ${this._maxFiles ?? 'null'},
|
|
1922
|
+
maxFilesValidationMessage: ${this._maxFiles !== null ? js(`The ${label} field must not have more than ${this._maxFiles} items.`) : 'null'},
|
|
1923
|
+
maxParallelUploads: null,
|
|
1924
|
+
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,
|
|
1935
|
+
state: $wire.$entangle(${js(this.getStatePath())}, false),
|
|
1936
|
+
statePath: ${js(this.getStatePath())},
|
|
1937
|
+
uploadButtonPosition: 'right',
|
|
1938
|
+
uploadingMessage: ${js(t('filament::components/button.messages.uploading_file'))},
|
|
1939
|
+
downloadActionLabel: ${js(tr('actions.download.label'))},
|
|
1940
|
+
openActionLabel: ${js(tr('actions.open.label'))},
|
|
1941
|
+
uploadProgressIndicatorPosition: 'right',
|
|
1942
|
+
})`;
|
|
1943
|
+
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([this._acceptedFileTypes, this._maxFiles, this._multiple, isDisabled])).digest('hex')}`;
|
|
1944
|
+
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 });
|
|
1946
|
+
}
|
|
1947
|
+
}
|