@navette-js/forms 0.0.0-stage → 1.0.0-beta.1
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 +309 -0
- package/lib/index.d.ts +871 -0
- package/lib/index.js +1957 -0
- package/package.json +62 -4
- package/views/asset.ejs +1 -0
- package/views/components/field-wrapper.ejs +39 -0
- package/views/components/rich-editor.ejs +472 -0
- package/views/components/text-input.ejs +32 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1957 @@
|
|
|
1
|
+
// @navette-js/forms — port de filament/forms : Field, TextInput, Checkbox, validation côté serveur.
|
|
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
|
+
/** Nombre tel que PHP l'écrit : au-delà des entiers exacts, un flottant à 14 chiffres significatifs (« 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
|
+
/** Texte d'aide affiché sous le champ. */
|
|
54
|
+
helperText(text) { this._helperText = text; return this; }
|
|
55
|
+
/** Identifiant du texte d'aide, à lier au contrôle par `aria-describedby`. */
|
|
56
|
+
getHelperTextId() { return this._helperText !== null ? `${this.getId()}-helper-text` : null; }
|
|
57
|
+
/** Libellé réservé aux lecteurs d'écran. */
|
|
58
|
+
hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
|
|
59
|
+
// --- indication à droite du libellé (port de Concerns\HasHint)
|
|
60
|
+
_hint = null;
|
|
61
|
+
_hintColor = null;
|
|
62
|
+
_hintIcon = null;
|
|
63
|
+
_hintIconTooltip = null;
|
|
64
|
+
_hintActions = [];
|
|
65
|
+
/**
|
|
66
|
+
* Indication affichée à droite du libellé (un composant `Text`). Le texte est échappé ; une `HtmlString` de `@navette-js/support`
|
|
67
|
+
* (`raw('<a href="…">…</a>')`) est insérée telle quelle : n'y placez que des valeurs échappées avec `e()`.
|
|
68
|
+
*/
|
|
69
|
+
hint(hint) { this._hint = hint; return this; }
|
|
70
|
+
/** Couleur de l'indication et de son icône (`gray` pour le texte, `primary` pour l'icône par défaut). */
|
|
71
|
+
hintColor(color) { this._hintColor = color; return this; }
|
|
72
|
+
/** Icône d'indication, avec une infobulle facultative (qui sert aussi de nom accessible). */
|
|
73
|
+
hintIcon(name, tooltip) {
|
|
74
|
+
this._hintIcon = name;
|
|
75
|
+
if (arguments.length >= 2)
|
|
76
|
+
this._hintIconTooltip = tooltip ?? null;
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
hintIconTooltip(tooltip) { this._hintIconTooltip = tooltip; return this; }
|
|
80
|
+
/** Action affichée après l'indication (en lien, petite taille), montée sur le champ. */
|
|
81
|
+
hintAction(action) { return this.hintActions([action]); }
|
|
82
|
+
hintActions(actions) { this._hintActions = [...this._hintActions, ...actions]; return this; }
|
|
83
|
+
getHintActions() { return this._hintActions; }
|
|
84
|
+
/** Actions que le champ expose au montage (`schemaComponent` = clé du champ) : ici, ses actions d'indication. */
|
|
85
|
+
getActions() { return this.getHintActions(); }
|
|
86
|
+
/** Rangée « après le libellé » : indication, icône d'indication et actions d'indication. */
|
|
87
|
+
async renderAfterLabel() {
|
|
88
|
+
const ctx = this.context();
|
|
89
|
+
const hint = await evaluate(this._hint, ctx);
|
|
90
|
+
const color = await evaluate(this._hintColor, ctx);
|
|
91
|
+
const iconName = await evaluate(this._hintIcon, ctx);
|
|
92
|
+
const items = [];
|
|
93
|
+
if (hint !== null && hint !== undefined && (hint instanceof HtmlString ? hint.html : hint) !== '')
|
|
94
|
+
items.push(Text.make(hint).color(color));
|
|
95
|
+
if (iconName)
|
|
96
|
+
items.push(SchemaIcon.make(iconName).tooltip(await evaluate(this._hintIconTooltip, ctx)).color(color));
|
|
97
|
+
items.push(...this._hintActions);
|
|
98
|
+
if (!items.length)
|
|
99
|
+
return '';
|
|
100
|
+
// Comme Filament : les composants de la rangée ont le chemin du champ et, pour conteneur, celui du schéma du champ.
|
|
101
|
+
return renderInlineItems(this, items, { alignment: 'end', scope: { path: this.getStatePath(), containerPath: this.container.getStatePath() }, mountKey: this.getKey(), actionView: 'link' });
|
|
102
|
+
}
|
|
103
|
+
/** Exécuté côté serveur quand l'état du champ change (champ `live()`). `set(nom, valeur)` modifie un autre champ du même formulaire. */
|
|
104
|
+
afterStateUpdated(callback) { this._afterStateUpdated = callback; return this; }
|
|
105
|
+
hasAfterStateUpdated() { return this._afterStateUpdated !== null; }
|
|
106
|
+
async callAfterStateUpdated(set) { await this._afterStateUpdated?.({ ...this.context(), set }); }
|
|
107
|
+
/** Force l'inclusion du champ dans les données envoyées, même désactivé (`->disabled()->dehydrated()`). */
|
|
108
|
+
dehydrated(condition = true) { this._dehydrated = condition; return this; }
|
|
109
|
+
/** Par défaut, un champ désactivé n'est ni validé ni enregistré. */
|
|
110
|
+
async isDehydrated() { return this._dehydrated ?? !(await this.isDisabled()); }
|
|
111
|
+
getDefaultState() { return typeof this._default === 'function' ? this._default() : this._default; }
|
|
112
|
+
/** Mise en forme de la valeur d'un enregistrement quand elle entre dans le formulaire (hydratation). */
|
|
113
|
+
hydrateState(value) { return value; }
|
|
114
|
+
rule(rule) { this._rules.push(rule); return this; }
|
|
115
|
+
/** Règle `unique` : la valeur ne doit pas exister dans la colonne du même nom ; `ignoreRecord` exclut l'enregistrement en cours d'édition. */
|
|
116
|
+
unique(options = {}) {
|
|
117
|
+
return this.rule({
|
|
118
|
+
name: 'unique',
|
|
119
|
+
passes: async (value, ctx) => {
|
|
120
|
+
const host = ctx.livewire;
|
|
121
|
+
const adapter = host.getDataAdapter?.();
|
|
122
|
+
if (!adapter)
|
|
123
|
+
throw new Error('unique() : le composant hôte ne fournit pas d\'adaptateur de données (getDataAdapter).');
|
|
124
|
+
const ignored = options.ignoreRecord ? (ctx.record ? adapter.getKey(ctx.record) : (host.getRecordKey?.() ?? null)) : null;
|
|
125
|
+
return !(await adapter.exists(options.column ?? this._name, value, ignored));
|
|
126
|
+
},
|
|
127
|
+
message: (a) => `The ${a} has already been taken.`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async getLabel() { return (await evaluate(this._label, this.context())) ?? headline(this._name); }
|
|
131
|
+
isRequired() { return evaluate(this._required, this.context()); }
|
|
132
|
+
isDisabled() { return evaluate(this._disabled, this.context()); }
|
|
133
|
+
getErrors() { return this.livewire._errors[this.getStatePath()] ?? []; }
|
|
134
|
+
hasError() { return this.getErrors().length > 0; }
|
|
135
|
+
/** Règles de validation : `required` d'abord, puis celles du champ. Les messages sont ceux de Laravel (en). */
|
|
136
|
+
async getValidationRules() {
|
|
137
|
+
const rules = [];
|
|
138
|
+
if (await this.isRequired())
|
|
139
|
+
rules.push({ name: 'required', passes: (v) => !blank(v) && v !== false, message: (a) => `The ${a} field is required.` });
|
|
140
|
+
return [...rules, ...this._rules];
|
|
141
|
+
}
|
|
142
|
+
async validate() {
|
|
143
|
+
// Comme Filament : un champ non enregistré (`dehydrated(false)` : confirmation, mot de passe actuel) est quand même validé ;
|
|
144
|
+
// seul un champ désactivé (et non forcé par `dehydrated()`) y échappe.
|
|
145
|
+
if ((await this.isDisabled()) && this._dehydrated !== true)
|
|
146
|
+
return [];
|
|
147
|
+
// Comme Laravel (ConvertEmptyStringsToNull) : une chaîne vide est traitée comme absente.
|
|
148
|
+
const value = this.context().state === '' ? null : this.context().state;
|
|
149
|
+
const attribute = (await this.getLabel()).toLowerCase();
|
|
150
|
+
for (const rule of await this.getValidationRules()) {
|
|
151
|
+
if (rule.name !== 'required' && blank(value))
|
|
152
|
+
continue;
|
|
153
|
+
if (!(await rule.passes(value, this.context())))
|
|
154
|
+
return [rule.message(attribute)];
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
/** Port de Field::wrapEmbeddedHtml (enveloppe par défaut). */
|
|
159
|
+
/** Valeur écrite dans les données à enregistrer (les champs composés n'y laissent qu'une ossature, remplie par leurs enfants). */
|
|
160
|
+
dehydrateState(state) { return state; }
|
|
161
|
+
/** Dernière mise en forme de la valeur enregistrée, une fois les champs enfants dé-hydratés (liste d'éléments d'un Builder). */
|
|
162
|
+
finalizeDehydratedState(state) { return state; }
|
|
163
|
+
async wrap(html, options = {}) {
|
|
164
|
+
const errors = this.getErrors();
|
|
165
|
+
// Champ d'un FusedGroup : enveloppe réduite à un libellé pour lecteurs d'écran (port de plain-field-wrapper).
|
|
166
|
+
if (this.container.getFieldWrapperView?.() === 'filament-forms::plain-field-wrapper') {
|
|
167
|
+
const label = await this.getLabel();
|
|
168
|
+
const labelHtml = label === '' ? '' : options.labelAsDiv ? `<div id="${e(this.getId())}-label" class="fi-fo-field-label fi-sr-only">${e(label)}</div>` : `<label for="${e(this.getId())}" class="fi-fo-field-label fi-sr-only">${e(label)}</label>`;
|
|
169
|
+
return `<div data-field-wrapper class="fi-fo-field">${labelHtml}${options.labelPrefix ?? ''}${html}${options.after ?? ''}</div>`;
|
|
170
|
+
}
|
|
171
|
+
// Texte d'aide : un petit schéma « en ligne » sous le contrôle (port de belowContent).
|
|
172
|
+
if (this._helperText !== null)
|
|
173
|
+
html += `<div id="${e(this.getHelperTextId())}" class="fi-sc fi-inline fi-sc-has-gap fi-sc-dense"><div x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: this.getStatePath(), containerPath: this.container.getStatePath() }))})"><div class="fi-sc-component"><span class="fi-sc-text">${e(this._helperText)}</span></div></div></div>`;
|
|
174
|
+
return views.render('filament-forms::components.field-wrapper', {
|
|
175
|
+
id: this.getId(), label: await this.getLabel(), required: (await this.isRequired()) && !(await this.isDisabled()),
|
|
176
|
+
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,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export class TextInput extends Field {
|
|
181
|
+
_type = null;
|
|
182
|
+
_isPassword = false;
|
|
183
|
+
_isRevealable = false;
|
|
184
|
+
_autocomplete = null;
|
|
185
|
+
_maxLength = null;
|
|
186
|
+
_minLength = null;
|
|
187
|
+
_placeholder = null;
|
|
188
|
+
_numeric = null;
|
|
189
|
+
_minValue = null;
|
|
190
|
+
_maxValue = null;
|
|
191
|
+
_prefix = null;
|
|
192
|
+
_live = null;
|
|
193
|
+
static make(name) { return configured(new TextInput(name)); }
|
|
194
|
+
/** Nombre décimal : `type="number"`, `step="any"`. */
|
|
195
|
+
numeric() { this._numeric = 'decimal'; return this.rule({ name: 'numeric', passes: (v) => String(v).trim() !== '' && Number.isFinite(Number(v)), message: (a) => `The ${a} field must be a number.` }); }
|
|
196
|
+
/** Nombre entier : `type="number"`, `step="1"`. */
|
|
197
|
+
integer() { this._numeric = 'integer'; return this.rule({ name: 'integer', passes: (v) => /^-?\d+$/.test(String(v).trim()), message: (a) => `The ${a} field must be an integer.` }); }
|
|
198
|
+
_step = null;
|
|
199
|
+
/** Pas de saisie d'un champ numérique (`step`), à la place de celui déduit de `numeric()` ou `integer()`. */
|
|
200
|
+
step(step) { this._step = step; return this; }
|
|
201
|
+
minValue(value) { this._minValue = value; return this.rule({ name: 'min', passes: (v) => Number(v) >= value, message: (a) => `The ${a} field must be at least ${value}.` }); }
|
|
202
|
+
maxValue(value) { this._maxValue = value; return this.rule({ name: 'max', passes: (v) => Number(v) <= value, message: (a) => `The ${a} field must not be greater than ${value}.` }); }
|
|
203
|
+
/** Libellé affiché avant la zone de saisie (ex. `$`). */
|
|
204
|
+
prefix(label) { this._prefix = label; return this; }
|
|
205
|
+
_suffix = null;
|
|
206
|
+
/** Libellé affiché après la saisie (« hours », « kg »…). */
|
|
207
|
+
suffix(label) { this._suffix = label; return this; }
|
|
208
|
+
/** L'état est envoyé au serveur à chaque frappe, ou à la perte de focus avec `onBlur`. */
|
|
209
|
+
live(options = {}) { this._live = options.onBlur ? 'blur' : 'live'; return this; }
|
|
210
|
+
type(type) { this._type = type; return this; }
|
|
211
|
+
email(condition = true) {
|
|
212
|
+
if (condition) {
|
|
213
|
+
this._type = 'email';
|
|
214
|
+
this.rule({ name: 'email', passes: (v) => /^[^\s@]+@[^\s@]+$/.test(String(v)), message: (a) => `The ${a} field must be a valid email address.` });
|
|
215
|
+
}
|
|
216
|
+
return this;
|
|
217
|
+
}
|
|
218
|
+
password(condition = true) { this._isPassword = condition; return this; }
|
|
219
|
+
revealable(condition = true) { this._isRevealable = condition; return this; }
|
|
220
|
+
/** `true` → "on", `false` → "off", chaîne → telle quelle (comme CanBeAutocompleted). */
|
|
221
|
+
autocomplete(value = true) { this._autocomplete = value === true ? 'on' : value === false ? 'off' : value; return this; }
|
|
222
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
223
|
+
/** Adresse web : champ de type `url`, valeur http(s) valide exigée. */
|
|
224
|
+
_mask = null;
|
|
225
|
+
/** Masque de saisie Alpine (`(999) 999-9999`) : guide la frappe dans le navigateur, sans valeur de validation. */
|
|
226
|
+
mask(pattern) { this._mask = pattern; return this; }
|
|
227
|
+
/** Numéro de téléphone : chiffres, espaces et ponctuation usuelle. */
|
|
228
|
+
tel() { this._type = 'tel'; return this.rule({ name: 'tel', passes: (v) => /^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\.\/0-9]*$/.test(String(v)), message: (a) => `The ${a} field format is invalid.` }); }
|
|
229
|
+
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.` }); }
|
|
230
|
+
url() { this._type = 'url'; return this.rule({ name: 'url', passes: (v) => { try {
|
|
231
|
+
return ['http:', 'https:'].includes(new URL(String(v)).protocol);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return false;
|
|
235
|
+
} }, message: (a) => `The ${a} field must be a valid URL.` }); }
|
|
236
|
+
maxLength(length) { this._maxLength = length; return this.rule({ name: 'max', passes: (v) => String(v).length <= length, message: (a) => `The ${a} field must not be greater than ${length} characters.` }); }
|
|
237
|
+
minLength(length) { this._minLength = length; return this.rule({ name: 'min', passes: (v) => String(v).length >= length, message: (a) => `The ${a} field must be at least ${length} characters.` }); }
|
|
238
|
+
/** Port de TextInput::toEmbeddedHtml (sans affixes libres, masque ni datalist pour l'instant). */
|
|
239
|
+
async toHtml() {
|
|
240
|
+
const isDisabled = await this.isDisabled();
|
|
241
|
+
const revealable = this._isPassword && this._isRevealable;
|
|
242
|
+
const type = revealable ? 'password' : this._mask !== null ? 'text' : (this._type ?? (this._isPassword ? 'password' : this._numeric ? 'number' : 'text'));
|
|
243
|
+
const model = `wire:model${this._live ? '.live' : ''}${this._live === 'blur' ? '.blur' : ''}`;
|
|
244
|
+
const input = new Attributes()
|
|
245
|
+
.merge({
|
|
246
|
+
'aria-invalid': this.hasError() ? 'true' : null,
|
|
247
|
+
'aria-describedby': this.getHelperTextId(),
|
|
248
|
+
autocomplete: this._autocomplete, autofocus: this._autofocus, disabled: isDisabled, id: this.getId(),
|
|
249
|
+
inputmode: this._numeric === 'decimal' ? 'decimal' : this._numeric === 'integer' ? 'numeric' : null,
|
|
250
|
+
max: phpNumber(this._maxValue), min: phpNumber(this._minValue),
|
|
251
|
+
maxlength: this._maxLength, minlength: this._minLength, placeholder: this._placeholder,
|
|
252
|
+
required: (await this.isRequired()) && !isDisabled, step: this._step ?? (this._numeric === 'decimal' ? 'any' : this._numeric === 'integer' ? 1 : null), type,
|
|
253
|
+
// Comme Filament : les modificateurs (`.live`, `.blur`) s'ajoutent aussi à `wire:model.fill`.
|
|
254
|
+
[revealable ? model.replace('wire:model', 'wire:model.fill') : model]: this.getStatePath(),
|
|
255
|
+
'x-bind:type': revealable ? "isPasswordRevealed ? 'text' : 'password'" : null,
|
|
256
|
+
'x-mask': this._mask,
|
|
257
|
+
})
|
|
258
|
+
.class(['fi-input', { 'fi-revealable': revealable }]);
|
|
259
|
+
const wrapper = new Attributes()
|
|
260
|
+
.merge({
|
|
261
|
+
'x-data': revealable ? 'passwordRevealFormComponent()' : this._mask !== null ? '{}' : null,
|
|
262
|
+
'x-load': revealable ? '' : null,
|
|
263
|
+
'x-load-src': revealable ? views.render('filament-forms::asset', { path: 'text-input/password-reveal' }).trim() : null,
|
|
264
|
+
'x-on:focus-input.stop': "$el.querySelector('input')?.focus()",
|
|
265
|
+
})
|
|
266
|
+
.class(['fi-fo-text-input', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
267
|
+
return this.wrap(views.render('filament-forms::components.text-input', {
|
|
268
|
+
input: input.toHtml(), wrapper: wrapper.toHtml(), revealable, prefix: this._prefix, suffix: this._suffix,
|
|
269
|
+
showLabel: t('filament-forms::components.text_input.actions.show_password.label'),
|
|
270
|
+
hideLabel: t('filament-forms::components.text_input.actions.hide_password.label'),
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Sélecteur de date natif (`<input type="date">`). Le sélecteur JavaScript de Filament n'est pas encore porté. */
|
|
275
|
+
export class DatePicker extends Field {
|
|
276
|
+
_maxDate = null;
|
|
277
|
+
_minDate = null;
|
|
278
|
+
_live = false;
|
|
279
|
+
static make(name) { return configured(new DatePicker(name)); }
|
|
280
|
+
_datePlaceholder = null;
|
|
281
|
+
/** Texte indicatif (les navigateurs ne l'affichent pas sur un champ `date` natif ; l'attribut est posé comme dans Filament). */
|
|
282
|
+
placeholder(text) { this._datePlaceholder = text; return this; }
|
|
283
|
+
/** Envoie l'état au serveur dès qu'il change (`wire:model.live`). */
|
|
284
|
+
live(condition = true) { this._live = condition; return this; }
|
|
285
|
+
/** Comme Filament : l'état d'un DatePicker est une date `AAAA-MM-JJ`, quelle que soit la précision stockée. */
|
|
286
|
+
hydrateState(value) { return typeof value === 'string' && value.length > 10 ? value.slice(0, 10) : value; }
|
|
287
|
+
static resolve(date) { return date === 'today' ? new Date().toISOString().slice(0, 10) : date; }
|
|
288
|
+
async bound(date) { const value = await evaluate(date, this.context()); return value ? DatePicker.resolve(value) : null; }
|
|
289
|
+
/** Date la plus tardive acceptée : `'today'`, une date `AAAA-MM-JJ`, ou une fonction du contexte (`({ get }) => get('endDate') || 'today'`). */
|
|
290
|
+
maxDate(date) {
|
|
291
|
+
this._maxDate = date;
|
|
292
|
+
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'}.` });
|
|
293
|
+
}
|
|
294
|
+
minDate(date) {
|
|
295
|
+
this._minDate = date;
|
|
296
|
+
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'}.` });
|
|
297
|
+
}
|
|
298
|
+
async toHtml() {
|
|
299
|
+
const isDisabled = await this.isDisabled();
|
|
300
|
+
const input = new Attributes()
|
|
301
|
+
.merge({
|
|
302
|
+
'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
|
|
303
|
+
max: await this.bound(this._maxDate), min: await this.bound(this._minDate), placeholder: this._datePlaceholder,
|
|
304
|
+
required: (await this.isRequired()) && !isDisabled, type: 'date', [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath(),
|
|
305
|
+
})
|
|
306
|
+
.class(['fi-input']);
|
|
307
|
+
const wrapper = new Attributes()
|
|
308
|
+
.merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
|
|
309
|
+
.class(['fi-fo-date-time-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
310
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Liste déroulante. Native (`<select>`) par défaut ; `searchable()` active le sélecteur JavaScript de Filament,
|
|
315
|
+
* qui interroge le serveur par les méthodes exposées (`getSearchResultsForJs`…).
|
|
316
|
+
*/
|
|
317
|
+
export class Select extends Field {
|
|
318
|
+
_options = [];
|
|
319
|
+
_placeholder = null;
|
|
320
|
+
_searchable = false;
|
|
321
|
+
_dynamic = null;
|
|
322
|
+
_searchOnServer = null;
|
|
323
|
+
_live = false;
|
|
324
|
+
_hasNoInitialOptions = false;
|
|
325
|
+
_multiple = false;
|
|
326
|
+
_optionLabelsUsing = null;
|
|
327
|
+
_disableOptionWhen = null;
|
|
328
|
+
_searchResultsUsing = null;
|
|
329
|
+
_optionLabelUsing = null;
|
|
330
|
+
static make(name) { return configured(new Select(name)); }
|
|
331
|
+
/** Résultats de recherche calculés par le serveur à chaque frappe (sans liste d'options préchargée). */
|
|
332
|
+
getSearchResultsUsing(resolver) { this._searchResultsUsing = resolver; this._searchable = true; return this; }
|
|
333
|
+
/** Libellé d'une valeur ; sert aussi à valider qu'une valeur choisie par recherche existe bien. */
|
|
334
|
+
getOptionLabelUsing(resolver) { this._optionLabelUsing = resolver; return this; }
|
|
335
|
+
/** Options fixes, ou fonction (éventuellement asynchrone) évaluée à chaque requête. */
|
|
336
|
+
/** `dynamic: false` : options fournies par une fonction mais fixes pour la durée de la page (annoncées comme statiques au navigateur). */
|
|
337
|
+
/**
|
|
338
|
+
* `dynamic: false` : options fournies par une fonction mais fixes pour la durée de la page.
|
|
339
|
+
* `searchOnServer: false` : la recherche se fait dans le navigateur, parmi les options déjà envoyées (cas d'une liste complète).
|
|
340
|
+
*/
|
|
341
|
+
options(options, config = {}) { this._options = options; this._dynamic = config.dynamic ?? null; this._searchOnServer = config.searchOnServer ?? null; return this; }
|
|
342
|
+
/** Option non sélectionnable selon le contexte (par exemple déjà choisie dans une autre ligne). */
|
|
343
|
+
disableOptionWhen(condition) { this._disableOptionWhen = condition; return this; }
|
|
344
|
+
/** L'état est envoyé au serveur dès qu'il change. */
|
|
345
|
+
live(condition = true) { this._live = condition; return this; }
|
|
346
|
+
placeholder(label) { this._placeholder = label; return this; }
|
|
347
|
+
_selectablePlaceholder = true;
|
|
348
|
+
/** `false` : le sélecteur natif n'a pas d'option vide (port de `selectablePlaceholder()`). */
|
|
349
|
+
selectablePlaceholder(condition = true) { this._selectablePlaceholder = condition; return this; }
|
|
350
|
+
searchable(condition = true) { this._searchable = condition; return this; }
|
|
351
|
+
/** Sélection multiple : l'état est une liste de valeurs ; le sélecteur JavaScript est toujours utilisé. */
|
|
352
|
+
multiple(condition = true) { this._multiple = condition; if (condition)
|
|
353
|
+
this._searchable = true; return this; }
|
|
354
|
+
isMultiple() { return this._multiple; }
|
|
355
|
+
/** Libellés de plusieurs valeurs d'un coup (sélection multiple avec recherche serveur) ; sert aussi à valider les valeurs. */
|
|
356
|
+
getOptionLabelsUsing(resolver) { this._optionLabelsUsing = resolver; return this; }
|
|
357
|
+
getDefaultState() { return this._default ?? (this._multiple ? [] : null); }
|
|
358
|
+
/** Valeurs sélectionnées, nettoyées : des chaînes, sans doublon. */
|
|
359
|
+
selectedValues() { const state = this.context().state; return [...new Set((Array.isArray(state) ? state : []).filter((v) => typeof v === 'string' || typeof v === 'number').map(String))]; }
|
|
360
|
+
async labelsOf(values) {
|
|
361
|
+
if (!values.length)
|
|
362
|
+
return [];
|
|
363
|
+
const known = this._optionLabelsUsing ? await this._optionLabelsUsing(values, this.context()) : await this.resolveOptions();
|
|
364
|
+
const map = new Map((Array.isArray(known) ? known : Object.entries(known)).map(([value, label]) => [String(value), String(label)]));
|
|
365
|
+
return values.filter((value) => map.has(value)).map((value) => [value, map.get(value)]);
|
|
366
|
+
}
|
|
367
|
+
/** Sans effet ici : les options sont toujours chargées avec le formulaire. */
|
|
368
|
+
preload() { return this; }
|
|
369
|
+
hydrateState(value) {
|
|
370
|
+
if (this._multiple)
|
|
371
|
+
return (Array.isArray(value) ? value : []).map(String);
|
|
372
|
+
return value === null || value === undefined || value === '' ? null : String(value);
|
|
373
|
+
}
|
|
374
|
+
dehydrateState(state) { return this._multiple ? this.selectedValues() : state; }
|
|
375
|
+
async resolveOptions() {
|
|
376
|
+
const options = typeof this._options === 'function' ? await this._options(this.context()) : this._options;
|
|
377
|
+
return (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
|
|
378
|
+
}
|
|
379
|
+
async getValidationRules() {
|
|
380
|
+
// Avec une recherche serveur, la valeur est valide si le serveur sait lui donner un libellé ; sinon elle doit figurer dans les options.
|
|
381
|
+
if (this._multiple)
|
|
382
|
+
return [...(await super.getValidationRules()), { name: 'in', passes: async (v) => { const values = Array.isArray(v) ? v.map(String) : []; return (await this.labelsOf([...new Set(values)])).length === new Set(values).size; }, message: (a) => `The selected ${a} is invalid.` }];
|
|
383
|
+
const passes = async (v) => (this._searchResultsUsing ? (await this._optionLabelUsing?.(String(v), this.context())) != null : (await this.resolveOptions()).some(([value]) => value === String(v)));
|
|
384
|
+
return [...(await super.getValidationRules()), { name: 'in', passes, message: (a) => `The selected ${a} is invalid.` }];
|
|
385
|
+
}
|
|
386
|
+
// --- méthodes exposées au JavaScript du sélecteur (appelées par callSchemaComponentMethod)
|
|
387
|
+
getExposedMethods() { return ['getOptionLabel', 'getSearchResultsForJs', 'getOptionsForJs', 'getOptionLabelsForJs']; }
|
|
388
|
+
static forJs(options) { return options.map(([value, label]) => ({ label, value, isDisabled: false })); }
|
|
389
|
+
async getOptionsForJs() { return this.withDisabled(Select.forJs(await this.resolveOptions())); }
|
|
390
|
+
withDisabled(options) {
|
|
391
|
+
if (!this._disableOptionWhen)
|
|
392
|
+
return options;
|
|
393
|
+
const ctx = this.context();
|
|
394
|
+
return options.map((option) => ({ ...option, isDisabled: this._disableOptionWhen(option.value, ctx) }));
|
|
395
|
+
}
|
|
396
|
+
async getSearchResultsForJs(search = '') {
|
|
397
|
+
if (this._searchResultsUsing) {
|
|
398
|
+
const found = await this._searchResultsUsing(String(search ?? '').slice(0, 1000), this.context());
|
|
399
|
+
return Select.forJs((Array.isArray(found) ? found : Object.entries(found)).map(([value, label]) => [String(value), String(label)]));
|
|
400
|
+
}
|
|
401
|
+
const needle = String(search ?? '').toLowerCase();
|
|
402
|
+
return Select.forJs((await this.resolveOptions()).filter(([, label]) => label.toLowerCase().includes(needle)));
|
|
403
|
+
}
|
|
404
|
+
async getOptionLabel() {
|
|
405
|
+
const state = this.context().state;
|
|
406
|
+
if (state === null || state === undefined || state === '')
|
|
407
|
+
return null;
|
|
408
|
+
if (this._optionLabelUsing)
|
|
409
|
+
return this._optionLabelUsing(String(state), this.context());
|
|
410
|
+
return (await this.resolveOptions()).find(([value]) => value === String(state))?.[1] ?? null;
|
|
411
|
+
}
|
|
412
|
+
async getOptionLabelsForJs() { return this._multiple ? (await this.labelsOf(this.selectedValues())).map(([value, label]) => ({ label, value })) : []; }
|
|
413
|
+
// --- création et modification d'une option dans une modale (port de createOptionForm / editOptionForm)
|
|
414
|
+
_createOptionForm = null;
|
|
415
|
+
_createOptionUsing = null;
|
|
416
|
+
_createOptionModalHeading = null;
|
|
417
|
+
_modifyCreateOptionAction = null;
|
|
418
|
+
_editOptionForm = null;
|
|
419
|
+
_updateOptionUsing = null;
|
|
420
|
+
_fillEditOptionForm = null;
|
|
421
|
+
_editOptionModalHeading = null;
|
|
422
|
+
_modifyEditOptionAction = null;
|
|
423
|
+
/**
|
|
424
|
+
* Formulaire de la modale « Create » ouverte par le bouton « + » du champ. Sa validation est faite côté serveur ;
|
|
425
|
+
* `createOptionUsing()` enregistre l'option et rend sa valeur, qui devient l'état du champ (ajoutée à la liste pour un `multiple()`).
|
|
426
|
+
* Les options doivent être fournies par une fonction (`options(() => …)`, ou une recherche serveur) pour que la nouvelle valeur y figure.
|
|
427
|
+
*/
|
|
428
|
+
createOptionForm(schema) { this._createOptionForm = schema; return this; }
|
|
429
|
+
/** Enregistre l'option à partir des données validées de la modale ; rend la valeur (clé) de l'option créée. */
|
|
430
|
+
createOptionUsing(callback) { this._createOptionUsing = callback; return this; }
|
|
431
|
+
createOptionModalHeading(heading) { this._createOptionModalHeading = heading; return this; }
|
|
432
|
+
/** Personnalise l'action de création (libellé, icône, largeur de modale…). */
|
|
433
|
+
createOptionAction(modify) { this._modifyCreateOptionAction = modify; return this; }
|
|
434
|
+
/**
|
|
435
|
+
* Formulaire de la modale « Edit » de l'option sélectionnée (sélection simple seulement). Le champ devient `live()` pour que le bouton
|
|
436
|
+
* apparaisse dès qu'une option est choisie. Préremplissage par `fillEditOptionActionFormUsing()`, enregistrement par `updateOptionUsing()`.
|
|
437
|
+
*/
|
|
438
|
+
editOptionForm(schema) { this._editOptionForm = schema; this._live = true; return this; }
|
|
439
|
+
updateOptionUsing(callback) { this._updateOptionUsing = callback; return this; }
|
|
440
|
+
/** Données initiales de la modale « Edit » (le contexte donne l'état courant du champ dans `state`). */
|
|
441
|
+
fillEditOptionActionFormUsing(callback) { this._fillEditOptionForm = callback; return this; }
|
|
442
|
+
editOptionModalHeading(heading) { this._editOptionModalHeading = heading; return this; }
|
|
443
|
+
editOptionAction(modify) { this._modifyEditOptionAction = modify; return this; }
|
|
444
|
+
/** Même formulaire pour la création et la modification. */
|
|
445
|
+
manageOptionForm(schema) { return this.createOptionForm(schema).editOptionForm(schema); }
|
|
446
|
+
setState(value) { writePath(this.livewire, this.getStatePath().split('.'), value); }
|
|
447
|
+
getCreateOptionAction() {
|
|
448
|
+
if (!this._createOptionForm)
|
|
449
|
+
return null;
|
|
450
|
+
const tr = (key) => t(`filament-forms::components.select.actions.create_option.${key}`);
|
|
451
|
+
const action = Action.make('createOption').label(tr('label')).icon('m-plus').iconButton().color('gray')
|
|
452
|
+
.hidden(() => this.isDisabled())
|
|
453
|
+
.modalHeading(this._createOptionModalHeading ?? tr('modal.heading'))
|
|
454
|
+
.modalSubmitActionLabel(tr('modal.actions.create.label'))
|
|
455
|
+
.schema(this._createOptionForm)
|
|
456
|
+
.action(async ({ data }) => {
|
|
457
|
+
if (!this._createOptionUsing)
|
|
458
|
+
throw new Error(`Select [${this.getStatePath()}] : createOptionUsing() est requis avec createOptionForm().`);
|
|
459
|
+
const created = await this._createOptionUsing(data, this.context());
|
|
460
|
+
if (created === null || created === undefined || created === '')
|
|
461
|
+
return;
|
|
462
|
+
this.setState(this._multiple ? [...new Set([...this.selectedValues(), String(created)])] : String(created));
|
|
463
|
+
await this.callAfterStateUpdated((name, value) => writePath(this.livewire, [...this.container.getStatePath().split('.').filter(Boolean), ...name.split('.')], value));
|
|
464
|
+
});
|
|
465
|
+
// Sélection multiple : « Create & create another » garde la modale ouverte sur un formulaire vide.
|
|
466
|
+
if (this._multiple)
|
|
467
|
+
action.extraModalFooterActions([Action.make('createAnother').label(tr('modal.actions.create_another.label')).color('gray').action(`callMountedAction(${js({ another: true })})`)]);
|
|
468
|
+
return this._modifyCreateOptionAction?.(action) ?? action;
|
|
469
|
+
}
|
|
470
|
+
getEditOptionAction() {
|
|
471
|
+
if (!this._editOptionForm || this._multiple)
|
|
472
|
+
return null;
|
|
473
|
+
const tr = (key) => t(`filament-forms::components.select.actions.edit_option.${key}`);
|
|
474
|
+
const filled = () => { const state = this.context().state; return state !== null && state !== undefined && state !== ''; };
|
|
475
|
+
const action = Action.make('editOption').label(tr('label')).icon('m-pencil-square').iconButton().color('gray')
|
|
476
|
+
.hidden(async () => (await this.isDisabled()) || !filled())
|
|
477
|
+
.modalHeading(this._editOptionModalHeading ?? tr('modal.heading'))
|
|
478
|
+
.modalSubmitActionLabel(tr('modal.actions.save.label'))
|
|
479
|
+
.schema(this._editOptionForm)
|
|
480
|
+
.fillForm(async () => (await this._fillEditOptionForm?.(this.context())) ?? {})
|
|
481
|
+
.action(async ({ data }) => {
|
|
482
|
+
if (!this._updateOptionUsing)
|
|
483
|
+
throw new Error(`Select [${this.getStatePath()}] : updateOptionUsing() est requis avec editOptionForm().`);
|
|
484
|
+
await this._updateOptionUsing(data, this.context());
|
|
485
|
+
});
|
|
486
|
+
return this._modifyEditOptionAction?.(action) ?? action;
|
|
487
|
+
}
|
|
488
|
+
/** Actions montées sur le champ : indication, création et modification d'option. */
|
|
489
|
+
getActions() { return [...this.getHintActions(), ...[this.getCreateOptionAction(), this.getEditOptionAction()].filter((action) => action !== null)]; }
|
|
490
|
+
/** Boutons « + » et « modifier » à droite du champ (port des suffixActions de Select). */
|
|
491
|
+
async suffixHtml() {
|
|
492
|
+
const ctx = this.context();
|
|
493
|
+
const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.getKey() };
|
|
494
|
+
const buttons = [];
|
|
495
|
+
for (const action of [this.getCreateOptionAction(), this.getEditOptionAction()]) {
|
|
496
|
+
if (action && (await action.isVisible(ctx)))
|
|
497
|
+
buttons.push((await action.toLinkHtml(ctx, context, '')).replace(' wire:key=""', ''));
|
|
498
|
+
}
|
|
499
|
+
return buttons.length ? `<div class="fi-input-wrp-suffix"><div class="fi-input-wrp-actions">${buttons.join('')}</div></div>` : '';
|
|
500
|
+
}
|
|
501
|
+
async toHtml() {
|
|
502
|
+
const isDisabled = await this.isDisabled();
|
|
503
|
+
const resolved = await this.resolveOptions();
|
|
504
|
+
const suffix = await this.suffixHtml();
|
|
505
|
+
if (this._searchable) {
|
|
506
|
+
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
507
|
+
const after = isRequired ? `<span id="${e(this.getId())}-required" class="fi-sr-only">${e(t('filament-forms::components.select.required_description'))}</span>` : '';
|
|
508
|
+
return this.wrap(await this.toJsHtml(resolved, isDisabled, suffix), { wrapperClass: 'fi-fo-select-wrp', after });
|
|
509
|
+
}
|
|
510
|
+
const select = new Attributes()
|
|
511
|
+
.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() })
|
|
512
|
+
.class(['fi-select-input']);
|
|
513
|
+
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() }]);
|
|
514
|
+
const options = [this._selectablePlaceholder ? `<option value="">${isDisabled ? '' : e(this._placeholder ?? t('filament-forms::components.select.placeholder'))}</option>` : '', ...resolved.map(([value, label]) => `<option value="${e(value)}">${e(label)}</option>`)].join('');
|
|
515
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><select ${select.toHtml()}>${options}</select></div>${suffix}</div>`, { wrapperClass: 'fi-fo-select-wrp' });
|
|
516
|
+
}
|
|
517
|
+
/** Port du rendu JavaScript de Select::toEmbeddedHtml (sélection simple). */
|
|
518
|
+
async toJsHtml(options, isDisabled, suffix = '') {
|
|
519
|
+
const state = this.context().state;
|
|
520
|
+
const hasState = !this._multiple && state !== null && state !== undefined && state !== '';
|
|
521
|
+
const selected = this._multiple ? await this.labelsOf(this.selectedValues()) : [];
|
|
522
|
+
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
523
|
+
// Champ obligatoire : une consigne pour lecteurs d'écran (« Select an option. ») est liée au sélecteur.
|
|
524
|
+
const describedBy = [isRequired ? `${this.getId()}-required` : null, this.hasError() ? `${this.getId()}-error` : null].filter(Boolean).join(' ') || null;
|
|
525
|
+
const accessibility = JSON.stringify({ 'aria-describedby': describedBy, 'aria-invalid': this.hasError() ? 'true' : null, 'aria-required': isRequired ? 'true' : null });
|
|
526
|
+
const forJs = this.withDisabled(Select.forJs(options));
|
|
527
|
+
// Options « dynamiques » : fournies par une fonction, donc susceptibles de changer d'une requête à l'autre.
|
|
528
|
+
const hasDynamicOptions = this._dynamic ?? typeof this._options === 'function';
|
|
529
|
+
const tr = (key, replace = {}) => t(`filament-forms::components.select.${key}`, replace);
|
|
530
|
+
const config = `selectFormComponent({
|
|
531
|
+
canOptionLabelsWrap: true,
|
|
532
|
+
canSelectPlaceholder: true,
|
|
533
|
+
clearButtonLabel: ${js(tr('actions.clear.label'))},
|
|
534
|
+
errorMessage: ${js(tr('error_message'))},
|
|
535
|
+
hasDynamicOptions: ${hasDynamicOptions ? 'true' : 'false'},
|
|
536
|
+
hasDynamicSearchResults: ${this._searchResultsUsing || (this._searchOnServer ?? hasDynamicOptions) ? 'true' : 'false'},
|
|
537
|
+
hasInitialNoOptionsMessage: ${this._searchResultsUsing || this._hasNoInitialOptions ? 'false' : 'true'},
|
|
538
|
+
id: ${js(this.getId())},
|
|
539
|
+
initialOptionLabel: ${js(hasState ? await this.getOptionLabel() : null)},
|
|
540
|
+
initialOptionLabels: ${selected.length ? js(selected.map(([value, label]) => ({ label, value, isDisabled: false }))) : '[]'},
|
|
541
|
+
initialState: ${this._multiple ? (selected.length ? js(selected.map(([value]) => value)) : '[]') : js(hasState ? String(state) : null)},
|
|
542
|
+
isAutofocused: ${this._autofocus ? 'true' : 'false'},
|
|
543
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
544
|
+
isHtmlAllowed: false,
|
|
545
|
+
isMultiple: ${this._multiple ? 'true' : 'false'},
|
|
546
|
+
isReorderable: false,
|
|
547
|
+
isSearchable: true,
|
|
548
|
+
key: ${js(this.getKey())},
|
|
549
|
+
livewireId: ${js(this.livewire.id)},
|
|
550
|
+
loadingMessage: ${js(tr('loading_message'))},
|
|
551
|
+
maxItems: null,
|
|
552
|
+
maxItemsMessage: ${js(tr('max_items_message', { count: '' }))},
|
|
553
|
+
noOptionsMessage: ${js(tr('no_options_message'))},
|
|
554
|
+
noSearchResultsMessage: ${js(tr('no_search_results_message'))},
|
|
555
|
+
options: ${forJs.length ? js(forJs) : '[]'},
|
|
556
|
+
optionsLimit: 50,
|
|
557
|
+
placeholder: ${js(this._placeholder ?? tr('placeholder'))},
|
|
558
|
+
position: null,
|
|
559
|
+
removeButtonLabel: ${js(tr('actions.remove_option.label'))},
|
|
560
|
+
searchDebounce: 1000,
|
|
561
|
+
searchingMessage: ${js(tr('searching_message'))},
|
|
562
|
+
searchLabel: ${js(tr('search_label'))},
|
|
563
|
+
searchPrompt: ${js(tr('search_prompt'))},
|
|
564
|
+
searchableOptionFields: ${js(['label'])},
|
|
565
|
+
state: $wire.$entangle(${js(this.getStatePath())}, ${this._live ? 'true' : 'false'}),
|
|
566
|
+
statePath: ${js(this.getStatePath())},
|
|
567
|
+
})`;
|
|
568
|
+
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() }]);
|
|
569
|
+
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify(forJs)).digest('hex')}`;
|
|
570
|
+
return `<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div class="fi-hidden" data-select-accessibility="${e(accessibility)}" x-data="{
|
|
571
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
572
|
+
init() {
|
|
573
|
+
const container = $el.nextElementSibling
|
|
574
|
+
container.dispatchEvent(
|
|
575
|
+
new CustomEvent('set-select-property', {
|
|
576
|
+
detail: { isDisabled: this.isDisabled },
|
|
577
|
+
}),
|
|
578
|
+
)
|
|
579
|
+
},
|
|
580
|
+
}"></div><div x-load x-load-src="${e(asset('select'))}" x-data="${e(config)}" wire:ignore wire:key="${e(key)}" x-on:set-select-property="$event.detail.isDisabled ? select.disable() : select.enable()" class="fi-select-input"><div x-ref="select"></div></div></div>${suffix}</div>`;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** Colonne d'un Repeater en table : intitulé et largeur. */
|
|
584
|
+
export class TableColumn {
|
|
585
|
+
label;
|
|
586
|
+
_width = null;
|
|
587
|
+
constructor(label) { this.label = label; }
|
|
588
|
+
static make(label) { return configured(new TableColumn(label)); }
|
|
589
|
+
width(width) { this._width = typeof width === 'number' ? `${width}px` : width; return this; }
|
|
590
|
+
getWidth() { return this._width; }
|
|
591
|
+
}
|
|
592
|
+
const ITEM_KEY = /^[A-Za-z0-9_-]{1,64}$/;
|
|
593
|
+
const isItemKey = (key) => ITEM_KEY.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
|
|
594
|
+
/**
|
|
595
|
+
* Liste d'éléments répétés, chacun avec les mêmes champs (port de Repeater, affichage en table).
|
|
596
|
+
* L'état est une table `clé d'élément → champs` ; l'ordre des clés est l'ordre d'affichage.
|
|
597
|
+
* Ajout, suppression et déplacement sont des actions du composant, montées avec `schemaComponent`.
|
|
598
|
+
*/
|
|
599
|
+
export class Repeater extends Field {
|
|
600
|
+
_schema = () => [];
|
|
601
|
+
_columns = [];
|
|
602
|
+
_defaultItems = 0;
|
|
603
|
+
_maxItems = 500;
|
|
604
|
+
_itemLinks = [];
|
|
605
|
+
static make(name) { return configured(new Repeater(name)); }
|
|
606
|
+
/** Champs d'un élément : une fonction, appelée pour chaque élément. */
|
|
607
|
+
schema(factory) { this._schema = factory; return this; }
|
|
608
|
+
/** Affichage en table, une colonne par champ. */
|
|
609
|
+
table(columns) { this._columns = columns; return this; }
|
|
610
|
+
defaultItems(count) { this._defaultItems = count; return this; }
|
|
611
|
+
maxItems(count) { this._maxItems = count; return this; }
|
|
612
|
+
_reorderable = true;
|
|
613
|
+
/** Faux : les éléments gardent leur ordre (cas d'une relation sans colonne d'ordre dans Filament). */
|
|
614
|
+
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
615
|
+
/** Lien affiché sur chaque ligne (équivalent d'une action d'élément à URL). */
|
|
616
|
+
extraItemLink(resolver) { this._itemLinks.push(resolver); return this; }
|
|
617
|
+
// --- état
|
|
618
|
+
/** Éléments bien formés : les clés viennent du navigateur, seules les clés simples sont retenues. */
|
|
619
|
+
getItems() {
|
|
620
|
+
const state = this.context().state;
|
|
621
|
+
if (!state || typeof state !== 'object' || Array.isArray(state))
|
|
622
|
+
return {};
|
|
623
|
+
return Object.fromEntries(Object.entries(state).filter(([key, item]) => isItemKey(key) && item !== null && typeof item === 'object' && !Array.isArray(item)).slice(0, this._maxItems));
|
|
624
|
+
}
|
|
625
|
+
setItems(items) { writePath(this.livewire, this.getStatePath().split('.'), items); }
|
|
626
|
+
itemDefaults() { return Object.fromEntries(this._schema().map((field) => [field.getName(), field.getDefaultState()])); }
|
|
627
|
+
getDefaultState() { return Object.fromEntries(Array.from({ length: this._defaultItems }, () => [randomUUID(), this.itemDefaults()])); }
|
|
628
|
+
/** Enregistrements d'une relation → éléments `record-<id>`, réduits aux champs déclarés. */
|
|
629
|
+
hydrateState(value) {
|
|
630
|
+
if (!Array.isArray(value))
|
|
631
|
+
return value && typeof value === 'object' ? value : {};
|
|
632
|
+
const fields = this._schema();
|
|
633
|
+
return Object.fromEntries(value.map((row) => [`record-${row.id}`, Object.fromEntries(fields.map((field) => [field.getName(), field.hydrateState(row[field.getName()] ?? field.getDefaultState())]))]));
|
|
634
|
+
}
|
|
635
|
+
dehydrateState() { return Object.fromEntries(Object.keys(this.getItems()).map((key) => [key, {}])); }
|
|
636
|
+
getChildSchemas() {
|
|
637
|
+
const container = this.container;
|
|
638
|
+
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()));
|
|
639
|
+
}
|
|
640
|
+
async getValidationRules() {
|
|
641
|
+
const rules = [];
|
|
642
|
+
if (await this.isRequired())
|
|
643
|
+
rules.push({ name: 'required', passes: () => Object.keys(this.getItems()).length > 0, message: (a) => `The ${a} field is required.` });
|
|
644
|
+
return [...rules, ...this._rules];
|
|
645
|
+
}
|
|
646
|
+
// --- actions du composant
|
|
647
|
+
getActions() {
|
|
648
|
+
const move = (offset) => ({ arguments: args }) => {
|
|
649
|
+
const items = this.getItems();
|
|
650
|
+
const keys = Object.keys(items);
|
|
651
|
+
const from = keys.indexOf(String(args.item));
|
|
652
|
+
const to = from + offset;
|
|
653
|
+
if (from < 0 || to < 0 || to >= keys.length)
|
|
654
|
+
return;
|
|
655
|
+
[keys[from], keys[to]] = [keys[to], keys[from]];
|
|
656
|
+
this.setItems(Object.fromEntries(keys.map((key) => [key, items[key]])));
|
|
657
|
+
};
|
|
658
|
+
return [
|
|
659
|
+
...this.getHintActions(),
|
|
660
|
+
Action.make('add').action(() => { const items = this.getItems(); if (Object.keys(items).length >= this._maxItems)
|
|
661
|
+
return; items[randomUUID()] = this.itemDefaults(); this.setItems(items); }),
|
|
662
|
+
Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!Object.hasOwn(items, key))
|
|
663
|
+
return; delete items[key]; this.setItems(items); }),
|
|
664
|
+
Action.make('moveUp').action(move(-1)),
|
|
665
|
+
Action.make('moveDown').action(move(1)),
|
|
666
|
+
// Glisser-déposer : le navigateur envoie le nouvel ordre, accepté seulement si c'est une permutation des clés existantes.
|
|
667
|
+
Action.make('reorder').action(({ arguments: args }) => {
|
|
668
|
+
const items = this.getItems();
|
|
669
|
+
const keys = Object.keys(items);
|
|
670
|
+
const order = Array.isArray(args.items) ? args.items.map(String) : [];
|
|
671
|
+
if (order.length !== keys.length || new Set(order).size !== keys.length || !order.every((key) => Object.hasOwn(items, key)))
|
|
672
|
+
return;
|
|
673
|
+
this.setItems(Object.fromEntries(order.map((key) => [key, items[key]])));
|
|
674
|
+
}),
|
|
675
|
+
];
|
|
676
|
+
}
|
|
677
|
+
async toHtml() {
|
|
678
|
+
if (!colorManager)
|
|
679
|
+
throw new Error('Aucun gestionnaire de couleurs : appelez useColorManager().');
|
|
680
|
+
const id = this.getId();
|
|
681
|
+
const key = this.getKey();
|
|
682
|
+
const livewireKey = this.getLivewireKey() ?? '';
|
|
683
|
+
const items = this.getItems();
|
|
684
|
+
const keys = Object.keys(items);
|
|
685
|
+
const schemas = this.getChildSchemas();
|
|
686
|
+
const tr = (name, replace = {}) => t(`filament-forms::components.repeater.${name}`, replace);
|
|
687
|
+
const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: key };
|
|
688
|
+
const expression = (action, args) => `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
|
|
689
|
+
const button = 'fi-ac-icon-btn-action fi-icon-btn';
|
|
690
|
+
const loading = (target) => `${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': target }))}`;
|
|
691
|
+
const actionIcon = (name, target) => icon(name, { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': target }) });
|
|
692
|
+
const itemLabel = tr('reordering.item');
|
|
693
|
+
const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
|
|
694
|
+
const rows = [];
|
|
695
|
+
// Comme Filament : pas de colonne de réordonnancement tant qu'il n'y a qu'une ligne.
|
|
696
|
+
const hasReorderColumn = this._reorderable && keys.length > 1;
|
|
697
|
+
for (const [index, itemKey] of keys.entries()) {
|
|
698
|
+
const moveButton = (action, label, iconName, direction, disabled) => {
|
|
699
|
+
const expr = expression(action, { item: itemKey });
|
|
700
|
+
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>`;
|
|
701
|
+
};
|
|
702
|
+
const cells = [];
|
|
703
|
+
for (const field of schemas[index].getComponents())
|
|
704
|
+
cells.push(`<td>${await field.toSchemaHtml(await field.isVisible())}</td>`);
|
|
705
|
+
const links = this._itemLinks.map((resolve) => resolve({ item: items[itemKey], key: itemKey })).filter((link) => link !== null)
|
|
706
|
+
.map((link) => `<div x-on:click.stop><a href="${e(link.url)}" target="_blank" x-tooltip="{
|
|
707
|
+
content: ${e(js(link.label))},
|
|
708
|
+
theme: $store.theme,
|
|
709
|
+
allowHTML: false,
|
|
710
|
+
}" aria-label="${e(link.label)}" class="${button} fi-size-sm">${icon(link.icon)}</a></div>`);
|
|
711
|
+
const deleteExpr = expression('delete', { item: itemKey });
|
|
712
|
+
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>`);
|
|
713
|
+
}
|
|
714
|
+
const head = this._columns.map((column) => `<th scope="col"${column.getWidth() ? ` style="width: ${e(column.getWidth())};"` : ''}>${e(column.label)}</th>`).join('');
|
|
715
|
+
const addExpr = expression('add', null);
|
|
716
|
+
const label = await this.getLabel();
|
|
717
|
+
const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
|
|
718
|
+
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('');
|
|
719
|
+
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>`;
|
|
720
|
+
return this.wrap(table, { labelAsDiv: true });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** Zone de texte multiligne (port de Textarea, sans redimensionnement automatique). */
|
|
724
|
+
/** Heure (port de TimePicker, saisie native) : l'état est `HH:MM` avec `seconds(false)`, `HH:MM:SS` sinon. */
|
|
725
|
+
export class TimePicker extends Field {
|
|
726
|
+
_seconds = true;
|
|
727
|
+
static make(name) { return configured(new TimePicker(name)); }
|
|
728
|
+
seconds(condition = true) { this._seconds = condition; return this; }
|
|
729
|
+
hydrateState(value) { return typeof value === 'string' && value.length >= 5 ? value.slice(value.length > 8 ? 11 : 0).slice(0, this._seconds ? 8 : 5) : value; }
|
|
730
|
+
async toHtml() {
|
|
731
|
+
const isDisabled = await this.isDisabled();
|
|
732
|
+
const input = new Attributes()
|
|
733
|
+
.merge({
|
|
734
|
+
'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
|
|
735
|
+
required: (await this.isRequired()) && !isDisabled, step: this._seconds ? 1 : null, type: 'time', 'wire:model': this.getStatePath(),
|
|
736
|
+
})
|
|
737
|
+
.class(['fi-input']);
|
|
738
|
+
const wrapper = new Attributes()
|
|
739
|
+
.merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
|
|
740
|
+
.class(['fi-fo-date-time-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
741
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/** Date et heure (port de DateTimePicker, saisie native `datetime-local`) : l'état enregistré est `AAAA-MM-JJ HH:MM:SS`. */
|
|
745
|
+
export class DateTimePicker extends Field {
|
|
746
|
+
_seconds = true;
|
|
747
|
+
static make(name) { return configured(new DateTimePicker(name)); }
|
|
748
|
+
seconds(condition = true) { this._seconds = condition; return this; }
|
|
749
|
+
dehydrateState(state) {
|
|
750
|
+
if (typeof state !== 'string' || state === '')
|
|
751
|
+
return state === '' ? null : state;
|
|
752
|
+
const value = state.replace('T', ' ');
|
|
753
|
+
return value.length === 16 ? `${value}:00` : value;
|
|
754
|
+
}
|
|
755
|
+
async toHtml() {
|
|
756
|
+
const isDisabled = await this.isDisabled();
|
|
757
|
+
const input = new Attributes()
|
|
758
|
+
.merge({
|
|
759
|
+
'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(),
|
|
760
|
+
required: (await this.isRequired()) && !isDisabled, step: this._seconds ? 1 : null, type: 'datetime-local', 'wire:model': this.getStatePath(),
|
|
761
|
+
})
|
|
762
|
+
.class(['fi-input']);
|
|
763
|
+
const wrapper = new Attributes()
|
|
764
|
+
.merge({ 'x-on:focus-input.stop': "$el.querySelector('input:not([type=hidden])')?.focus()" })
|
|
765
|
+
.class(['fi-fo-date-time-picker', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
766
|
+
return this.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><input ${input.toHtml()} /></div></div>`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/** Choix unique en boutons radio (port de Radio). */
|
|
770
|
+
export class Radio extends Field {
|
|
771
|
+
_options = [];
|
|
772
|
+
_descriptions = {};
|
|
773
|
+
_inline = false;
|
|
774
|
+
_live = false;
|
|
775
|
+
static make(name) { return configured(new Radio(name)); }
|
|
776
|
+
options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
|
|
777
|
+
/** Texte d'aide par valeur, sous le libellé de l'option. */
|
|
778
|
+
descriptions(descriptions) { this._descriptions = descriptions; return this; }
|
|
779
|
+
/** Options côte à côte. */
|
|
780
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
781
|
+
live(condition = true) { this._live = condition; return this; }
|
|
782
|
+
async getValidationRules() {
|
|
783
|
+
return [...(await super.getValidationRules()), { name: 'in', passes: (v) => this._options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
|
|
784
|
+
}
|
|
785
|
+
async toHtml() {
|
|
786
|
+
const id = this.getId();
|
|
787
|
+
const isDisabled = await this.isDisabled();
|
|
788
|
+
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
789
|
+
const container = new Attributes()
|
|
790
|
+
.merge({ 'aria-describedby': this.hasError() ? `${id}-error` : null, 'aria-labelledby': `${id}-label`, 'aria-required': isRequired ? 'true' : null, role: 'radiogroup' })
|
|
791
|
+
.class(['fi-fo-radio', this._inline ? 'fi-inline' : null]);
|
|
792
|
+
const items = this._options.map(([value, label]) => {
|
|
793
|
+
const input = new Attributes()
|
|
794
|
+
.merge({ type: 'radio', disabled: isDisabled, id: `${id}-${value}`, name: id, required: isRequired, value, [this._live ? 'wire:model.live' : 'wire:model']: this.getStatePath() })
|
|
795
|
+
.class(['fi-radio-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
796
|
+
const description = this._descriptions[value] ? `<p class="fi-fo-radio-label-description">${e(this._descriptions[value])}</p>` : '';
|
|
797
|
+
return `<label class="fi-fo-radio-label"><input ${input.toHtml()} /><div class="fi-fo-radio-label-text"><p>${e(label)}</p>${description}</div></label>`;
|
|
798
|
+
});
|
|
799
|
+
return this.wrap(`<div ${container.toHtml()}>${items.join('')}</div>`, { labelAsDiv: true });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
export class Textarea extends Field {
|
|
803
|
+
_rows = null;
|
|
804
|
+
_maxLength = null;
|
|
805
|
+
static make(name) { return configured(new Textarea(name)); }
|
|
806
|
+
rows(rows) { this._rows = rows; return this; }
|
|
807
|
+
maxLength(length) { this._maxLength = length; return this.rule({ name: 'max', passes: (v) => String(v).length <= length, message: (a) => `The ${a} field must not be greater than ${length} characters.` }); }
|
|
808
|
+
async toHtml() {
|
|
809
|
+
const isDisabled = await this.isDisabled();
|
|
810
|
+
// Hauteur initiale : 1,5rem par ligne plus la marge intérieure (comme Filament).
|
|
811
|
+
const height = (this._rows ?? 2) * 1.5 + 0.75;
|
|
812
|
+
const textarea = new Attributes().merge({
|
|
813
|
+
'aria-invalid': this.hasError() ? 'true' : null, disabled: isDisabled, id: this.getId(), maxlength: this._maxLength,
|
|
814
|
+
required: (await this.isRequired()) && !isDisabled, rows: this._rows, 'wire:model': this.getStatePath(),
|
|
815
|
+
});
|
|
816
|
+
const wrapper = new Attributes().class(['fi-fo-textarea', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
817
|
+
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({
|
|
818
|
+
initialHeight: ${height ?? 'null'},
|
|
819
|
+
shouldAutosize: false,
|
|
820
|
+
state: $wire.$entangle(${e(js(this.getStatePath()))}),
|
|
821
|
+
})" x-model="state" ${textarea.toHtml()}></textarea></div></div></div>`, { wrapperClass: 'fi-fo-textarea-wrp' });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/** Saisie d'une liste de mots-clés (port de TagsInput) : l'état est un tableau de chaînes. */
|
|
825
|
+
export class TagsInput extends Field {
|
|
826
|
+
_placeholder = null;
|
|
827
|
+
_suggestions = [];
|
|
828
|
+
_splitKeys = [];
|
|
829
|
+
static make(name) { return configured(new TagsInput(name)); }
|
|
830
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
831
|
+
/** Valeurs proposées à la saisie. */
|
|
832
|
+
suggestions(suggestions) { this._suggestions = suggestions.map(String).slice(0, 1000); return this; }
|
|
833
|
+
/** Touches qui valident le mot en cours, en plus d'Entrée (`['Tab', ',']`). */
|
|
834
|
+
splitKeys(keys) { this._splitKeys = keys; return this; }
|
|
835
|
+
getDefaultState() { return this._default ?? []; }
|
|
836
|
+
hydrateState(value) {
|
|
837
|
+
if (typeof value === 'string') {
|
|
838
|
+
try {
|
|
839
|
+
value = JSON.parse(value);
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
value = value === '' ? [] : [value];
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return Array.isArray(value) ? value.filter((tag) => typeof tag === 'string' || typeof tag === 'number').map(String) : [];
|
|
846
|
+
}
|
|
847
|
+
/** Seules des chaînes non vides sont enregistrées, quelle que soit la forme reçue du navigateur. */
|
|
848
|
+
dehydrateState(state) { return (Array.isArray(state) ? state : []).filter((tag) => typeof tag === 'string' || typeof tag === 'number').map((tag) => String(tag).trim()).filter((tag) => tag !== '').slice(0, 1000); }
|
|
849
|
+
async toHtml() {
|
|
850
|
+
if (!colorManager)
|
|
851
|
+
throw new Error('Aucun gestionnaire de couleurs : appelez useColorManager().');
|
|
852
|
+
const id = this.getId();
|
|
853
|
+
const isDisabled = await this.isDisabled();
|
|
854
|
+
const label = await this.getLabel();
|
|
855
|
+
const wrapper = new Attributes()
|
|
856
|
+
.merge({ 'aria-label': label, role: 'group', 'x-on:focus-input.stop': "$el.querySelector('input')?.focus()" })
|
|
857
|
+
.class(['fi-fo-tags-input', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
858
|
+
const input = new Attributes()
|
|
859
|
+
.merge({ autocomplete: 'off', disabled: isDisabled, id, list: `${id}-suggestions`, placeholder: this._placeholder ?? t('filament-forms::components.tags_input.placeholder'), type: 'text', 'x-bind': 'input' })
|
|
860
|
+
.class(['fi-input']);
|
|
861
|
+
const badge = ['fi-badge', 'fi-size-md', ...colorManager.getComponentClasses('badge', 'primary')].join(' ');
|
|
862
|
+
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>`;
|
|
863
|
+
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({
|
|
864
|
+
state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
|
|
865
|
+
splitKeys: ${this._splitKeys.length ? e(js(this._splitKeys)) : '[]'},
|
|
866
|
+
tagAddedMessage: ${e(js(t('filament-forms::components.tags_input.tag_added')))},
|
|
867
|
+
tagRemovedMessage: ${e(js(t('filament-forms::components.tags_input.tag_removed')))},
|
|
868
|
+
})"><input ${input.toHtml()} /><div x-ref="liveRegion" aria-live="polite" class="fi-sr-only"></div><datalist id="${e(id)}-suggestions">${this._suggestions.map((s) => `<template x-bind:key="${e(js(s))}" x-if="! (state?.includes(${e(js(s))}) ?? true)"><option value="${e(s)}" /></template>`).join('')}</datalist><div wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.${createHash('md5').update(JSON.stringify(this.context().state ?? [])).digest('hex')}"><template x-cloak x-if="state?.length"><div class="fi-fo-tags-input-tags-ctn"><template x-for="(tag, index) in state" x-bind:key="\`\${tag}-\${index}\`"><span class="${badge}"><span class="fi-badge-label-ctn"><span class="fi-badge-label"><span x-text="tag"></span></span></span>${deleteButton}</span></template></div></template></div></div></div></div>`, { wrapperClass: 'fi-fo-tags-input-wrp' });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/** Interrupteur (port de Toggle). */
|
|
872
|
+
export class Toggle extends Field {
|
|
873
|
+
static make(name) { return configured(new Toggle(name)); }
|
|
874
|
+
getDefaultState() { return this._default ?? false; }
|
|
875
|
+
hydrateState(value) { return value === true || value === 1 || value === '1'; }
|
|
876
|
+
async toHtml() {
|
|
877
|
+
if (!colorManager)
|
|
878
|
+
throw new Error('Aucun gestionnaire de couleurs : appelez useColorManager().');
|
|
879
|
+
const on = this.context().state === true;
|
|
880
|
+
const colors = colorManager.getComponentClasses('toggle', 'primary').join(' ');
|
|
881
|
+
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']);
|
|
882
|
+
const knob = '<div><div aria-hidden="true"></div><div aria-hidden="true"></div></div>';
|
|
883
|
+
return this.wrap('', { labelPrefix: `<button x-data="{ state: $wire.$entangle(${e(js(this.getStatePath()))}, false) }" x-bind:aria-checked="state ? 'true' : 'false'" x-on:click="state = ! state" x-bind:class="state ? 'fi-toggle-on ${colors}' : 'fi-toggle-off'"${on ? ' x-cloak' : ''} role="switch" type="button" ${button.toHtml()}>${knob}</button>${on ? `<div x-cloak="inline-flex" wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.placeholder" class="fi-toggle fi-toggle-on fi-hidden ${colors}">${knob}</div>` : ''}` });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/** Choix unique présenté en boutons (port de ToggleButtons, sélection simple). */
|
|
887
|
+
export class ToggleButtons extends Field {
|
|
888
|
+
_options = [];
|
|
889
|
+
_inline = false;
|
|
890
|
+
_grouped = false;
|
|
891
|
+
_colors = {};
|
|
892
|
+
_icons = {};
|
|
893
|
+
static make(name) { return configured(new ToggleButtons(name)); }
|
|
894
|
+
_live = false;
|
|
895
|
+
/** L'état est envoyé au serveur dès qu'il change. */
|
|
896
|
+
live(condition = true) { this._live = condition; return this; }
|
|
897
|
+
/** Couleur par valeur (ex. celle d'une énumération). */
|
|
898
|
+
colors(colors) { this._colors = colors; return this; }
|
|
899
|
+
/** Icône par valeur. */
|
|
900
|
+
icons(icons) { this._icons = icons; return this; }
|
|
901
|
+
options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
|
|
902
|
+
/** Boutons côte à côte. */
|
|
903
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
904
|
+
/** Boutons accolés en un seul groupe. */
|
|
905
|
+
grouped(condition = true) { this._grouped = condition; return this; }
|
|
906
|
+
async getValidationRules() {
|
|
907
|
+
return [...(await super.getValidationRules()), { name: 'in', passes: (v) => this._options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
|
|
908
|
+
}
|
|
909
|
+
async toHtml() {
|
|
910
|
+
if (!colorManager)
|
|
911
|
+
throw new Error('Aucun gestionnaire de couleurs : appelez useColorManager().');
|
|
912
|
+
const id = this.getId();
|
|
913
|
+
const isDisabled = await this.isDisabled();
|
|
914
|
+
const isRequired = (await this.isRequired()) && !isDisabled;
|
|
915
|
+
const describedBy = [isRequired ? `${id}-required` : null, this.hasError() ? `${id}-error` : null].filter(Boolean).join(' ') || null;
|
|
916
|
+
const container = new Attributes()
|
|
917
|
+
.merge({ 'aria-describedby': describedBy, 'aria-labelledby': `${id}-label`, 'aria-required': isRequired ? 'true' : null, role: 'radiogroup', 'wire:key': `${this.getLivewireKey()}.inputs` })
|
|
918
|
+
.class(['fi-fo-toggle-buttons', this._grouped ? 'fi-btn-group' : null, !this._grouped && this._inline ? 'fi-inline' : null]);
|
|
919
|
+
const manager = colorManager;
|
|
920
|
+
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(' ');
|
|
921
|
+
const items = this._options.map(([value, label]) => {
|
|
922
|
+
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']);
|
|
923
|
+
const html = `<input ${input.toHtml()} /><label for="${e(`${id}-${value}`)}" class="${button(value)}">${this._icons[value] ? icon(this._icons[value]) : ''}${e(label)}</label>`;
|
|
924
|
+
return this._grouped ? html : `<div class="fi-fo-toggle-buttons-btn-ctn">${html}</div>`;
|
|
925
|
+
});
|
|
926
|
+
const after = isRequired ? `<span id="${e(id)}-required" class="fi-sr-only">${e(t('filament-forms::components.toggle_buttons.required_description'))}</span>` : '';
|
|
927
|
+
return this.wrap(`<div ${container.toHtml()}>${items.join('')}</div>`, { wrapperClass: 'fi-fo-toggle-buttons-wrp', labelAsDiv: true, focusable: true, after });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// Schéma TipTap du serveur : les nœuds et marques que l'éditeur de Filament sait produire avec sa barre d'outils par défaut.
|
|
931
|
+
// Hors périmètre pour l'instant : grilles, blocs personnalisés, mentions, balises de fusion, couleur de texte, « lead » et « small ».
|
|
932
|
+
const RICH_EXTENSIONS = [StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'start' }), Subscript, Superscript, Highlight, Image, TableKit];
|
|
933
|
+
const isRichDocument = (value) => !!value && typeof value === 'object' && value.type === 'doc';
|
|
934
|
+
const richText = (node) => (typeof node?.text === 'string' ? node.text : '') + (Array.isArray(node?.content) ? node.content.map(richText).join('') : '');
|
|
935
|
+
const hasRichContent = (node) => node?.type === 'image' || node?.type === 'horizontalRule' || (Array.isArray(node?.content) && node.content.some(hasRichContent));
|
|
936
|
+
/** HTML enregistré → document TipTap. L'analyse écarte ce que le schéma ne connaît pas (scripts, attributs d'événement, liens `javascript:`). */
|
|
937
|
+
export function richHtmlToDocument(html) { return generateJSON(html ?? '', RICH_EXTENSIONS); }
|
|
938
|
+
/**
|
|
939
|
+
* Document TipTap → HTML à enregistrer, ou null s'il est vide ou invalide. Le document vient du navigateur :
|
|
940
|
+
* il est rendu puis ré-analysé, pour que seul ce que le schéma accepte soit conservé.
|
|
941
|
+
*/
|
|
942
|
+
export function richDocumentToHtml(document) {
|
|
943
|
+
if (!isRichDocument(document))
|
|
944
|
+
return null;
|
|
945
|
+
try {
|
|
946
|
+
const clean = generateJSON(generateHTML(document, RICH_EXTENSIONS), RICH_EXTENSIONS);
|
|
947
|
+
return richText(clean).trim() === '' && !hasRichContent(clean) ? null : generateHTML(clean, RICH_EXTENSIONS);
|
|
948
|
+
}
|
|
949
|
+
catch {
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Éditeur de texte riche (port de RichEditor) : le navigateur travaille sur un document TipTap, la base reçoit du HTML.
|
|
955
|
+
* Le balisage est celui de Filament, repris de l'oracle (vue générée). Non portés : pièces jointes, blocs personnalisés, mentions.
|
|
956
|
+
*/
|
|
957
|
+
export class RichEditor extends Field {
|
|
958
|
+
static make(name) { return configured(new RichEditor(name)); }
|
|
959
|
+
hydrateState(value) { return isRichDocument(value) ? value : richHtmlToDocument(typeof value === 'string' ? value : ''); }
|
|
960
|
+
getDefaultState() { return richHtmlToDocument(typeof this._default === 'string' ? this._default : ''); }
|
|
961
|
+
dehydrateState(state) { return richDocumentToHtml(state); }
|
|
962
|
+
async getValidationRules() {
|
|
963
|
+
const rules = [];
|
|
964
|
+
if (await this.isRequired())
|
|
965
|
+
rules.push({ name: 'required', passes: (value) => richDocumentToHtml(value) !== null, message: (a) => `The ${a} field is required.` });
|
|
966
|
+
return [...rules, ...this._rules];
|
|
967
|
+
}
|
|
968
|
+
getExposedMethods() { return ['getMentionSearchResultsForJs', 'getMentionLabelsForJs', 'getCustomBlockPreviewsForJs', 'getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
|
|
969
|
+
getMentionSearchResultsForJs() { return []; }
|
|
970
|
+
getMentionLabelsForJs() { return []; }
|
|
971
|
+
getCustomBlockPreviewsForJs() { return []; }
|
|
972
|
+
getUploadedFileAttachmentTemporaryUrl() { return null; }
|
|
973
|
+
saveUploadedFileAttachmentAndGetUrl() { return null; }
|
|
974
|
+
/** Action « lien » de la barre d'outils : une modale (URL, nouvel onglet), puis des commandes renvoyées à l'éditeur du navigateur. */
|
|
975
|
+
getActions() {
|
|
976
|
+
const tr = (name) => t(`filament-forms::components.rich_editor.actions.link.${name}`);
|
|
977
|
+
const key = this.getKey();
|
|
978
|
+
return [
|
|
979
|
+
...this.getHintActions(),
|
|
980
|
+
Action.make('link').label(tr('label')).modalHeading(tr('modal.heading')).modalWidth('lg')
|
|
981
|
+
.fillForm(({ arguments: args }) => ({ url: typeof args.url === 'string' ? args.url : null, shouldOpenInNewTab: args.shouldOpenInNewTab === true }))
|
|
982
|
+
.schema(() => [
|
|
983
|
+
TextInput.make('url').label(tr('modal.form.url.label'))
|
|
984
|
+
// Seules des adresses sans code exécutable sont acceptées.
|
|
985
|
+
.rule({ name: 'url', passes: (value) => /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(String(value)), message: (a) => `The ${a} field must be a valid URL.` }),
|
|
986
|
+
Checkbox.make('shouldOpenInNewTab').label(tr('modal.form.should_open_in_new_tab.label')),
|
|
987
|
+
])
|
|
988
|
+
.action(({ livewire, arguments: args, data }) => {
|
|
989
|
+
const selection = (args.editorSelection ?? null);
|
|
990
|
+
const single = selection?.head === selection?.anchor;
|
|
991
|
+
const url = typeof data.url === 'string' && data.url !== '' ? data.url : null;
|
|
992
|
+
const commands = [
|
|
993
|
+
...(single ? [{ name: 'extendMarkRange', arguments: ['link'] }] : []),
|
|
994
|
+
url ? { name: 'setLink', arguments: [{ href: url, target: data.shouldOpenInNewTab ? '_blank' : null }] } : { name: 'unsetLink', arguments: [] },
|
|
995
|
+
];
|
|
996
|
+
livewire.dispatch('run-rich-editor-commands', { awaitSchemaComponent: key, livewireId: livewire.id, key, editorSelection: selection, commands });
|
|
997
|
+
}),
|
|
998
|
+
];
|
|
999
|
+
}
|
|
1000
|
+
async toHtml() {
|
|
1001
|
+
return this.wrap(views.render('filament-forms::components.rich-editor', { livewireId: this.livewire.id, key: this.getKey(), statePath: this.getStatePath(), label: await this.getLabel(), js }), { labelAsDiv: true });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/** Sélecteur de couleur (port de ColorPicker, format hexadécimal). */
|
|
1005
|
+
export class ColorPicker extends Field {
|
|
1006
|
+
static make(name) { return configured(new ColorPicker(name)); }
|
|
1007
|
+
/** Format hexadécimal (le seul pris en charge ; présent pour la compatibilité d'API). */
|
|
1008
|
+
hex() { return this; }
|
|
1009
|
+
constructor(name) {
|
|
1010
|
+
super(name);
|
|
1011
|
+
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.` });
|
|
1012
|
+
}
|
|
1013
|
+
async toHtml() {
|
|
1014
|
+
const isDisabled = await this.isDisabled();
|
|
1015
|
+
const state = this.context().state;
|
|
1016
|
+
const id = this.getId();
|
|
1017
|
+
const key = this.getLivewireKey() ?? '';
|
|
1018
|
+
// Même empreinte que Filament : md5(serialize([$isDisabled])).
|
|
1019
|
+
const hash = createHash('md5').update(`a:1:{i:0;b:${isDisabled ? 1 : 0};}`).digest('hex');
|
|
1020
|
+
const input = new Attributes().merge({
|
|
1021
|
+
'aria-controls': `${id}-panel`, 'aria-expanded': 'false', 'aria-haspopup': 'dialog', 'aria-invalid': this.hasError() ? 'true' : null, autocomplete: 'off',
|
|
1022
|
+
disabled: isDisabled, id, required: (await this.isRequired()) && !isDisabled, role: 'combobox', type: 'text', 'x-model': 'state',
|
|
1023
|
+
}).class(['fi-input']);
|
|
1024
|
+
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() }]);
|
|
1025
|
+
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({
|
|
1026
|
+
isAutofocused: false,
|
|
1027
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1028
|
+
isLive: false,
|
|
1029
|
+
isLiveDebounced: false,
|
|
1030
|
+
isLiveOnBlur: false,
|
|
1031
|
+
liveDebounce: null,
|
|
1032
|
+
state: $wire.$entangle(${e(js(this.getStatePath()))}),
|
|
1033
|
+
})" 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="{
|
|
1034
|
+
'fi-empty': ! state,
|
|
1035
|
+
}" 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>`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
export class Checkbox extends Field {
|
|
1039
|
+
static make(name) { return configured(new Checkbox(name)); }
|
|
1040
|
+
getDefaultState() { return this._default ?? false; }
|
|
1041
|
+
async toHtml() {
|
|
1042
|
+
const input = new Attributes()
|
|
1043
|
+
.merge({ type: 'checkbox', disabled: await this.isDisabled(), id: this.getId(), required: await this.isRequired(), 'wire:model': this.getStatePath() })
|
|
1044
|
+
.class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
1045
|
+
return this.wrap('', { labelPrefix: `<input ${input.toHtml()} />` });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/** Plusieurs cases à cocher pour une liste de valeurs (port de CheckboxList, sans recherche ni « tout cocher »). */
|
|
1049
|
+
export class CheckboxList extends Field {
|
|
1050
|
+
_options = [];
|
|
1051
|
+
_optionColumns = null;
|
|
1052
|
+
static make(name) { return configured(new CheckboxList(name)); }
|
|
1053
|
+
options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
|
|
1054
|
+
/** Nombre de colonnes sur lesquelles les options se répartissent (à partir de `lg`). */
|
|
1055
|
+
columns(columns) { this._optionColumns = columns; return this; }
|
|
1056
|
+
getDefaultState() { return this._default ?? []; }
|
|
1057
|
+
hydrateState(value) {
|
|
1058
|
+
let list = value;
|
|
1059
|
+
if (typeof value === 'string') {
|
|
1060
|
+
try {
|
|
1061
|
+
list = JSON.parse(value);
|
|
1062
|
+
}
|
|
1063
|
+
catch {
|
|
1064
|
+
list = [];
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return (Array.isArray(list) ? list : []).map(String);
|
|
1068
|
+
}
|
|
1069
|
+
/** Seules les valeurs proposées sont retenues, sans doublon, dans l'ordre des options. */
|
|
1070
|
+
dehydrateState(state) {
|
|
1071
|
+
const chosen = new Set((Array.isArray(state) ? state : []).map(String));
|
|
1072
|
+
return this._options.map(([value]) => value).filter((value) => chosen.has(value));
|
|
1073
|
+
}
|
|
1074
|
+
async getValidationRules() {
|
|
1075
|
+
const known = new Set(this._options.map(([value]) => value));
|
|
1076
|
+
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.` }];
|
|
1077
|
+
}
|
|
1078
|
+
async toHtml() {
|
|
1079
|
+
const isDisabled = await this.isDisabled();
|
|
1080
|
+
const key = this.getLivewireKey() ?? '';
|
|
1081
|
+
const options = this._options.map(([value, label]) => {
|
|
1082
|
+
const input = new Attributes().merge({ disabled: isDisabled, type: 'checkbox', value, 'wire:model': this.getStatePath() }).class(['fi-checkbox-input', this.hasError() ? 'fi-invalid' : 'fi-valid']);
|
|
1083
|
+
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>`;
|
|
1084
|
+
});
|
|
1085
|
+
const columns = this._optionColumns ?? 1;
|
|
1086
|
+
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;"';
|
|
1087
|
+
return this.wrap(`<div role="group" x-load x-load-src="${e(asset('checkbox-list'))}" x-data="checkboxListFormComponent({
|
|
1088
|
+
livewireId: ${e(js(this.livewire.id))},
|
|
1089
|
+
})" aria-labelledby="${e(this.getId())}-label" class="fi-fo-checkbox-list"><div wire:key="${e(key)}.options" ${grid}>${options.join('')}</div></div>`, { labelAsDiv: true });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** Paires clé / valeur libres, enregistrées comme un objet (port de KeyValue). */
|
|
1093
|
+
export class KeyValue extends Field {
|
|
1094
|
+
_keyLabel = null;
|
|
1095
|
+
_valueLabel = null;
|
|
1096
|
+
_reorderable = false;
|
|
1097
|
+
static make(name) { return configured(new KeyValue(name)); }
|
|
1098
|
+
keyLabel(label) { this._keyLabel = label; return this; }
|
|
1099
|
+
valueLabel(label) { this._valueLabel = label; return this; }
|
|
1100
|
+
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
1101
|
+
// Dans le formulaire, l'état est une liste de paires `{ key, value }` (ce qu'attend le composant du navigateur, et qui garde l'ordre) ;
|
|
1102
|
+
// à l'enregistrement il redevient un objet.
|
|
1103
|
+
getDefaultState() { return KeyValue.toPairs(this._default); }
|
|
1104
|
+
static toPairs(value) {
|
|
1105
|
+
let object = value;
|
|
1106
|
+
if (typeof value === 'string') {
|
|
1107
|
+
try {
|
|
1108
|
+
object = JSON.parse(value);
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
object = {};
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return object && typeof object === 'object' && !Array.isArray(object) ? Object.entries(object).map(([key, v]) => ({ key, value: v })) : [];
|
|
1115
|
+
}
|
|
1116
|
+
hydrateState(value) { return KeyValue.toPairs(value); }
|
|
1117
|
+
/** Objet plat de chaînes : clés vides, objets imbriqués et clés dangereuses sont écartés ; 200 paires au plus. */
|
|
1118
|
+
dehydrateState(state) {
|
|
1119
|
+
const out = {};
|
|
1120
|
+
if (!Array.isArray(state))
|
|
1121
|
+
return out;
|
|
1122
|
+
for (const pair of state.slice(0, 200)) {
|
|
1123
|
+
if (!pair || typeof pair !== 'object' || typeof pair.key !== 'string')
|
|
1124
|
+
continue;
|
|
1125
|
+
const value = pair.value;
|
|
1126
|
+
const name = pair.key.trim().slice(0, 255);
|
|
1127
|
+
if (!name || name === '__proto__' || name === 'constructor' || name === 'prototype')
|
|
1128
|
+
continue;
|
|
1129
|
+
out[name] = value === null || value === undefined || typeof value === 'object' ? null : String(value).slice(0, 5000);
|
|
1130
|
+
}
|
|
1131
|
+
return out;
|
|
1132
|
+
}
|
|
1133
|
+
async toHtml() {
|
|
1134
|
+
const id = this.getId();
|
|
1135
|
+
const tr = (key) => t(`filament-forms::components.key_value.${key}`);
|
|
1136
|
+
const keyLabel = this._keyLabel ?? tr('fields.key.label'), valueLabel = this._valueLabel ?? tr('fields.value.label');
|
|
1137
|
+
const reordering = { row: tr('reordering.row'), position: tr('reordering.position'), moved: tr('reordering.moved') };
|
|
1138
|
+
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([keyLabel, valueLabel, this._reorderable])).digest('hex')}`;
|
|
1139
|
+
const iconButton = 'type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled"';
|
|
1140
|
+
const dangerClasses = ['fi-ac-icon-btn-action', 'fi-icon-btn', 'fi-size-sm', ...(colorManager?.getComponentClasses('icon-button', 'danger') ?? [])].join(' ');
|
|
1141
|
+
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>` : '';
|
|
1142
|
+
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({
|
|
1143
|
+
state: $wire.$entangle(${e(js(this.getStatePath()))}, false),
|
|
1144
|
+
reordering: ${e(js(reordering))},
|
|
1145
|
+
})" wire:ignore wire:key="${e(key)}" class="fi-fo-key-value-table-ctn"><table aria-labelledby="${e(id)}-label" id="${e(id)}" class="fi-fo-key-value-table"><thead><tr>${this._reorderable ? `<th scope="col" x-show="rows.length" class="fi-has-action"><span class="fi-sr-only">${e(tr('columns.reorder.label'))}</span></th>` : ''}<th scope="col">${e(keyLabel)}</th><th scope="col">${e(valueLabel)}</th><th scope="col" x-show="rows.length" class="fi-has-action"><span class="fi-sr-only">${e(tr('columns.actions.label'))}</span></th></tr></thead><tbody${this._reorderable ? ' x-on:end.stop="reorderRows($event)" x-sortable data-sortable-animation-duration="300"' : ''}><template x-bind:key="row.id" x-for="(row, index) in rows"><tr${this._reorderable ? ' x-bind:x-sortable-item="row.id"' : ''}>${reorderCell}<td><input aria-label="${e(keyLabel)}" placeholder="" type="text" x-model="row.key" x-on:input.debounce.500ms="updateState" class="fi-input" /></td><td><input aria-label="${e(valueLabel)}" placeholder="" type="text" x-model="row.value" x-on:input.debounce.500ms="updateState" class="fi-input" /></td><td class="fi-has-action"><div x-on:click="deleteRow(index)"><button title="${e(tr('actions.delete.label'))}" aria-label="${e(tr('actions.delete.label'))}" ${iconButton} class="${dangerClasses}">${icon('m-trash')}</button></div></td></tr></template></tbody></table><span x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span><div x-on:click="addRow" class="fi-fo-key-value-add-action-ctn"><button ${iconButton} class="fi-ac-link-action fi-link fi-size-md"><span class="fi-link-label">${e(tr('actions.add.label'))}</span></button></div></div></div></div>`, { labelAsDiv: true, wrapperClass: 'fi-fo-key-value-wrp' });
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/** Expression JavaScript écrite par le développeur (formateur d'infobulle d'un Slider…), insérée telle quelle : jamais de donnée utilisateur. */
|
|
1149
|
+
export class RawJs {
|
|
1150
|
+
js;
|
|
1151
|
+
constructor(js) { this.js = js; }
|
|
1152
|
+
static make(js) { return new RawJs(js); }
|
|
1153
|
+
toString() { return this.js; }
|
|
1154
|
+
}
|
|
1155
|
+
/** Longueur CSS simple (`10rem`, `300px`, `50%`) ; toute autre valeur est refusée (elle finirait dans un attribut `style`). */
|
|
1156
|
+
const cssLength = (value) => (value !== null && /^\d+(\.\d+)?(px|rem|em|%|vh|vw)$/.test(value.trim()) ? value.trim() : null);
|
|
1157
|
+
/** Attributs d'accessibilité d'un champ (port de Field::getAccessibilityAttributes) : texte d'aide et message d'erreur. */
|
|
1158
|
+
function accessibilityOf(field, options = {}) {
|
|
1159
|
+
const ids = [field.getHelperTextId(), field.hasError() ? `${field.getId()}-error` : null].filter(Boolean);
|
|
1160
|
+
return { 'aria-describedby': ids.length ? ids.join(' ') : null, 'aria-invalid': options.withInvalid !== false && field.hasError() ? 'true' : null };
|
|
1161
|
+
}
|
|
1162
|
+
/** Second argument de `$entangle()` : vrai seulement pour un champ `live()` simple (ni `onBlur`, ni `debounce`), comme Filament. */
|
|
1163
|
+
const entangleLive = (live) => (live !== null && !live.onBlur && live.debounce === null ? 'true' : 'false');
|
|
1164
|
+
const MARKDOWN_TOOLBAR = [['bold', 'italic', 'strike', 'link'], ['heading'], ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], ['table'], ['undo', 'redo']];
|
|
1165
|
+
/**
|
|
1166
|
+
* Éditeur Markdown (port de MarkdownEditor, éditeur EasyMDE de Filament). L'état est le texte Markdown brut, à nettoyer avant tout
|
|
1167
|
+
* affichage en HTML. Non portés : pièces jointes (le bouton `attachFiles` est absent par défaut) ; en lecture seule (`disabled()`),
|
|
1168
|
+
* le texte est affiché échappé, sans conversion Markdown.
|
|
1169
|
+
*/
|
|
1170
|
+
export class MarkdownEditor extends Field {
|
|
1171
|
+
_toolbarButtons = MARKDOWN_TOOLBAR;
|
|
1172
|
+
_minHeight = '10rem';
|
|
1173
|
+
_maxHeight = null;
|
|
1174
|
+
_placeholder = null;
|
|
1175
|
+
_live = null;
|
|
1176
|
+
static make(name) { return configured(new MarkdownEditor(name)); }
|
|
1177
|
+
/** Boutons de la barre d'outils, en groupes (`[['bold', 'italic'], ['undo', 'redo']]`) ou à plat. */
|
|
1178
|
+
toolbarButtons(buttons) { this._toolbarButtons = buttons; return this; }
|
|
1179
|
+
/** Retire des boutons ; sans argument, les retire tous. */
|
|
1180
|
+
disableToolbarButtons(names = []) {
|
|
1181
|
+
this._toolbarButtons = names.length ? this._toolbarButtons.map((group) => (Array.isArray(group) ? group.filter((name) => !names.includes(name)) : group)).filter((group) => (Array.isArray(group) ? group.length > 0 : !names.includes(group))) : [];
|
|
1182
|
+
return this;
|
|
1183
|
+
}
|
|
1184
|
+
disableAllToolbarButtons(condition = true) { if (condition)
|
|
1185
|
+
this._toolbarButtons = []; return this; }
|
|
1186
|
+
/** Ajoute des boutons (dans un groupe à part). */
|
|
1187
|
+
enableToolbarButtons(names) { this._toolbarButtons = [...this._toolbarButtons, names]; return this; }
|
|
1188
|
+
getToolbarButtons() { return this._toolbarButtons; }
|
|
1189
|
+
hasToolbarButton(name) { return this._toolbarButtons.some((group) => (Array.isArray(group) ? group.includes(name) : group === name)); }
|
|
1190
|
+
/** Hauteur minimale de la zone d'édition (`10rem` par défaut). */
|
|
1191
|
+
minHeight(height) { this._minHeight = cssLength(height); return this; }
|
|
1192
|
+
maxHeight(height) { this._maxHeight = cssLength(height); return this; }
|
|
1193
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
1194
|
+
/** L'état est envoyé au serveur au fil de la frappe (`debounce` en millisecondes), ou à la perte de focus avec `onBlur`. */
|
|
1195
|
+
live(options = {}) { this._live = { onBlur: options.onBlur === true, debounce: options.debounce ?? null }; return this; }
|
|
1196
|
+
maxLength(length) { return this.rule({ name: 'max', passes: (v) => String(v).length <= length, message: (a) => `The ${a} field must not be greater than ${length} characters.` }); }
|
|
1197
|
+
minLength(length) { return this.rule({ name: 'min', passes: (v) => String(v).length >= length, message: (a) => `The ${a} field must be at least ${length} characters.` }); }
|
|
1198
|
+
hydrateState(value) { return typeof value === 'string' ? value : null; }
|
|
1199
|
+
async getValidationRules() {
|
|
1200
|
+
return [...(await super.getValidationRules()), { name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }];
|
|
1201
|
+
}
|
|
1202
|
+
getExposedMethods() { return ['getUploadedFileAttachmentTemporaryUrl', 'saveUploadedFileAttachmentAndGetUrl']; }
|
|
1203
|
+
getUploadedFileAttachmentTemporaryUrl() { return null; }
|
|
1204
|
+
saveUploadedFileAttachmentAndGetUrl() { return null; }
|
|
1205
|
+
async toHtml() {
|
|
1206
|
+
const id = this.getId();
|
|
1207
|
+
const accessibility = accessibilityOf(this);
|
|
1208
|
+
if (await this.isDisabled()) {
|
|
1209
|
+
const state = this.context().state;
|
|
1210
|
+
const attributes = new Attributes().merge({ 'aria-labelledby': `${id}-label`, 'aria-describedby': accessibility['aria-describedby'], id, role: 'group', tabindex: this._maxHeight ? '0' : null })
|
|
1211
|
+
.style([this._minHeight ? `--min-height: ${this._minHeight}` : null, this._maxHeight ? `--max-height: ${this._maxHeight}` : null])
|
|
1212
|
+
.class(['fi-fo-markdown-editor', 'fi-disabled', 'fi-prose']);
|
|
1213
|
+
const content = typeof state === 'string' && state.trim() !== ''
|
|
1214
|
+
? state.split(/\n{2,}/).map((paragraph) => `<p>${e(paragraph.trim()).replace(/\n/g, '<br />')}</p>`).join('')
|
|
1215
|
+
: `<span class="fi-fo-markdown-editor-placeholder">${e(this._placeholder ?? '')}</span>`;
|
|
1216
|
+
return this.wrap(`<div ${attributes.toHtml()}>${content}</div>`, { labelAsDiv: true });
|
|
1217
|
+
}
|
|
1218
|
+
const tr = (key) => t(`filament-forms::components.markdown_editor.${key}`);
|
|
1219
|
+
const translations = {
|
|
1220
|
+
file_attachments_accepted_file_types_message: tr('file_attachments_accepted_file_types_message'),
|
|
1221
|
+
file_attachments_max_size_message: tr('file_attachments_max_size_message'),
|
|
1222
|
+
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}`)])),
|
|
1223
|
+
};
|
|
1224
|
+
const config = `markdownEditorFormComponent({
|
|
1225
|
+
canAttachFiles: false,
|
|
1226
|
+
isLiveDebounced: ${this._live?.debounce !== null && this._live?.debounce !== undefined ? 'true' : 'false'},
|
|
1227
|
+
isLiveOnBlur: ${this._live?.onBlur ? 'true' : 'false'},
|
|
1228
|
+
label: ${js(await this.getLabel())},
|
|
1229
|
+
liveDebounce: ${js(this._live?.debounce ?? null)},
|
|
1230
|
+
maxHeight: ${js(this._maxHeight)},
|
|
1231
|
+
minHeight: ${js(this._minHeight)},
|
|
1232
|
+
placeholder: ${js(this._placeholder)},
|
|
1233
|
+
state: $wire.$entangle(${js(this.getStatePath())}, ${entangleLive(this._live)}),
|
|
1234
|
+
toolbarButtons: ${js(this._toolbarButtons)},
|
|
1235
|
+
translations: ${js(translations)},
|
|
1236
|
+
uploadFileAttachmentUsing: async (file, onSuccess, onError) => {
|
|
1237
|
+
return onError()
|
|
1238
|
+
},
|
|
1239
|
+
})`;
|
|
1240
|
+
const wrapper = new Attributes().class(['fi-fo-markdown-editor', 'fi-input-wrp', { 'fi-invalid': this.hasError() }]);
|
|
1241
|
+
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 });
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const CODE_LANGUAGES = ['cpp', 'css', 'go', 'html', 'java', 'javascript', 'json', 'markdown', 'php', 'python', 'sql', 'xml', 'yaml'];
|
|
1245
|
+
/** Éditeur de code (port de CodeEditor, CodeMirror de Filament) : coloration selon `language()`, retour à la ligne avec `wrap()`. */
|
|
1246
|
+
export class CodeEditor extends Field {
|
|
1247
|
+
_language = null;
|
|
1248
|
+
_canWrap = false;
|
|
1249
|
+
_live = null;
|
|
1250
|
+
static make(name) { return configured(new CodeEditor(name)); }
|
|
1251
|
+
/** Langage : `cpp`, `css`, `go`, `html`, `java`, `javascript`, `json`, `markdown`, `php`, `python`, `sql`, `xml`, `yaml`. */
|
|
1252
|
+
language(language) {
|
|
1253
|
+
if (language !== null && !CODE_LANGUAGES.includes(language))
|
|
1254
|
+
throw new Error(`CodeEditor : langage inconnu « ${language} ».`);
|
|
1255
|
+
this._language = language;
|
|
1256
|
+
return this;
|
|
1257
|
+
}
|
|
1258
|
+
getLanguage() { return this._language; }
|
|
1259
|
+
/**
|
|
1260
|
+
* Retour à la ligne des lignes longues (port de `wrap()`). Appelée avec du HTML, c'est l'enveloppe de champ héritée,
|
|
1261
|
+
* que le rendu du composant utilise.
|
|
1262
|
+
*/
|
|
1263
|
+
wrap(condition, options) {
|
|
1264
|
+
if (typeof condition === 'string')
|
|
1265
|
+
return super.wrap(condition, options);
|
|
1266
|
+
this._canWrap = condition ?? true;
|
|
1267
|
+
return this;
|
|
1268
|
+
}
|
|
1269
|
+
canWrap() { return this._canWrap; }
|
|
1270
|
+
live(options = {}) { this._live = { onBlur: options.onBlur === true, debounce: options.debounce ?? null }; return this; }
|
|
1271
|
+
hydrateState(value) { return typeof value === 'string' ? value : value === null || value === undefined ? null : JSON.stringify(value); }
|
|
1272
|
+
async getValidationRules() {
|
|
1273
|
+
return [...(await super.getValidationRules()), { name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }];
|
|
1274
|
+
}
|
|
1275
|
+
async toHtml() {
|
|
1276
|
+
const id = this.getId();
|
|
1277
|
+
const isDisabled = await this.isDisabled();
|
|
1278
|
+
const config = `codeEditorFormComponent({
|
|
1279
|
+
canWrap: ${this._canWrap ? 'true' : 'false'},
|
|
1280
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1281
|
+
isLive: ${this._live !== null ? 'true' : 'false'},
|
|
1282
|
+
isLiveDebounced: ${this._live?.debounce !== null && this._live?.debounce !== undefined ? 'true' : 'false'},
|
|
1283
|
+
isLiveOnBlur: ${this._live?.onBlur ? 'true' : 'false'},
|
|
1284
|
+
label: ${js(await this.getLabel())},
|
|
1285
|
+
liveDebounce: ${js(this._live?.debounce ?? null)},
|
|
1286
|
+
language: ${js(this._language)},
|
|
1287
|
+
state: $wire.$entangle(${js(this.getStatePath())}, ${entangleLive(this._live)}),
|
|
1288
|
+
})`;
|
|
1289
|
+
// Même empreinte que Filament : md5(serialize([$isDisabled, $language])).
|
|
1290
|
+
const hash = createHash('md5').update(`a:2:{i:0;b:${isDisabled ? 1 : 0};i:1;${this._language === null ? 'N;' : `s:${this._language.length}:"${this._language}";`}}`).digest('hex');
|
|
1291
|
+
const wrapper = new Attributes().class(['fi-fo-code-editor', 'fi-input-wrp', { 'fi-disabled': isDisabled, 'fi-invalid': this.hasError() }]);
|
|
1292
|
+
return super.wrap(`<div ${wrapper.toHtml()}><div class="fi-input-wrp-content-ctn"><div hidden data-field-accessibility ${new Attributes().merge(accessibilityOf(this)).toHtml()}></div><div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" x-load x-load-src="${e(asset('code-editor'))}" x-data="${e(config)}" wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.${hash}"><div x-ref="editor" x-cloak></div></div></div></div>`, { labelAsDiv: true });
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Curseur (port de Slider, noUiSlider de Filament) : une valeur, ou plusieurs poignées quand l'état est une liste (`default([20, 70])`).
|
|
1297
|
+
* Obligatoire par défaut, comme dans Filament ; les bornes, le pas et le type numérique sont vérifiés côté serveur.
|
|
1298
|
+
*/
|
|
1299
|
+
export class Slider extends Field {
|
|
1300
|
+
_minValue = 0;
|
|
1301
|
+
_maxValue = 100;
|
|
1302
|
+
_step = null;
|
|
1303
|
+
_minDifference = null;
|
|
1304
|
+
_maxDifference = null;
|
|
1305
|
+
_rangePadding = null;
|
|
1306
|
+
_fillTrack = null;
|
|
1307
|
+
_vertical = false;
|
|
1308
|
+
_rtl = null;
|
|
1309
|
+
_behavior = 'tap';
|
|
1310
|
+
_tooltips = false;
|
|
1311
|
+
_pipsMode = null;
|
|
1312
|
+
_pipsDensity = null;
|
|
1313
|
+
_pipsFormatter = null;
|
|
1314
|
+
_pipsValues = null;
|
|
1315
|
+
_steppedPips = false;
|
|
1316
|
+
_pipsFilter = null;
|
|
1317
|
+
_nonLinearPoints = null;
|
|
1318
|
+
_decimalPlaces = null;
|
|
1319
|
+
_live = null;
|
|
1320
|
+
constructor(name) { super(name); this._required = true; }
|
|
1321
|
+
static make(name) { return configured(new Slider(name)); }
|
|
1322
|
+
range(minValue, maxValue) { this._minValue = minValue; this._maxValue = maxValue; return this; }
|
|
1323
|
+
minValue(value) { this._minValue = value; return this; }
|
|
1324
|
+
maxValue(value) { this._maxValue = value; return this; }
|
|
1325
|
+
/** Pas entre deux valeurs ; `1` impose un entier, sinon la valeur doit être un multiple du pas. */
|
|
1326
|
+
step(step) { this._step = step; return this; }
|
|
1327
|
+
/** Écart minimal / maximal entre deux poignées. */
|
|
1328
|
+
minDifference(difference) { this._minDifference = difference; return this; }
|
|
1329
|
+
maxDifference(difference) { this._maxDifference = difference; return this; }
|
|
1330
|
+
/** Marge interdite aux extrémités : un nombre, ou `[début, fin]`. */
|
|
1331
|
+
rangePadding(padding) { this._rangePadding = padding; return this; }
|
|
1332
|
+
/** Segments colorés de la piste, un booléen par segment (`[true, false]` par défaut). */
|
|
1333
|
+
fillTrack(fill = [true, false]) { this._fillTrack = fill; return this; }
|
|
1334
|
+
vertical(condition = true) { this._vertical = condition; return this; }
|
|
1335
|
+
/** De droite à gauche (de haut en bas pour un curseur vertical, ce qui est le réglage par défaut en vertical). */
|
|
1336
|
+
rtl(condition = true) { this._rtl = condition; return this; }
|
|
1337
|
+
/** Comportement de la poignée (`tap`, `drag`, `fixed`, `snap`, `unconstrained`…) ; `null` : aucun. */
|
|
1338
|
+
behavior(behavior = null) { this._behavior = behavior; return this; }
|
|
1339
|
+
/** Infobulles sur les poignées : booléen, un booléen par poignée, ou `RawJs` qui formate `$value`. */
|
|
1340
|
+
tooltips(tooltips = true) { this._tooltips = tooltips; return this; }
|
|
1341
|
+
/** Graduations : `range` (par défaut), `steps`, `positions`, `count`, `values`. */
|
|
1342
|
+
pips(mode = 'range', density = null) { this._pipsMode = mode; this._pipsDensity = density; return this; }
|
|
1343
|
+
pipsMode(mode) { this._pipsMode = mode; return this; }
|
|
1344
|
+
pipsDensity(density) { this._pipsDensity = density; return this; }
|
|
1345
|
+
/** Formatage des graduations (`RawJs` qui reçoit `$value`). */
|
|
1346
|
+
pipsFormatter(formatter) { this._pipsFormatter = formatter; return this; }
|
|
1347
|
+
pipsValues(values) { this._pipsValues = values; return this; }
|
|
1348
|
+
steppedPips(condition = true) { this._steppedPips = condition; return this; }
|
|
1349
|
+
/** Filtre des graduations (`RawJs` qui reçoit `$value`). */
|
|
1350
|
+
pipsFilter(filter) { this._pipsFilter = filter; return this; }
|
|
1351
|
+
/** Échelle non linéaire (`{ '10%': 500, '50%': 4000 }`). */
|
|
1352
|
+
nonLinearPoints(points) { this._nonLinearPoints = points; return this; }
|
|
1353
|
+
/** Nombre de décimales de la valeur enregistrée. */
|
|
1354
|
+
decimalPlaces(places) { this._decimalPlaces = places; return this; }
|
|
1355
|
+
live(options = {}) { this._live = { onBlur: options.onBlur === true, debounce: options.debounce ?? null }; return this; }
|
|
1356
|
+
getMinValueWithPadding() { const padding = this._rangePadding; return this._minValue + (typeof padding === 'number' ? padding : Array.isArray(padding) ? (padding[0] ?? 0) : 0); }
|
|
1357
|
+
getMaxValueWithPadding() { const padding = this._rangePadding; return this._maxValue - (typeof padding === 'number' ? padding : Array.isArray(padding) ? (padding[1] ?? 0) : 0); }
|
|
1358
|
+
isMultiple() { return Array.isArray(this.context().state); }
|
|
1359
|
+
getDefaultState() { return this._default ?? this._minValue; }
|
|
1360
|
+
cast(value) {
|
|
1361
|
+
if (typeof value !== 'number' && (typeof value !== 'string' || value.trim() === '' || !Number.isFinite(Number(value))))
|
|
1362
|
+
return value;
|
|
1363
|
+
const number = Number(value);
|
|
1364
|
+
return this._decimalPlaces === null ? number : Number(number.toFixed(this._decimalPlaces));
|
|
1365
|
+
}
|
|
1366
|
+
hydrateState(value) { return Array.isArray(value) ? value.map((item) => this.cast(item)) : this.cast(value); }
|
|
1367
|
+
dehydrateState(state) { return Array.isArray(state) ? state.map((item) => this.cast(item)) : this.cast(state); }
|
|
1368
|
+
async getValidationRules() {
|
|
1369
|
+
const min = this.getMinValueWithPadding(), max = this.getMaxValueWithPadding(), step = this._step;
|
|
1370
|
+
const isNumber = (v) => (typeof v === 'number' || typeof v === 'string') && String(v).trim() !== '' && Number.isFinite(Number(v));
|
|
1371
|
+
const onStep = (v) => step === null || (step === 1 ? Number.isInteger(Number(v)) : Math.abs(Number(v) / step - Math.round(Number(v) / step)) < 1e-9);
|
|
1372
|
+
const each = (test) => (v) => (Array.isArray(v) ? v.length > 0 && v.every(test) : test(v));
|
|
1373
|
+
return [
|
|
1374
|
+
...(await super.getValidationRules()),
|
|
1375
|
+
{ name: 'numeric', passes: each(isNumber), message: (a) => `The ${a} field must be a number.` },
|
|
1376
|
+
{ name: 'min', passes: each((v) => Number(v) >= min), message: (a) => `The ${a} field must be at least ${min}.` },
|
|
1377
|
+
{ name: 'max', passes: each((v) => Number(v) <= max), message: (a) => `The ${a} field must not be greater than ${max}.` },
|
|
1378
|
+
{ name: step === 1 ? 'integer' : 'multiple_of', passes: each(onStep), message: (a) => (step === 1 ? `The ${a} field must be an integer.` : `The ${a} field must be a multiple of ${step}.`) },
|
|
1379
|
+
];
|
|
1380
|
+
}
|
|
1381
|
+
/** Port de convertRawJsExpressionsToFormatterObjects : un `RawJs` devient `{ to: ($value) => … }`. */
|
|
1382
|
+
static formatters(value) {
|
|
1383
|
+
if (value instanceof RawJs)
|
|
1384
|
+
return `{ to: ($value) => ${value.js} }`;
|
|
1385
|
+
if (Array.isArray(value))
|
|
1386
|
+
return `[${value.map((item) => Slider.formatters(item)).join(', ')}]`;
|
|
1387
|
+
return js(value);
|
|
1388
|
+
}
|
|
1389
|
+
async toHtml() {
|
|
1390
|
+
const id = this.getId();
|
|
1391
|
+
const isDisabled = await this.isDisabled();
|
|
1392
|
+
const hasTooltips = Array.isArray(this._tooltips) ? this._tooltips.some((tooltip) => tooltip !== false) : this._tooltips !== false;
|
|
1393
|
+
const behavior = this._behavior === null || (Array.isArray(this._behavior) && !this._behavior.length) ? 'none' : [].concat(this._behavior).join('-');
|
|
1394
|
+
const config = `sliderFormComponent({
|
|
1395
|
+
arePipsStepped: ${this._steppedPips ? 'true' : 'false'},
|
|
1396
|
+
behavior: ${js(behavior)},
|
|
1397
|
+
decimalPlaces: ${js(this._decimalPlaces)},
|
|
1398
|
+
fillTrack: ${js(this._fillTrack)},
|
|
1399
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1400
|
+
isRtl: ${(this._rtl ?? this._vertical) ? 'true' : 'false'},
|
|
1401
|
+
isVertical: ${this._vertical ? 'true' : 'false'},
|
|
1402
|
+
maxDifference: ${js(this._maxDifference)},
|
|
1403
|
+
minDifference: ${js(this._minDifference)},
|
|
1404
|
+
maxValue: ${js(this._maxValue)},
|
|
1405
|
+
minValue: ${js(this._minValue)},
|
|
1406
|
+
nonLinearPoints: ${js(this._nonLinearPoints)},
|
|
1407
|
+
pipsDensity: ${js(this._pipsDensity)},
|
|
1408
|
+
pipsFilter: ${this._pipsFilter ? `($value) => ${this._pipsFilter.js}` : 'null'},
|
|
1409
|
+
pipsFormatter: ${Slider.formatters(this._pipsFormatter)},
|
|
1410
|
+
pipsMode: ${js(this._pipsMode)},
|
|
1411
|
+
pipsValues: ${js(this._pipsValues)},
|
|
1412
|
+
rangePadding: ${js(this._rangePadding)},
|
|
1413
|
+
state: $wire.$entangle(${js(this.getStatePath())}, ${this._live !== null ? 'true' : 'false'}),
|
|
1414
|
+
step: ${js(this._step)},
|
|
1415
|
+
tooltips: ${Slider.formatters(this._tooltips)},
|
|
1416
|
+
})`;
|
|
1417
|
+
const accessibility = accessibilityOf(this, { withInvalid: !this.isMultiple() });
|
|
1418
|
+
const attributes = new Attributes().merge({ 'aria-labelledby': `${id}-label`, id, role: 'group', ...this._extraAttributes })
|
|
1419
|
+
.class(['fi-fo-slider', { 'fi-fo-slider-has-pips': this._pipsMode !== null, 'fi-fo-slider-has-tooltips': hasTooltips, 'fi-fo-slider-vertical': this._vertical }]);
|
|
1420
|
+
// Même empreinte que Filament : md5(serialize([$isDisabled])).
|
|
1421
|
+
const hash = createHash('md5').update(`a:1:{i:0;b:${isDisabled ? 1 : 0};}`).digest('hex');
|
|
1422
|
+
return this.wrap(`<div hidden data-field-accessibility ${new Attributes().merge(accessibility).toHtml()}></div><div x-load x-load-src="${e(asset('slider'))}" x-data="${e(config)}" wire:ignore wire:key="${e(this.getLivewireKey() ?? '')}.${hash}" ${attributes.toHtml()}></div>`, { labelAsDiv: true });
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
/** Champ caché (port de Hidden) : un `<input type="hidden">` lié à l'état, sans libellé ni place dans la grille. */
|
|
1426
|
+
export class Hidden extends Field {
|
|
1427
|
+
constructor(name) { super(name); this._columnSpan = { default: 'hidden' }; }
|
|
1428
|
+
static make(name) { return configured(new Hidden(name)); }
|
|
1429
|
+
async toHtml() {
|
|
1430
|
+
const attributes = new Attributes().merge({ id: this.getId(), type: 'hidden', 'wire:model': this.getStatePath(), ...this._extraAttributes }).class(['fi-fo-hidden']);
|
|
1431
|
+
return `<input ${attributes.toHtml()} />`;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
/** Code à usage unique (port de OneTimeCodeInput) : une case par chiffre ; l'état est la chaîne de chiffres, de longueur `length()` exactement. */
|
|
1435
|
+
export class OneTimeCodeInput extends Field {
|
|
1436
|
+
_length = 6;
|
|
1437
|
+
_submitOnCompletion = false;
|
|
1438
|
+
_readOnly = false;
|
|
1439
|
+
constructor(name) {
|
|
1440
|
+
super(name);
|
|
1441
|
+
// Comme Filament : `numeric` (et non `integer`, qui refuserait les zéros de tête), puis `digits:<longueur>`.
|
|
1442
|
+
this.rule({ name: 'numeric', passes: (v) => /^\d+$/.test(String(v)), message: (a) => `The ${a} field must be a number.` });
|
|
1443
|
+
this.rule({ name: 'digits', passes: (v) => new RegExp(`^\\d{${this._length}}$`).test(String(v)), message: (a) => `The ${a} field must be ${this._length} digits.` });
|
|
1444
|
+
}
|
|
1445
|
+
static make(name) { return configured(new OneTimeCodeInput(name)); }
|
|
1446
|
+
/** Nombre de chiffres (6 par défaut). */
|
|
1447
|
+
length(length) { this._length = Math.max(1, Math.min(32, Math.floor(length))); return this; }
|
|
1448
|
+
getLength() { return this._length; }
|
|
1449
|
+
/** Soumet le formulaire dès que le dernier chiffre est saisi. */
|
|
1450
|
+
submitOnCompletion(condition = true) { this._submitOnCompletion = condition; return this; }
|
|
1451
|
+
readOnly(condition = true) { this._readOnly = condition; return this; }
|
|
1452
|
+
hydrateState(value) { return value === null || value === undefined ? null : String(value); }
|
|
1453
|
+
async toHtml() {
|
|
1454
|
+
const isDisabled = await this.isDisabled();
|
|
1455
|
+
const accessibility = accessibilityOf(this);
|
|
1456
|
+
const label = await this.getLabel();
|
|
1457
|
+
const container = new Attributes().merge({ ...this._extraAttributes, 'aria-describedby': accessibility['aria-describedby'], 'aria-label': label !== '' ? label : null }).class(['fi-one-time-code-input-ctn']);
|
|
1458
|
+
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']);
|
|
1459
|
+
const digit = new Attributes().merge(accessibility);
|
|
1460
|
+
const inputs = Array.from({ length: this._length }, (_, index) => (index === 0
|
|
1461
|
+
? `<input autocomplete="one-time-code" inputmode="numeric" type="text" ${first.toHtml()} />`
|
|
1462
|
+
: `<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" />`));
|
|
1463
|
+
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>`);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Texte en lecture seule dans un formulaire (port de Placeholder, déprécié dans Filament au profit de TextEntry avec `state()`).
|
|
1468
|
+
* Rien n'est enregistré. Une `HtmlString` est insérée telle quelle.
|
|
1469
|
+
*/
|
|
1470
|
+
export class Placeholder extends Component {
|
|
1471
|
+
_label = null;
|
|
1472
|
+
_content = null;
|
|
1473
|
+
_hiddenLabel = false;
|
|
1474
|
+
constructor(name) { super(); this._name = name; }
|
|
1475
|
+
static make(name) { return configured(new Placeholder(name)); }
|
|
1476
|
+
hasStatePath() { return true; }
|
|
1477
|
+
label(label) { this._label = label; return this; }
|
|
1478
|
+
hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
|
|
1479
|
+
content(content) { this._content = content; return this; }
|
|
1480
|
+
async toHtml() {
|
|
1481
|
+
const ctx = this.context();
|
|
1482
|
+
const label = e((await evaluate(this._label, ctx)) ?? headline(this._name));
|
|
1483
|
+
const content = await evaluate(this._content, ctx);
|
|
1484
|
+
const html = content instanceof HtmlString ? content.html : e(content);
|
|
1485
|
+
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>`;
|
|
1486
|
+
return `<div class="fi-in-entry">${labelCol}<div class="fi-in-entry-content-col"><div class="fi-in-entry-content-ctn" role="definition"><div class="fi-in-entry-content"><div class="fi-in-text-item fi-size-sm fi-in-text fi-wrapped">${html}</div></div></div></div></div>`;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
// --- état imbriqué : un champ placé dans un groupe à état (ex. `address.city`) se lit et s'écrit à son chemin relatif au schéma
|
|
1490
|
+
function relativePath(schema, field) {
|
|
1491
|
+
const base = schema.getStatePath();
|
|
1492
|
+
const full = field.getStatePath();
|
|
1493
|
+
return (base && full.startsWith(`${base}.`) ? full.slice(base.length + 1) : full).split('.');
|
|
1494
|
+
}
|
|
1495
|
+
const readPath = (source, segments) => segments.reduce((node, key) => (node === null || node === undefined ? undefined : node[key]), source);
|
|
1496
|
+
function writePath(target, segments, value) {
|
|
1497
|
+
let node = target;
|
|
1498
|
+
for (const key of segments.slice(0, -1)) {
|
|
1499
|
+
if (node[key] === null || typeof node[key] !== 'object')
|
|
1500
|
+
node[key] = {};
|
|
1501
|
+
node = node[key];
|
|
1502
|
+
}
|
|
1503
|
+
node[segments[segments.length - 1]] = value;
|
|
1504
|
+
}
|
|
1505
|
+
/** Remplit l'état par défaut de tous les champs d'un schéma (équivalent de `$this->form->fill()`). */
|
|
1506
|
+
export function fillSchema(schema, target) {
|
|
1507
|
+
for (const c of schema.getFlatComponents())
|
|
1508
|
+
if (c instanceof Field)
|
|
1509
|
+
writePath(target, relativePath(schema, c), c.getDefaultState());
|
|
1510
|
+
}
|
|
1511
|
+
/** Remplit l'état du formulaire à partir d'un enregistrement, champ par champ (équivalent de `$this->form->fill($record->attributesToArray())`). */
|
|
1512
|
+
export function fillSchemaFromRecord(schema, target, record) {
|
|
1513
|
+
for (const c of schema.getFlatComponents()) {
|
|
1514
|
+
if (!(c instanceof Field))
|
|
1515
|
+
continue;
|
|
1516
|
+
const segments = relativePath(schema, c);
|
|
1517
|
+
writePath(target, segments, c.hydrateState(readPath(record, segments) ?? c.getDefaultState()));
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
/** Valide tous les champs visibles d'un schéma ; retourne les erreurs par chemin d'état. */
|
|
1521
|
+
export async function validateSchema(schema) {
|
|
1522
|
+
const errors = {};
|
|
1523
|
+
for (const c of await schema.getVisibleFlatComponents()) {
|
|
1524
|
+
if (!(c instanceof Field))
|
|
1525
|
+
continue;
|
|
1526
|
+
const messages = await c.validate();
|
|
1527
|
+
if (messages.length)
|
|
1528
|
+
errors[c.getStatePath()] = messages;
|
|
1529
|
+
}
|
|
1530
|
+
return errors;
|
|
1531
|
+
}
|
|
1532
|
+
/** État à enregistrer : seulement les champs déclarés, visibles et « déshydratés » ; une chaîne vide vaut `null` (comme Laravel). */
|
|
1533
|
+
export async function getSchemaState(schema) {
|
|
1534
|
+
const data = {};
|
|
1535
|
+
const fields = [];
|
|
1536
|
+
for (const c of await schema.getVisibleFlatComponents()) {
|
|
1537
|
+
if (!(c instanceof Field) || !(await c.isDehydrated()))
|
|
1538
|
+
continue;
|
|
1539
|
+
const state = await c.dehydrateState(c.context().state);
|
|
1540
|
+
writePath(data, relativePath(schema, c), state === '' || state === undefined ? null : state);
|
|
1541
|
+
fields.push(c);
|
|
1542
|
+
}
|
|
1543
|
+
// Du plus profond au moins profond : un conteneur voit ses enfants déjà mis en forme.
|
|
1544
|
+
for (const c of fields.reverse()) {
|
|
1545
|
+
const path = relativePath(schema, c);
|
|
1546
|
+
const current = readPath(data, path);
|
|
1547
|
+
const final = c.finalizeDehydratedState(current);
|
|
1548
|
+
if (final !== current)
|
|
1549
|
+
writePath(data, path, final);
|
|
1550
|
+
}
|
|
1551
|
+
return data;
|
|
1552
|
+
}
|
|
1553
|
+
/** Lancée depuis `beforeValidation` / `afterValidation` d'une étape pour rester sur l'étape courante (équivalent de l'exception `Halt` de Filament). */
|
|
1554
|
+
export class Halt extends Error {
|
|
1555
|
+
constructor() { super('Halt'); }
|
|
1556
|
+
}
|
|
1557
|
+
/** Une étape d'un assistant : un titre, une description et une icône facultatives, son propre schéma (port de Wizard\Step). */
|
|
1558
|
+
export class Step extends Component {
|
|
1559
|
+
children = [];
|
|
1560
|
+
_columns = {};
|
|
1561
|
+
label;
|
|
1562
|
+
_description = null;
|
|
1563
|
+
_icon = null;
|
|
1564
|
+
_completedIcon = null;
|
|
1565
|
+
_beforeValidation = null;
|
|
1566
|
+
_afterValidation = null;
|
|
1567
|
+
constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::wizard-step`; }
|
|
1568
|
+
static make(label) { return configured(new Step(label)); }
|
|
1569
|
+
schema(components) { this.children = components; return this; }
|
|
1570
|
+
columns(columns = 2) { this._columns = columns; return this; }
|
|
1571
|
+
description(description) { this._description = description; return this; }
|
|
1572
|
+
icon(name) { this._icon = name; return this; }
|
|
1573
|
+
completedIcon(name) { this._completedIcon = name; return this; }
|
|
1574
|
+
/** Avant la validation de l'étape, au clic sur « Next » ; lever `Halt` pour rester sur l'étape. */
|
|
1575
|
+
beforeValidation(callback) { this._beforeValidation = callback; return this; }
|
|
1576
|
+
/** Après une validation réussie, avant de passer à l'étape suivante ; lever `Halt` pour rester sur l'étape. */
|
|
1577
|
+
afterValidation(callback) { this._afterValidation = callback; return this; }
|
|
1578
|
+
getLabel() { return this.label; }
|
|
1579
|
+
getDescription() { return this._description; }
|
|
1580
|
+
getIcon() { return this._icon; }
|
|
1581
|
+
getCompletedIcon() { return this._completedIcon; }
|
|
1582
|
+
async callBeforeValidation() { await this._beforeValidation?.(this.context()); }
|
|
1583
|
+
async callAfterValidation() { await this._afterValidation?.(this.context()); }
|
|
1584
|
+
getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
|
|
1585
|
+
async toHtml() { return this.getChildSchemas()[0].toHtml(); }
|
|
1586
|
+
/** Panneau de l'étape : un `<form>` quand l'assistant sait quoi soumettre (Entrée passe à l'étape suivante, ou enregistre à la dernière). */
|
|
1587
|
+
async toStepHtml(submitHandler, hasHeader) {
|
|
1588
|
+
const key = this.getKey();
|
|
1589
|
+
const tag = submitHandler ? 'form' : 'div';
|
|
1590
|
+
const attributes = new Attributes().merge({
|
|
1591
|
+
'aria-labelledby': hasHeader ? `${key}-tab` : null,
|
|
1592
|
+
'aria-label': hasHeader ? null : this.label,
|
|
1593
|
+
id: key,
|
|
1594
|
+
role: 'group',
|
|
1595
|
+
'x-data': `filamentSchemaComponent(${js(this.getAlpineScopeConfiguration())})`,
|
|
1596
|
+
}).class(['fi-sc-wizard-step']);
|
|
1597
|
+
return `<${tag} x-bind:tabindex="$el.querySelector('[autofocus]') ? '-1' : '0'" x-bind:class="{
|
|
1598
|
+
'fi-active': step === ${e(js(key))},
|
|
1599
|
+
}" x-on:expand="
|
|
1600
|
+
if (! isStepAccessible(${e(js(key))})) {
|
|
1601
|
+
return
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
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}>`;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Assistant par étapes (port de Wizard) : toutes les étapes sont rendues, le navigateur n'affiche que l'étape courante.
|
|
1609
|
+
* « Next » valide l'étape courante côté serveur avant d'avancer, sauf si l'assistant est `skippable()`.
|
|
1610
|
+
*/
|
|
1611
|
+
export class Wizard extends Component {
|
|
1612
|
+
steps;
|
|
1613
|
+
_label = null;
|
|
1614
|
+
_skippable = false;
|
|
1615
|
+
_startStep = 1;
|
|
1616
|
+
_contained = true;
|
|
1617
|
+
_headerHidden = false;
|
|
1618
|
+
_cancelAction = null;
|
|
1619
|
+
_submitAction = null;
|
|
1620
|
+
_alpineSubmitHandler = null;
|
|
1621
|
+
_stepQueryStringKey = null;
|
|
1622
|
+
_modifyNext = null;
|
|
1623
|
+
_modifyPrevious = null;
|
|
1624
|
+
constructor(steps = []) { super(); this.steps = steps; }
|
|
1625
|
+
static make(steps = []) { return configured(new Wizard(steps)); }
|
|
1626
|
+
schema(steps) { this.steps = steps; return this; }
|
|
1627
|
+
label(label) { this._label = label; return this; }
|
|
1628
|
+
/** Étapes accessibles dans n'importe quel ordre, sans validation au passage. */
|
|
1629
|
+
skippable(condition = true) { this._skippable = condition; return this; }
|
|
1630
|
+
/** Étape affichée à l'ouverture (à partir de 1). */
|
|
1631
|
+
startOnStep(step) { this._startStep = step; return this; }
|
|
1632
|
+
/** Faux : sans carte autour de l'assistant (c'est le cas sur une page de resource). */
|
|
1633
|
+
contained(condition = true) { this._contained = condition; return this; }
|
|
1634
|
+
hiddenHeader(condition = true) { this._headerHidden = condition; return this; }
|
|
1635
|
+
/** Action affichée à la place de « Back » sur la première étape. */
|
|
1636
|
+
cancelAction(action) { this._cancelAction = action; return this; }
|
|
1637
|
+
/** Action affichée à la place de « Next » sur la dernière étape. */
|
|
1638
|
+
submitAction(action) { this._submitAction = action; return this; }
|
|
1639
|
+
/** Expression Alpine exécutée quand la dernière étape est soumise au clavier (ex. `$wire.create()`). */
|
|
1640
|
+
alpineSubmitHandler(handler) { this._alpineSubmitHandler = handler; return this; }
|
|
1641
|
+
/** Garde l'étape courante dans l'adresse (`?step=…`). */
|
|
1642
|
+
persistStepInQueryString(key = 'step') { this._stepQueryStringKey = key; return this; }
|
|
1643
|
+
nextAction(modify) { this._modifyNext = modify; return this; }
|
|
1644
|
+
previousAction(modify) { this._modifyPrevious = modify; return this; }
|
|
1645
|
+
getKey() {
|
|
1646
|
+
const statePath = this.container.getStatePath();
|
|
1647
|
+
const own = statePath ? `${statePath}::wizard` : 'wizard';
|
|
1648
|
+
return `${this.container.getKey()}.${this._label ? `${slug(this._label)}::${own}` : own}`;
|
|
1649
|
+
}
|
|
1650
|
+
getExposedMethods() { return ['nextStep', 'previousStep']; }
|
|
1651
|
+
/** « Next » redessine le composant : les erreurs de validation de l'étape doivent apparaître. */
|
|
1652
|
+
isRenderlessMethod() { return false; }
|
|
1653
|
+
getChildSchemas() { return [childSchema(this, this.steps)]; }
|
|
1654
|
+
getSteps() { this.getChildSchemas(); return this.steps; }
|
|
1655
|
+
/** Appelée par le navigateur au clic sur « Next » : valide l'étape courante, puis annonce l'étape suivante. */
|
|
1656
|
+
async nextStep(currentStepIndex = null, currentStepKey = null) {
|
|
1657
|
+
const steps = this.getSteps();
|
|
1658
|
+
if (!Number.isInteger(currentStepIndex) || currentStepIndex < 0 || currentStepIndex >= steps.length - 1)
|
|
1659
|
+
return;
|
|
1660
|
+
const current = steps[currentStepIndex];
|
|
1661
|
+
if (currentStepKey !== null && current.getKey() !== currentStepKey)
|
|
1662
|
+
return;
|
|
1663
|
+
const livewire = this.livewire;
|
|
1664
|
+
if (!this._skippable) {
|
|
1665
|
+
try {
|
|
1666
|
+
await current.callBeforeValidation();
|
|
1667
|
+
const errors = await validateSchema(current.getChildSchemas()[0]);
|
|
1668
|
+
livewire._errors = errors;
|
|
1669
|
+
if (Object.keys(errors).length) {
|
|
1670
|
+
livewire.dispatch('form-validation-error', { livewireId: livewire.id });
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
await current.callAfterValidation();
|
|
1674
|
+
}
|
|
1675
|
+
catch (error) {
|
|
1676
|
+
if (error instanceof Halt)
|
|
1677
|
+
return;
|
|
1678
|
+
throw error;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
livewire.dispatch('next-wizard-step', { key: this.getKey(), step: steps[currentStepIndex + 1].getKey(), currentStep: current.getKey() });
|
|
1682
|
+
}
|
|
1683
|
+
/** Le retour en arrière se fait dans le navigateur ; la méthode existe pour le contrat de Filament. */
|
|
1684
|
+
previousStep() { }
|
|
1685
|
+
async toHtml() {
|
|
1686
|
+
const steps = this.getSteps();
|
|
1687
|
+
const key = this.getKey();
|
|
1688
|
+
const ctx = this.context();
|
|
1689
|
+
const tr = (name) => t(`filament-schemas::components.wizard.${name}`);
|
|
1690
|
+
const previous = Action.make('previous').label(tr('actions.previous_step.label')).color('gray');
|
|
1691
|
+
const previousAction = this._modifyPrevious?.(previous) ?? previous;
|
|
1692
|
+
const next = Action.make('next').label(tr('actions.next_step.label')).iconPosition('after');
|
|
1693
|
+
if (steps.length > 1)
|
|
1694
|
+
next.livewireTarget(steps.slice(0, -1).map((step, index) => `callSchemaComponentMethod(${js(key)}, 'nextStep', ${js({ currentStepIndex: index, currentStepKey: step.getKey() })})`).join(', '));
|
|
1695
|
+
const nextAction = this._modifyNext?.(next) ?? next;
|
|
1696
|
+
const [previousDisabled, nextDisabled] = [await previousAction.isDisabled(ctx), await nextAction.isDisabled(ctx)];
|
|
1697
|
+
const visibleKeys = [];
|
|
1698
|
+
for (const step of steps)
|
|
1699
|
+
if (await step.isVisible())
|
|
1700
|
+
visibleKeys.push(step.getKey());
|
|
1701
|
+
const header = [];
|
|
1702
|
+
steps.forEach((step, index) => {
|
|
1703
|
+
const stepKey = step.getKey();
|
|
1704
|
+
const shown = new Attributes({ 'x-cloak': 'x-cloak', 'x-show': `getStepIndex(step) <= ${index}` });
|
|
1705
|
+
const stepIcon = step.getIcon()
|
|
1706
|
+
? icon(step.getIcon(), { size: 'lg', attributes: shown })
|
|
1707
|
+
: `<span x-show="getStepIndex(step) <= ${index}" aria-hidden="true" class="fi-sc-wizard-header-step-number">${String(index + 1).padStart(2, '0')}</span>`;
|
|
1708
|
+
header.push(`<li class="fi-sc-wizard-header-step" x-bind:class="{
|
|
1709
|
+
'fi-active': getStepIndex(step) === ${index},
|
|
1710
|
+
'fi-completed': getStepIndex(step) > ${index},
|
|
1711
|
+
}"><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>`);
|
|
1712
|
+
});
|
|
1713
|
+
const panels = [];
|
|
1714
|
+
for (const step of steps)
|
|
1715
|
+
panels.push(await step.toStepHtml(this._alpineSubmitHandler, !this._headerHidden));
|
|
1716
|
+
const outer = new Attributes().merge({ id: key }).class(['fi-sc-wizard', { 'fi-contained': this._contained, 'fi-sc-wizard-header-hidden': this._headerHidden }]);
|
|
1717
|
+
return `<div x-load x-load-src="${e(views.render('filament-schemas::asset', { path: 'wizard' }).trim())}" x-data="wizardSchemaComponent({
|
|
1718
|
+
isSkippable: ${this._skippable ? 'true' : 'false'},
|
|
1719
|
+
isStepPersistedInQueryString: ${this._stepQueryStringKey ? 'true' : 'false'},
|
|
1720
|
+
livewireId: ${e(js(this.livewire.id))},
|
|
1721
|
+
schemaKey: ${e(js(this.container.getKey().split('.')[0]))},
|
|
1722
|
+
startStep: ${this._startStep},
|
|
1723
|
+
stepQueryStringKey: ${e(js(this._stepQueryStringKey))},
|
|
1724
|
+
})" x-on:next-wizard-step.window="${e(`if ($event.detail.key === ${js(key)}) goToNextStep($event.detail.step, $event.detail.currentStep)`)}" x-on:go-to-wizard-step.window="${e(`$event.detail.key === ${js(key)} && goToStep($event.detail.step)`)}" wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${e(JSON.stringify(visibleKeys))}" x-ref="stepsData" />${this._headerHidden ? '' : `<ol${this._label ? ` aria-label="${e(this._label)}"` : ''} role="list" x-cloak x-ref="header" class="fi-sc-wizard-header">${header.join('')}</ol>`}${panels.join('')}<div x-cloak class="fi-sc-wizard-footer"><div x-cloak${previousDisabled ? '' : ' x-on:click="goToPreviousStep"'} x-show="! isFirstStep()">${await previousAction.toHtml(ctx)}</div><div x-show="isFirstStep()">${this._cancelAction ? await this._cancelAction.toHtml(ctx) : ''}</div><div x-cloak${nextDisabled ? '' : ` x-on:click.capture="if ($el.hasAttribute('aria-disabled')) { $event.preventDefault(); $event.stopImmediatePropagation() }" x-on:click="if (! $el.hasAttribute('aria-disabled')) requestNextStep()"`} x-bind:class="{ 'fi-hidden': isLastStep() }" wire:loading.attr="aria-disabled">${await nextAction.toHtml(ctx)}</div><div x-bind:class="{ 'fi-hidden': ! isLastStep() }">${this._submitAction ? await this._submitAction.toHtml(ctx) : ''}</div></div></div>`;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
/** Stockage sur le disque local, servi sous une adresse publique. Le nom du fichier est aléatoire, son extension vient du type détecté. */
|
|
1728
|
+
export class LocalFileStorage {
|
|
1729
|
+
root;
|
|
1730
|
+
baseUrl;
|
|
1731
|
+
constructor(root, baseUrl) { this.root = root; this.baseUrl = baseUrl.replace(/\/$/, ''); }
|
|
1732
|
+
resolve(key) {
|
|
1733
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,250}$/.test(key) || key.split('/').some((part) => part === '..' || part === '.'))
|
|
1734
|
+
return null;
|
|
1735
|
+
return nodePath.join(this.root, ...key.split('/'));
|
|
1736
|
+
}
|
|
1737
|
+
async store(file, directory) {
|
|
1738
|
+
const key = [directory, `${randomUUID()}.${file.extension}`].filter(Boolean).join('/');
|
|
1739
|
+
const target = this.resolve(key);
|
|
1740
|
+
if (!target)
|
|
1741
|
+
throw new Error('Dossier de téléversement invalide.');
|
|
1742
|
+
mkdirSync(nodePath.dirname(target), { recursive: true });
|
|
1743
|
+
copyFileSync(file.path, target);
|
|
1744
|
+
writeFileSync(`${target}.meta.json`, JSON.stringify({ name: file.name, type: file.type }));
|
|
1745
|
+
return key;
|
|
1746
|
+
}
|
|
1747
|
+
async info(key) {
|
|
1748
|
+
const target = this.resolve(key);
|
|
1749
|
+
if (!target || !existsSync(target))
|
|
1750
|
+
return null;
|
|
1751
|
+
let meta = {};
|
|
1752
|
+
try {
|
|
1753
|
+
meta = JSON.parse(readFileSync(`${target}.meta.json`, 'utf8'));
|
|
1754
|
+
}
|
|
1755
|
+
catch { /* fichier déposé sans métadonnées */ }
|
|
1756
|
+
return { name: meta.name ?? nodePath.basename(key), size: statSync(target).size, type: meta.type ?? 'application/octet-stream', url: `${this.baseUrl}/${key.split('/').map(encodeURIComponent).join('/')}` };
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Téléversement de fichiers (port de FileUpload, rendu FilePond de Filament).
|
|
1761
|
+
* État : `{ <clé de fichier>: <clé enregistrée> | 'livewire-file:<id temporaire>' }`. Une clé enregistrée n'est acceptée du navigateur
|
|
1762
|
+
* que si elle figure déjà dans l'enregistrement en cours d'édition : on ne peut pas s'attribuer le fichier d'un autre.
|
|
1763
|
+
*/
|
|
1764
|
+
export class FileUpload extends Field {
|
|
1765
|
+
/** Stockage par défaut de l'application. */
|
|
1766
|
+
static storage = null;
|
|
1767
|
+
_multiple = false;
|
|
1768
|
+
_maxFiles = null;
|
|
1769
|
+
_maxSize = null;
|
|
1770
|
+
_reorderable = false;
|
|
1771
|
+
_acceptedFileTypes = null;
|
|
1772
|
+
_directory = null;
|
|
1773
|
+
_storeFiles = true;
|
|
1774
|
+
_storage = null;
|
|
1775
|
+
_getUploadedFileUsing = null;
|
|
1776
|
+
static make(name) { return configured(new FileUpload(name)); }
|
|
1777
|
+
multiple(condition = true) { this._multiple = condition; return this; }
|
|
1778
|
+
maxFiles(count) { this._maxFiles = count; return this; }
|
|
1779
|
+
/** Taille maximale d'un fichier, en kilo-octets. */
|
|
1780
|
+
maxSize(kilobytes) { this._maxSize = kilobytes; return this; }
|
|
1781
|
+
reorderable(condition = true) { this._reorderable = condition; return this; }
|
|
1782
|
+
/** Types MIME acceptés (`image/jpeg`, `image/*`…), vérifiés sur le contenu du fichier reçu. */
|
|
1783
|
+
acceptedFileTypes(types) { this._acceptedFileTypes = types; return this; }
|
|
1784
|
+
image() { return this.acceptedFileTypes(['image/*']); }
|
|
1785
|
+
directory(directory) { this._directory = directory; return this; }
|
|
1786
|
+
storage(storage) { this._storage = storage; return this; }
|
|
1787
|
+
/** `false` : le champ n'enregistre rien lui-même ; les données reçoivent des `FileUploadEntry`, à traiter par l'adaptateur (médias). */
|
|
1788
|
+
storeFiles(condition = true) { this._storeFiles = condition; return this; }
|
|
1789
|
+
/** Informations d'un fichier déjà enregistré (nom, taille, type, adresse), quand elles ne viennent pas du stockage. */
|
|
1790
|
+
getUploadedFileUsing(resolver) { this._getUploadedFileUsing = resolver; return this; }
|
|
1791
|
+
getDefaultState() { return {}; }
|
|
1792
|
+
hydrateState(value) {
|
|
1793
|
+
const keys = Array.isArray(value) ? value : value === null || value === undefined || value === '' ? [] : typeof value === 'object' ? Object.values(value) : [value];
|
|
1794
|
+
return Object.fromEntries(keys.filter((key) => typeof key === 'string' || typeof key === 'number').map((key) => [randomUUID(), String(key)]));
|
|
1795
|
+
}
|
|
1796
|
+
get uploads() { return this.livewire._runtime?.uploads ?? null; }
|
|
1797
|
+
getStorage() { return this._storage ?? FileUpload.storage; }
|
|
1798
|
+
/** Entrées de l'état, nettoyées : clé de fichier sûre, valeur chaîne. */
|
|
1799
|
+
entries() {
|
|
1800
|
+
const state = this.context().state;
|
|
1801
|
+
if (!state || typeof state !== 'object' || Array.isArray(state))
|
|
1802
|
+
return [];
|
|
1803
|
+
return Object.entries(state).filter(([key, value]) => /^[A-Za-z0-9-]{1,64}$/.test(key) && typeof value === 'string').map(([key, value]) => [key, value]).slice(0, 100);
|
|
1804
|
+
}
|
|
1805
|
+
/** Clés enregistrées que l'enregistrement en cours possède déjà : les seules qu'on accepte de relire depuis le navigateur. */
|
|
1806
|
+
originalKeys() {
|
|
1807
|
+
const record = this.container.getRecord();
|
|
1808
|
+
return new Set(Object.values(this.hydrateState(record?.[this.getName()])));
|
|
1809
|
+
}
|
|
1810
|
+
isTemporary(value) { return value.startsWith('livewire-file:'); }
|
|
1811
|
+
// --- méthodes exposées au JavaScript du champ
|
|
1812
|
+
getExposedMethods() { return ['deleteUploadedFile', 'removeUploadedFile', 'reorderUploadedFiles', 'getUploadedFiles']; }
|
|
1813
|
+
async getUploadedFiles() {
|
|
1814
|
+
const original = this.originalKeys();
|
|
1815
|
+
const files = {};
|
|
1816
|
+
for (const [fileKey, value] of this.entries()) {
|
|
1817
|
+
if (this.isTemporary(value) || !original.has(value)) {
|
|
1818
|
+
files[fileKey] = null;
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
const info = this._getUploadedFileUsing ? await this._getUploadedFileUsing(value, this.context()) : await this.getStorage()?.info(value);
|
|
1822
|
+
// Seules des adresses locales ou http(s) partent vers le navigateur.
|
|
1823
|
+
files[fileKey] = info && /^(\/(?!\/)|https?:\/\/)/.test(info.url) ? info : null;
|
|
1824
|
+
}
|
|
1825
|
+
return files;
|
|
1826
|
+
}
|
|
1827
|
+
writeState(entries) { writePath(this.livewire, this.getStatePath().split('.'), Object.fromEntries(entries)); }
|
|
1828
|
+
/** Retire un fichier du champ. Le fichier enregistré n'est supprimé qu'à l'enregistrement du formulaire, par l'application. */
|
|
1829
|
+
removeUploadedFile(fileKey) {
|
|
1830
|
+
const removed = this.entries().find(([key]) => key === fileKey);
|
|
1831
|
+
if (removed && this.isTemporary(removed[1]))
|
|
1832
|
+
this.uploads?.remove(removed[1].slice('livewire-file:'.length));
|
|
1833
|
+
this.writeState(this.entries().filter(([key]) => key !== fileKey));
|
|
1834
|
+
}
|
|
1835
|
+
deleteUploadedFile(fileKey) { this.removeUploadedFile(fileKey); }
|
|
1836
|
+
reorderUploadedFiles(fileKeys) {
|
|
1837
|
+
if (!this._reorderable || !Array.isArray(fileKeys))
|
|
1838
|
+
return;
|
|
1839
|
+
const entries = new Map(this.entries());
|
|
1840
|
+
const ordered = fileKeys.map(String).filter((key) => entries.has(key));
|
|
1841
|
+
this.writeState([...ordered.map((key) => [key, entries.get(key)]), ...[...entries].filter(([key]) => !ordered.includes(key))]);
|
|
1842
|
+
}
|
|
1843
|
+
async getValidationRules() {
|
|
1844
|
+
const entries = () => this.entries();
|
|
1845
|
+
const temporary = () => entries().filter(([, value]) => this.isTemporary(value)).map(([, value]) => this.uploads?.resolve(value) ?? null);
|
|
1846
|
+
const accepts = (type) => (this._acceptedFileTypes ?? []).some((accepted) => accepted === type || (accepted.endsWith('/*') && type.startsWith(accepted.slice(0, -1))));
|
|
1847
|
+
const rules = [];
|
|
1848
|
+
if (await this.isRequired())
|
|
1849
|
+
rules.push({ name: 'required', passes: () => entries().length > 0, message: (a) => `The ${a} field is required.` });
|
|
1850
|
+
rules.push({ name: 'file', passes: () => temporary().every((file) => file !== null), message: (a) => `The ${a} field must be a file.` });
|
|
1851
|
+
if (!this._multiple)
|
|
1852
|
+
rules.push({ name: 'max_files', passes: () => entries().length <= 1, message: (a) => `The ${a} field must not have more than 1 items.` });
|
|
1853
|
+
if (this._maxFiles !== null)
|
|
1854
|
+
rules.push({ name: 'max_files', passes: () => entries().length <= this._maxFiles, message: (a) => `The ${a} field must not have more than ${this._maxFiles} items.` });
|
|
1855
|
+
if (this._acceptedFileTypes)
|
|
1856
|
+
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(', ')}.` });
|
|
1857
|
+
if (this._maxSize !== null)
|
|
1858
|
+
rules.push({ name: 'max', passes: () => temporary().every((file) => file !== null && file.size <= this._maxSize * 1024), message: (a) => `The ${a} field must not be greater than ${this._maxSize} kilobytes.` });
|
|
1859
|
+
return rules;
|
|
1860
|
+
}
|
|
1861
|
+
/** La validation porte sur l'état entier, même vide (un champ obligatoire sans fichier doit échouer). */
|
|
1862
|
+
async validate() {
|
|
1863
|
+
if (!(await this.isDehydrated()))
|
|
1864
|
+
return [];
|
|
1865
|
+
const attribute = (await this.getLabel()).toLowerCase();
|
|
1866
|
+
for (const rule of await this.getValidationRules())
|
|
1867
|
+
if (!(await rule.passes(this.context().state, this.context())))
|
|
1868
|
+
return [rule.message(attribute)];
|
|
1869
|
+
return [];
|
|
1870
|
+
}
|
|
1871
|
+
/** À l'enregistrement : les fichiers temporaires sont stockés, les clés existantes non reconnues sont écartées. */
|
|
1872
|
+
async dehydrateState() {
|
|
1873
|
+
const original = this.originalKeys();
|
|
1874
|
+
const out = [];
|
|
1875
|
+
for (const [, value] of this.entries()) {
|
|
1876
|
+
if (this.isTemporary(value)) {
|
|
1877
|
+
const file = this.uploads?.resolve(value);
|
|
1878
|
+
if (!file)
|
|
1879
|
+
continue;
|
|
1880
|
+
if (!this._storeFiles) {
|
|
1881
|
+
out.push({ file });
|
|
1882
|
+
continue;
|
|
1883
|
+
}
|
|
1884
|
+
const storage = this.getStorage();
|
|
1885
|
+
if (!storage)
|
|
1886
|
+
throw new Error('FileUpload : aucun stockage configuré (FileUpload.storage).');
|
|
1887
|
+
out.push(await storage.store(file, this._directory));
|
|
1888
|
+
this.uploads?.remove(file.id);
|
|
1889
|
+
}
|
|
1890
|
+
else if (original.has(value))
|
|
1891
|
+
out.push(this._storeFiles ? value : { key: value });
|
|
1892
|
+
}
|
|
1893
|
+
return this._multiple ? out : (out[0] ?? null);
|
|
1894
|
+
}
|
|
1895
|
+
async toHtml() {
|
|
1896
|
+
const isDisabled = await this.isDisabled();
|
|
1897
|
+
const id = this.getId();
|
|
1898
|
+
const label = (await this.getLabel()).toLowerCase();
|
|
1899
|
+
const tr = (key) => t(`filament-forms::components.file_upload.${key}`);
|
|
1900
|
+
const config = `fileUploadFormComponent({
|
|
1901
|
+
acceptedFileTypes: ${this._acceptedFileTypes ? js(this._acceptedFileTypes) : 'null'},
|
|
1902
|
+
automaticallyCropImagesAspectRatio: null,
|
|
1903
|
+
automaticallyOpenImageEditorForAspectRatio: null,
|
|
1904
|
+
automaticallyResizeImagesMode: null,
|
|
1905
|
+
automaticallyResizeImagesHeight: null,
|
|
1906
|
+
automaticallyResizeImagesWidth: null,
|
|
1907
|
+
canEditSvgs: true,
|
|
1908
|
+
confirmSvgEditingMessage: ${js(tr('editor.svg.messages.confirmation'))},
|
|
1909
|
+
disabledSvgEditingMessage: ${js(tr('editor.svg.messages.disabled'))},
|
|
1910
|
+
hasCircleCropper: false,
|
|
1911
|
+
hasImageEditor: false,
|
|
1912
|
+
imageEditorEmptyFillColor: null,
|
|
1913
|
+
imageEditorMode: 1,
|
|
1914
|
+
imageEditorViewportHeight: null,
|
|
1915
|
+
imageEditorViewportWidth: null,
|
|
1916
|
+
imagePreviewHeight: null,
|
|
1917
|
+
isAvatar: false,
|
|
1918
|
+
isDeletable: true,
|
|
1919
|
+
isDisabled: ${isDisabled ? 'true' : 'false'},
|
|
1920
|
+
isDownloadable: false,
|
|
1921
|
+
isImageEditorExplicitlyEnabled: false,
|
|
1922
|
+
isMultiple: ${this._multiple ? 'true' : 'false'},
|
|
1923
|
+
isOpenable: false,
|
|
1924
|
+
isPasteable: true,
|
|
1925
|
+
isPreviewable: true,
|
|
1926
|
+
isReorderable: ${this._reorderable ? 'true' : 'false'},
|
|
1927
|
+
isSvgEditingConfirmed: false,
|
|
1928
|
+
itemPanelAspectRatio: null,
|
|
1929
|
+
loadingIndicatorPosition: 'right',
|
|
1930
|
+
locale: 'en',
|
|
1931
|
+
maxFiles: ${this._maxFiles ?? 'null'},
|
|
1932
|
+
maxFilesValidationMessage: ${this._maxFiles !== null ? js(`The ${label} field must not have more than ${this._maxFiles} items.`) : 'null'},
|
|
1933
|
+
maxParallelUploads: null,
|
|
1934
|
+
maxSize: ${this._maxSize !== null ? js(`${this._maxSize}KB`) : 'null'},
|
|
1935
|
+
mimeTypeMap: [],
|
|
1936
|
+
minSize: null,
|
|
1937
|
+
panelAspectRatio: null,
|
|
1938
|
+
panelLayout: 'compact',
|
|
1939
|
+
placeholder: null,
|
|
1940
|
+
removeUploadedFileButtonPosition: 'left',
|
|
1941
|
+
shouldAppendFiles: false,
|
|
1942
|
+
shouldAutomaticallyUpscaleImagesWhenResizing: true,
|
|
1943
|
+
shouldOrientImageFromExif: true,
|
|
1944
|
+
shouldTransformImage: false,
|
|
1945
|
+
state: $wire.$entangle(${js(this.getStatePath())}, false),
|
|
1946
|
+
statePath: ${js(this.getStatePath())},
|
|
1947
|
+
uploadButtonPosition: 'right',
|
|
1948
|
+
uploadingMessage: ${js(t('filament::components/button.messages.uploading_file'))},
|
|
1949
|
+
downloadActionLabel: ${js(tr('actions.download.label'))},
|
|
1950
|
+
openActionLabel: ${js(tr('actions.open.label'))},
|
|
1951
|
+
uploadProgressIndicatorPosition: 'right',
|
|
1952
|
+
})`;
|
|
1953
|
+
const key = `${this.getLivewireKey()}.${createHash('md5').update(JSON.stringify([this._acceptedFileTypes, this._maxFiles, this._multiple, isDisabled])).digest('hex')}`;
|
|
1954
|
+
const input = new Attributes().merge({ 'x-ref': 'input', 'aria-labelledby': `${id}-label`, disabled: isDisabled, multiple: this._multiple ? 'multiple' : null, type: 'file' });
|
|
1955
|
+
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 });
|
|
1956
|
+
}
|
|
1957
|
+
}
|