@navette-js/schemas 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/lib/index.js ADDED
@@ -0,0 +1,832 @@
1
+ // @navette-js/schemas — port de filament/schemas : Schema, Component et premiers composants de mise en page.
2
+ // Rendu en deux temps par composant : résolution asynchrone des propriétés, puis vue EJS synchrone (ADR 0003).
3
+ import { Attributes, HtmlString, cls, e, evaluate, icon, js, renderHook, views, configured, configureUsing } from '@navette-js/support';
4
+ import { t } from '@navette-js/support/i18n';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createHash } from 'node:crypto';
7
+ views.namespace('filament-schemas', fileURLToPath(new URL('../views', import.meta.url)));
8
+ export class Component {
9
+ _grow = true;
10
+ /** Dans un Flex : `false` pour garder la largeur naturelle au lieu d'occuper la place disponible. */
11
+ grow(condition = true) { this._grow = condition; return this; }
12
+ canGrow() { return this._grow; }
13
+ /** Réglage par défaut de toutes les instances créées ensuite par `make()` : `TextInput.configureUsing((input) => input.maxLength(255))`. */
14
+ static configureUsing(configure) { configureUsing(this, configure); }
15
+ _name = null;
16
+ _keyName = null;
17
+ _hidden = false;
18
+ _visible = true;
19
+ _columnSpan = { default: 1 };
20
+ _extraAttributes = {};
21
+ _columnStart = null;
22
+ container;
23
+ hidden(condition = true) { this._hidden = condition; return this; }
24
+ visible(condition = true) { this._visible = condition; return this; }
25
+ key(key) { this._keyName = key; return this; }
26
+ /** Masqué pour une opération donnée (`'create'`, `'edit'`…). */
27
+ hiddenOn(operations) { const list = [].concat(operations); return this.hidden(({ operation }) => operation !== null && list.includes(operation)); }
28
+ /** Visible seulement pour une opération donnée. */
29
+ visibleOn(operations) { const list = [].concat(operations); return this.visible(({ operation }) => operation !== null && list.includes(operation)); }
30
+ /** Comme Filament : un nombre vaut pour le point de rupture `lg` ; un objet remplace entièrement la valeur par défaut. Les valeurs peuvent être des closures. */
31
+ columnSpan(span) { this._columnSpan = typeof span === 'object' ? span : { default: 1, lg: span }; return this; }
32
+ columnSpanFull() { this._columnSpan = { default: 'full' }; return this; }
33
+ /** Colonne de départ dans la grille, à partir du point de rupture `lg` (comme Filament). */
34
+ columnStart(start) { this._columnStart = start; return this; }
35
+ /** Toute la largeur de la grille, quel que soit l'écran (`columnSpan('full')` ne vaut qu'à partir de `lg`). */
36
+ columnSpanFull() { return this.columnSpan({ default: 'full' }); }
37
+ extraAttributes(attributes) { Object.assign(this._extraAttributes, attributes); return this; }
38
+ get livewire() { return this.container.livewire; }
39
+ isLiberatedFromContainerGrid() { return false; }
40
+ /** Méthodes que le JavaScript du composant peut appeler côté serveur (annoncées au navigateur). */
41
+ getExposedMethods() { return []; }
42
+ /** Faux pour une méthode exposée après laquelle le composant hôte doit être redessiné (étape suivante d'un assistant : erreurs de validation). */
43
+ isRenderlessMethod(_method) { return true; }
44
+ hasStatePath() { return false; }
45
+ /** Clé absolue du composant : `<clé du schéma>.<clé ou nom>`, ou null. */
46
+ getKey() {
47
+ // `::data::` dans une clé générée (section, onglets) tient lieu du chemin d'état du schéma : absent sans chemin (infolist), comme dans Filament.
48
+ const statePath = this.container.getStatePath();
49
+ const own = (this._keyName ?? this._name)?.replace('::data::', statePath ? `::${statePath}::` : '::') ?? null;
50
+ return own === null ? null : `${this.container.getKey()}.${own}`;
51
+ }
52
+ getLivewireKey() { const k = this.getKey(); return k === null ? null : `${this.livewire.id}.${k}`; }
53
+ getStatePath() { return this.hasStatePath() ? [this.container.getStatePath(), this._name].filter(Boolean).join('.') : ''; }
54
+ getAlpineScopeConfiguration() {
55
+ // Un composant sans état propre (mise en page) expose le chemin d'état de son conteneur.
56
+ return { key: this.getKey(), exposedMethods: this.getExposedMethods(), path: this.hasStatePath() ? this.getStatePath() : this.container.getStatePath(), containerPath: this.container.getStatePath() };
57
+ }
58
+ context() {
59
+ const livewire = this.livewire;
60
+ const read = (path) => path.split('.').reduce((o, k) => o?.[k], livewire);
61
+ return { livewire, component: this, operation: this.container.getOperation(), record: this.container.getRecord(), get: (p) => read([this.container.getStatePath(), p].filter(Boolean).join('.')), state: this.hasStatePath() ? read(this.getStatePath()) : undefined };
62
+ }
63
+ async isVisible() {
64
+ const ctx = this.context();
65
+ return !(await evaluate(this._hidden, ctx)) && (await evaluate(this._visible, ctx));
66
+ }
67
+ /** Port de Component::toSchemaHtml : enveloppe de colonne de grille, rendue même si le composant est masqué. */
68
+ async toSchemaHtml(isVisible) {
69
+ const key = this.getKey();
70
+ const span = {};
71
+ const rawSpan = typeof this._columnSpan === 'object' ? this._columnSpan : { default: 1, lg: this._columnSpan };
72
+ for (const [breakpoint, value] of Object.entries(rawSpan))
73
+ span[breakpoint] = await evaluate(value, this.context());
74
+ const attributes = new Attributes()
75
+ .when(!this.container.isInline(), (a) => a.gridColumn(span, !isVisible))
76
+ .when(!this.container.isInline() && this._columnStart !== null, (a) => a.class(['lg:fi-grid-col-start']).style([`--col-start-lg: ${this._columnStart}`]))
77
+ .merge({ 'wire:key': this.getLivewireKey() });
78
+ return views.render('filament-schemas::component', {
79
+ key, isVisible, attributes: attributes.toHtml(),
80
+ alpine: isVisible ? `filamentSchemaComponent(${js(this.getAlpineScopeConfiguration())})` : null,
81
+ html: isVisible ? await this.toHtml() : '',
82
+ });
83
+ }
84
+ }
85
+ export class Schema {
86
+ livewire;
87
+ _key = 'schema';
88
+ _components = [];
89
+ _statePath = null;
90
+ _columns = {};
91
+ _root = true;
92
+ _inline = false;
93
+ _operation = null;
94
+ _record = null;
95
+ _extraClass = null;
96
+ _partial = false;
97
+ _dense = false;
98
+ constructor(livewire) { this.livewire = livewire; }
99
+ static make(livewire) { return configured(new Schema(livewire)); }
100
+ key(key) { this._key = key; return this; }
101
+ components(components) { this._components = components; for (const c of components)
102
+ c.container = this; return this; }
103
+ schema(components) { return this.components(components); }
104
+ statePath(path) { this._statePath = path; return this; }
105
+ columns(columns) { this._columns = columns; return this; }
106
+ root(isRoot = true) { this._root = isRoot; return this; }
107
+ inline(isInline = true) { this._inline = isInline; return this; }
108
+ operation(operation) { this._operation = operation; return this; }
109
+ record(record) { this._record = record; return this; }
110
+ /** Classe placée avant celles de la grille (ex. `fi-section-content`). */
111
+ extraClass(name) { this._extraClass = name; return this; }
112
+ /** Espacement resserré entre composants (`fi-sc-dense`). */
113
+ dense(condition = true) { this._dense = condition; return this; }
114
+ /** Schéma rendu partiellement : sa racine porte `wire:partial="schema.<clé>"`. */
115
+ partiallyRendered(condition = true) { this._partial = condition; return this; }
116
+ _partialName = null;
117
+ /** Nom du fragment `wire:partial` de la racine, quand il ne suit pas la clé du schéma (formulaire de filtres : `table-filters-form`). */
118
+ partialName(name) { this._partialName = name; return this; }
119
+ _gap = true;
120
+ /** `false` : composants accolés, sans espacement (`fi-sc-has-gap` absent), comme dans un FusedGroup. */
121
+ gap(condition = true) { this._gap = condition; return this; }
122
+ _fieldWrapperView = null;
123
+ /**
124
+ * Enveloppe des champs de ce schéma : `'filament-forms::plain-field-wrapper'` ne garde qu'un libellé réservé aux lecteurs d'écran
125
+ * (champs d'un FusedGroup). `null` : l'enveloppe habituelle.
126
+ */
127
+ fieldWrapperView(view) { this._fieldWrapperView = view; return this; }
128
+ getFieldWrapperView() { return this._fieldWrapperView; }
129
+ getKey() { return this._key; }
130
+ getStatePath() { return this._statePath ?? ''; }
131
+ getComponents() { return this._components; }
132
+ getOperation() { return this._operation; }
133
+ getRecord() { return this._record; }
134
+ isInline() { return this._inline; }
135
+ isRoot() { return this._root; }
136
+ /** Tous les champs à état, en profondeur. */
137
+ getFlatComponents() {
138
+ return this._components.flatMap((c) => [c, ...(c.getChildSchemas?.() ?? []).flatMap((s) => s.getFlatComponents())]);
139
+ }
140
+ /** Les mêmes, sans les composants masqués ni leur descendance : un champ placé dans une section masquée n'est ni validé ni enregistré, comme dans Filament. */
141
+ async getVisibleFlatComponents() {
142
+ const out = [];
143
+ for (const c of this._components) {
144
+ if (!(await c.isVisible()))
145
+ continue;
146
+ out.push(c);
147
+ for (const s of (c.getChildSchemas?.() ?? []))
148
+ out.push(...(await s.getVisibleFlatComponents()));
149
+ }
150
+ return out;
151
+ }
152
+ /** Port de Schema::renderEmbeddedHtml. */
153
+ async toHtml() {
154
+ const withVisibility = await Promise.all(this._components.map(async (c) => [c, await c.isVisible()]));
155
+ if (!withVisibility.some(([, v]) => v))
156
+ return '';
157
+ const attributes = new Attributes(this._extraClass ? { class: this._extraClass } : {})
158
+ .when(!this._inline, (a) => a.grid(this._columns))
159
+ .merge({
160
+ 'wire:partial': this._partialName ?? (this._partial ? `schema.${this._key}` : null),
161
+ 'x-data': this._root ? `filamentSchema({ livewireId: ${js(this.livewire.id)}, schemaKey: ${js(this._key)} })` : null,
162
+ 'x-on:form-validation-error.window': this._root ? 'handleFormValidationError' : null,
163
+ 'x-on:reset-schema-component-state.window': this._root ? 'handleClientSideStateReset' : null,
164
+ })
165
+ .class(['fi-sc', { 'fi-inline': this._inline }, { 'fi-sc-has-gap': this._gap }, { 'fi-sc-dense': this._dense }]);
166
+ const parts = [];
167
+ for (const [component, isVisible] of withVisibility) {
168
+ if (!component.isLiberatedFromContainerGrid())
169
+ parts.push(await component.toSchemaHtml(isVisible));
170
+ else if (isVisible)
171
+ parts.push(`<div class="fi-sc-liberated" wire:key="${e(component.getLivewireKey() ?? '')}" x-data="filamentSchemaComponent(${js(component.getAlpineScopeConfiguration())})">${await component.toHtml()}</div>`);
172
+ }
173
+ return `<div ${attributes.toHtml()}>${parts.join('')}</div>`;
174
+ }
175
+ }
176
+ /** Schéma enfant d'un composant de mise en page : partage la clé et le chemin d'état du parent, sans être racine. */
177
+ export function childSchema(parent, components) {
178
+ const c = parent.container;
179
+ return new Schema(c.livewire).key(c.getKey()).statePath(c.getStatePath() || null).operation(c.getOperation()).record(c.getRecord()).root(false).components(components);
180
+ }
181
+ /** Groupe à état : ses enfants vivent sous `<chemin du conteneur>.<nom>` (utilisé par les formulaires de filtres). */
182
+ export class StatefulGroup extends Component {
183
+ children;
184
+ constructor(name, children) { super(); this._name = name; this.children = children; }
185
+ static make(name, children) { return configured(new StatefulGroup(name, children)); }
186
+ hasStatePath() { return true; }
187
+ getChildSchemas() {
188
+ const c = this.container;
189
+ return [new Schema(c.livewire).key(this.getKey()).statePath(this.getStatePath()).operation(c.getOperation()).record(c.getRecord()).root(false).components(this.children)];
190
+ }
191
+ async toHtml() { return `<div id="${e(this.getKey())}">${await this.getChildSchemas()[0].toHtml()}</div>`; }
192
+ }
193
+ /** Regroupe des composants dans une grille, sans habillage (port de Group). */
194
+ export class Group extends Component {
195
+ children;
196
+ _columns = {};
197
+ constructor(children = []) { super(); this.children = children; }
198
+ static make(children = []) { return configured(new Group(children)); }
199
+ schema(components) { this.children = components; return this; }
200
+ columns(columns) { this._columns = columns; return this; }
201
+ getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
202
+ async toHtml() { return `<div>${await this.getChildSchemas()[0].toHtml()}</div>`; }
203
+ }
204
+ /** Grille de colonnes (port de Grid) : deux colonnes par défaut à partir de `lg`. */
205
+ export class Grid extends Group {
206
+ constructor(columns = 2) { super(); this._columns = columns; }
207
+ static make(columns = 2) { return configured(new Grid(Array.isArray(columns) ? 2 : columns)); }
208
+ }
209
+ /**
210
+ * Composants côte à côte à partir d'un point de rupture (port de Flex) : chacun occupe la place disponible,
211
+ * sauf ceux marqués `grow(false)`, qui gardent leur largeur naturelle.
212
+ */
213
+ export class Flex extends Component {
214
+ children;
215
+ _from = 'sm';
216
+ constructor(children = []) { super(); this.children = children; }
217
+ static make(children = []) { return configured(new Flex(children)); }
218
+ schema(components) { this.children = components; return this; }
219
+ from(breakpoint) { this._from = breakpoint; return this; }
220
+ getChildSchemas() { return [childSchema(this, this.children)]; }
221
+ async toHtml() {
222
+ const parts = [];
223
+ for (const component of this.getChildSchemas()[0].getComponents()) {
224
+ if (!(await component.isVisible()))
225
+ continue;
226
+ parts.push(`<div wire:key="${e(component.getLivewireKey() ?? '')}" x-data="filamentSchemaComponent(${js(component.getAlpineScopeConfiguration())})"${component.canGrow() ? ' class="fi-growable"' : ''}>${await component.toHtml()}</div>`);
227
+ }
228
+ return `<div class="fi-sc-flex fi-from-${this._from}">${parts.join('')}</div>`;
229
+ }
230
+ }
231
+ export const slug = (text) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
232
+ /** Section : carte regroupant des composants, avec un en-tête facultatif (titre, actions). Sans repli ni icône pour l'instant. */
233
+ export class Section extends Component {
234
+ children = [];
235
+ _columns = {};
236
+ _heading;
237
+ _afterHeader = [];
238
+ constructor(heading = null) {
239
+ super();
240
+ this._heading = heading;
241
+ // Comme Filament : une section titrée a une clé dérivée de son titre.
242
+ if (heading !== null)
243
+ this._keyName = `${slug(heading)}::data::section`;
244
+ }
245
+ static make(heading = null) { return configured(new Section(heading)); }
246
+ schema(components) { this.children = components; return this; }
247
+ columns(columns) { this._columns = columns; return this; }
248
+ _collapsible = false;
249
+ _collapsed = false;
250
+ /** Section repliable par son en-tête ; `collapsed()` la replie d'emblée. */
251
+ collapsible(condition = true) { this._collapsible = condition; return this; }
252
+ collapsed(condition = true) { this._collapsed = condition; if (condition)
253
+ this._collapsible = true; return this; }
254
+ /** Actions affichées à droite du titre. */
255
+ afterHeader(actions) { this._afterHeader = actions; return this; }
256
+ /** Actions que la section expose au montage. */
257
+ getActions() { return this._afterHeader; }
258
+ getChildSchemas() { return [childSchema(this, this.children).columns(this._columns).extraClass('fi-section-content')]; }
259
+ async toHtml() {
260
+ const [content] = this.getChildSchemas();
261
+ if (this._heading === null) {
262
+ return `<div class="fi-sc-section"><section x-data="{
263
+ isCollapsed: false,
264
+ }" class="fi-section"><div class="fi-section-content-ctn">${await content.toHtml()}</div></section></div>`;
265
+ }
266
+ const key = this.getKey();
267
+ const ctx = this.context();
268
+ const after = [];
269
+ // Contexte de montage : celui de l'hôte, plus le schéma auquel la section appartient (comme Filament).
270
+ const mountContext = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.container.getKey() };
271
+ for (const action of this._afterHeader)
272
+ if (await action.isVisible(ctx))
273
+ after.push(`<div class="fi-sc-action">${await action.toHtml(ctx, mountContext)}</div>`);
274
+ // Section repliable : événements `collapse-section` / `expand-section`… et bouton dans l'en-tête (port de filament::components.section).
275
+ const idTest = `if ($event.detail.id == (${js(key)} ?? $el.id))`;
276
+ const collapse = this._collapsible ? ` x-on:collapse-section.window="${e(`${idTest} isCollapsed = true`)}" x-on:expand="isCollapsed = false" x-on:expand-section.window="${e(`${idTest} isCollapsed = false`)}" x-on:open-section.window="${e(`${idTest} isCollapsed = false`)}" x-on:toggle-section.window="${e(`${idTest} isCollapsed = ! isCollapsed`)}" x-on:reset-schema-component-state.window="${e(`if (($event.detail.livewireId === ${js(this.livewire.id)}) && ($event.detail.schemaKey === ${js(key.split('.')[0])})) $nextTick(() => isCollapsed = ${this._collapsed ? 'true' : 'false'})`)}" x-bind:class="isCollapsed &amp;&amp; 'fi-collapsed'"` : '';
277
+ const collapseButton = this._collapsible ? `<button type="button" x-on:click.stop="isCollapsed = ! isCollapsed" aria-label="${e(t(this._collapsed ? 'filament::components/section.actions.expand.label' : 'filament::components/section.actions.collapse.label'))}" x-bind:aria-label="isCollapsed ? ${e(js(t('filament::components/section.actions.expand.label')))} : ${e(js(t('filament::components/section.actions.collapse.label')))}" aria-expanded="${this._collapsed ? 'false' : 'true'}" x-bind:aria-expanded="(! isCollapsed).toString()" aria-controls="${e(key)}-content" class="fi-icon-btn fi-size-md fi-section-collapse-btn">${icon('m-chevron-up')}</button>` : '';
278
+ return `<div id="${e(key)}" class="fi-sc-section"><section x-data="{
279
+ isCollapsed: ${this._collapsed ? 'true' : 'false'},
280
+ }"${collapse} aria-labelledby="${e(key)}-heading" class="${cls(['fi-section', 'fi-section-has-header', { 'fi-collapsible': this._collapsible, 'fi-collapsed': this._collapsed }])}"><header${this._collapsible ? ` x-on:click="if (! $event.target.closest('.fi-section-header-after-ctn')) isCollapsed = ! isCollapsed"` : ''} class="fi-section-header"><div class="fi-section-header-content fi-section-header-content-has-heading"><div class="fi-section-header-text-ctn"><h2 id="${e(key)}-heading" class="fi-section-header-heading">${e(this._heading)}</h2></div>${after.length ? `<div class="fi-section-header-after-ctn"><div class="fi-sc fi-inline fi-align-end fi-sc-has-gap fi-sc-dense">${after.join('')}</div></div>` : ''}</div>${collapseButton}</header><div id="${e(key)}-content" class="fi-section-content-ctn">${await content.toHtml()}</div></section></div>`;
281
+ }
282
+ }
283
+ /**
284
+ * Balisage libre dans un schéma. Le contenu est inséré TEL QUEL : n'y placez jamais une valeur venue d'un utilisateur ou de la base
285
+ * sans l'échapper avec `e()` de `@navette-js/support`.
286
+ */
287
+ export class Html extends Component {
288
+ content;
289
+ constructor(content) { super(); this.content = content; }
290
+ static make(content) { return new Html(content); }
291
+ async toHtml() { return typeof this.content === 'function' ? this.content(this.context()) : this.content; }
292
+ }
293
+ /** Un onglet d'un composant Tabs : un titre, une icône facultative et son propre schéma (port de Tabs\Tab). */
294
+ export class Tab extends Component {
295
+ children = [];
296
+ _columns = {};
297
+ _icon = null;
298
+ label;
299
+ constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::tab`; }
300
+ static make(label) { return configured(new Tab(label)); }
301
+ schema(components) { this.children = components; return this; }
302
+ columns(columns) { this._columns = columns; return this; }
303
+ icon(name) { this._icon = name; return this; }
304
+ getLabel() { return this.label; }
305
+ getIcon() { return this._icon; }
306
+ /** Clé de l'onglet côté navigateur (sans le préfixe du schéma). */
307
+ /** Clé de l'onglet dans son groupe : `::data::` suit le chemin d'état du schéma, absent d'un infolist (comme dans Filament). */
308
+ getTabKey() { const statePath = this.container.getStatePath(); return this._keyName.replace('::data::', statePath ? `::${statePath}::` : '::'); }
309
+ getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
310
+ async toHtml() { return this.getChildSchemas()[0].toHtml(); }
311
+ }
312
+ /** Onglets de mise en page : tous les panneaux sont rendus, le navigateur n'affiche que l'onglet actif (port de Tabs). */
313
+ export class Tabs extends Component {
314
+ tabs = [];
315
+ label;
316
+ constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::tabs`; }
317
+ static make(label) { return configured(new Tabs(label)); }
318
+ schema(tabs) { this.tabs = tabs; return this; }
319
+ getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
320
+ getLazyLoadedTabBadges() { return {}; }
321
+ getDeferredTabBadges() { return {}; }
322
+ getChildSchemas() { return [childSchema(this, this.tabs)]; }
323
+ async toHtml() {
324
+ // Le schéma enfant rattache les onglets à leur conteneur : c'est lui qui donne leurs clés.
325
+ this.getChildSchemas();
326
+ const key = this.getKey();
327
+ const id = this.livewire.id;
328
+ const q = (k) => `'${k.replace(/[^A-Za-z0-9:_-]/g, '')}'`;
329
+ const tabId = (tab) => `fi-sc-tab-${createHash('md5').update(tab.getKey()).digest('hex')}`;
330
+ const visible = [];
331
+ for (const tab of this.tabs)
332
+ if (await tab.isVisible())
333
+ visible.push(tab);
334
+ const buttons = visible.map((tab) => {
335
+ const k = tab.getTabKey();
336
+ return `<button type="button" x-bind:class="{
337
+ 'fi-active': isTabSelected(${q(k)}),
338
+ 'fi-sc-tabs-header-overflow': overflowTabs.includes(${q(k)}),
339
+ 'fi-sc-tabs-header-overflow-open': isOverflowOpen &amp;&amp; overflowTabs.includes(${q(k)}),
340
+ }" id="${tabId(tab)}" role="tab" aria-controls="${e(tab.getKey())}" tabindex="-1" x-bind:tabindex="getTabIndex(${q(k)})" data-tab-key="${e(k)}" x-bind:data-tab-available="Boolean(true).toString()" x-bind:aria-selected="isTabSelected(${q(k)})" x-on:click="selectTab(${q(k)}, $event)" x-show="true" class="fi-tabs-item">${tab.getIcon() ? icon(tab.getIcon()) : ''}<span class="fi-tabs-item-label">${e(tab.getLabel())}</span></button>`;
341
+ });
342
+ const panels = [];
343
+ for (const tab of visible) {
344
+ const k = tab.getTabKey();
345
+ panels.push(`<div aria-labelledby="${tabId(tab)}" id="${e(tab.getKey())}" role="tabpanel" tabindex="0" wire:key="${e(`${id}.${tab.getKey()}`)}.container" x-show="true" x-bind:class="{ 'fi-active': isTabSelected(${q(k)}) }" x-on:expand="revealTab(${q(k)})" x-data="filamentSchemaComponent(${e(js(tab.getAlpineScopeConfiguration()))})" class="fi-sc-tabs-tab">${await tab.toHtml()}</div>`);
346
+ }
347
+ return `<div x-data="tabsSchemaComponent({
348
+ activeTab: 1,
349
+ hasTabPanels: true,
350
+ isScrollable: true,
351
+ isVertical: false,
352
+ isTabPersisted: false,
353
+ isTabPersistedInQueryString: false,
354
+ livewireId: ${e(js(id))},
355
+ schemaKey: ${e(js(key.split('.')[0]))},
356
+ tab: null,
357
+ tabQueryStringKey: null,
358
+ })" x-load x-load-src="${e(views.render('filament-schemas::asset', { path: 'tabs' }).trim())}" wire:ignore.self id="${e(key)}" wire:key="${e(`${id}.${key}`)}.container" class="fi-sc-tabs fi-contained"><div x-cloak="x-cloak" x-ref="tabsHeader" class="fi-tabs fi-contained"><div x-ref="tablist" role="tablist" x-bind:role="hasVisibleTabHeaders ? 'tablist' : 'group'" aria-label="${e(this.label)}" x-on:keydown="handleKeydown($event)" class="fi-sc-tabs-tablist">${buttons.join('')}</div></div>${panels.join('')}</div>`;
359
+ }
360
+ }
361
+ /** Point d'injection d'un render hook, avec les portées du composant hôte. Libéré de la grille du conteneur. */
362
+ export class RenderHook extends Component {
363
+ hook;
364
+ constructor(hook) { super(); this.hook = hook; }
365
+ static make(hook) { return configured(new RenderHook(hook)); }
366
+ isLiberatedFromContainerGrid() { return true; }
367
+ async toHtml() { return renderHook(this.hook, this.livewire.getRenderHookScopes?.() ?? []); }
368
+ }
369
+ /** Intègre un autre schéma du composant hôte (`form`, `infolist`…) comme schéma racine. */
370
+ export class EmbeddedSchema extends Component {
371
+ schemaName;
372
+ constructor(name) { super(); this.schemaName = name; }
373
+ static make(name) { return configured(new EmbeddedSchema(name)); }
374
+ async toHtml() { return (await this.livewire.getSchema(this.schemaName)).toHtml(); }
375
+ }
376
+ /** Balise <form> : contenu, pied (actions), soumission vers une méthode du composant hôte. */
377
+ export class Form extends Component {
378
+ children;
379
+ _footer = [];
380
+ _id = null;
381
+ _submit = null;
382
+ constructor(children) { super(); this.children = children; }
383
+ static make(children = []) { return configured(new Form(children)); }
384
+ id(id) { this._id = id; return this; }
385
+ livewireSubmitHandler(method) { this._submit = method; return this; }
386
+ footer(components) { this._footer = components; return this; }
387
+ getChildSchemas() { return [childSchema(this, this.children), childSchema(this, this._footer)]; }
388
+ async toHtml() {
389
+ const attributes = new Attributes().merge({ id: this._id, 'wire:submit': this._submit }).class(['fi-sc-form']);
390
+ const [content, footer] = this.getChildSchemas();
391
+ return `<form ${attributes.toHtml()}>${await content.toHtml()}${await footer.toHtml()}</form>`;
392
+ }
393
+ }
394
+ /** Rangée d'actions d'un schéma. */
395
+ export class Actions extends Component {
396
+ actions;
397
+ _fullWidth = false;
398
+ _alignment = null;
399
+ constructor(actions) { super(); this.actions = actions; }
400
+ static make(actions) { return configured(new Actions(actions)); }
401
+ fullWidth(condition = true) { this._fullWidth = condition; return this; }
402
+ alignment(alignment) { this._alignment = alignment; return this; }
403
+ async toHtml() {
404
+ const ctx = this.context();
405
+ const visible = [];
406
+ for (const a of this.actions)
407
+ if (await a.isVisible(ctx))
408
+ visible.push(await a.toHtml(ctx));
409
+ if (!visible.length)
410
+ return '';
411
+ const inner = cls(['fi-ac', { 'fi-width-full': this._fullWidth }, this._alignment ? `fi-align-${this._alignment}` : null]);
412
+ return `<div ${new Attributes().merge({ id: this.getKey() }).class(['fi-sc-actions']).toHtml()}><div class="${inner}">${visible.join('')}</div></div>`;
413
+ }
414
+ }
415
+ // ------------------------------------------------------------------ couleurs, infobulles et rangées « en ligne »
416
+ let colorManager = null;
417
+ /** Gestionnaire de couleurs utilisé hors d'un panel ; dans un panel, celui du panel de l'hôte a la priorité. */
418
+ export const useColorManager = (manager) => { colorManager = manager; };
419
+ const colorsFor = (component) => component.livewire?.panel?.colors ?? colorManager;
420
+ /** Couleur sans nuance calculée (icône, callout, état vide) : rien pour le gris, comme les composants `HasDefaultGrayColor` de Filament. */
421
+ const plainColorClasses = (color) => (color && color !== 'gray' ? ['fi-color', `fi-color-${color.replace(/[^a-zA-Z0-9_-]/g, '')}`] : []);
422
+ /** Classe de taille ou de graisse : seuls les caractères d'un nom de classe sont gardés. */
423
+ const safeClass = (value) => (value ? value.replace(/[^a-zA-Z0-9_:-]/g, '') || null : null);
424
+ const tooltipExpression = (text) => (text ? `{ content: ${js(text)}, theme: $store.theme, allowHTML: false }` : null);
425
+ const isBlank = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (value instanceof HtmlString && value.html.trim() === '');
426
+ /** Texte échappé, ou HTML d'une `HtmlString` inséré tel quel (à ne construire qu'avec des valeurs échappées). */
427
+ const textOrHtml = (value) => (value instanceof HtmlString ? value.html : e(value));
428
+ /**
429
+ * Rangée « en ligne » (port d'un schéma enfant `inline()` de Filament : pied, contrôles, après le libellé) où composants et actions se mêlent.
430
+ * Les actions sont montées avec `schemaComponent: mountKey` (par défaut le schéma racine, qui les retrouve par `getActions()` du composant parent).
431
+ * `actionView` : `'button'` (bouton plein), `'link'` (lien, ou bouton-icône pour une action `iconButton()`), `'auto'` (bouton-icône si `iconButton()`, bouton sinon).
432
+ * `scope` remplace des clés de la configuration Alpine des composants (`path`, `containerPath`).
433
+ */
434
+ export async function renderInlineItems(parent, items, options = {}) {
435
+ const components = items.filter((item) => item instanceof Component);
436
+ if (components.length)
437
+ childSchema(parent, components).inline();
438
+ const ctx = parent.context();
439
+ const mountContext = { ...(parent.livewire.getActionMountContext?.() ?? {}), schemaComponent: options.mountKey ?? parent.container.getKey() };
440
+ const parts = [];
441
+ for (const item of items) {
442
+ if (item instanceof Component) {
443
+ if (!(await item.isVisible()))
444
+ continue;
445
+ const key = item.getKey();
446
+ const attributes = new Attributes().merge({
447
+ 'wire:partial': key !== null ? `schema-component::${key}` : null,
448
+ 'x-data': `filamentSchemaComponent(${js({ ...item.getAlpineScopeConfiguration(), ...(options.scope ?? {}) })})`,
449
+ 'wire:key': item.getLivewireKey(),
450
+ });
451
+ parts.push(`<div ${attributes.toHtml()}><div class="fi-sc-component">${await item.toHtml()}</div></div>`);
452
+ continue;
453
+ }
454
+ if (!(await item.isVisible(ctx)))
455
+ continue;
456
+ const action = item;
457
+ const view = options.actionView ?? 'button';
458
+ const asLink = typeof action.toLinkHtml === 'function' && (view === 'link' || (view === 'auto' && action._iconButton === true));
459
+ // Le rendu « lien » des actions sert aussi aux lignes de table : sa clé de ligne, vide ici, est retirée.
460
+ const html = asLink ? String(await action.toLinkHtml(ctx, mountContext, '')).replace(' wire:key=""', '') : await action.toHtml(ctx, mountContext);
461
+ parts.push(`<div class="fi-sc-action">${html}</div>`);
462
+ }
463
+ if (!parts.length)
464
+ return '';
465
+ return `<div class="${cls(['fi-sc', 'fi-inline', options.alignment ? `fi-align-${options.alignment}` : null, 'fi-sc-has-gap', 'fi-sc-dense'])}">${parts.join('')}</div>`;
466
+ }
467
+ /** Actions d'une liste d'éléments en ligne (à exposer par `getActions()` pour qu'elles puissent être montées). */
468
+ const actionsOf = (items) => items.filter((item) => !(item instanceof Component));
469
+ /** Grille « en colonnes » (port de `grid($columns, GridDirection::Column)`) : les éléments se remplissent colonne par colonne. */
470
+ function columnGrid(columns) {
471
+ const cols = typeof columns === 'number' ? { lg: columns } : { ...(columns ?? {}) };
472
+ for (const key of Object.keys(cols))
473
+ if (!cols[key])
474
+ delete cols[key];
475
+ cols.default ??= 1;
476
+ const name = (breakpoint) => breakpoint.replace(/@/g, 'c').replace(/!/g, 'n');
477
+ return new Attributes()
478
+ .class(['fi-grid', 'fi-grid-direction-col', ...Object.keys(cols).map((breakpoint) => (breakpoint === 'default' ? (cols[breakpoint] > 1 ? 'fi-grid-cols' : '') : `${breakpoint}:fi-grid-cols`))])
479
+ .style(Object.entries(cols).map(([breakpoint, count]) => `--cols-${name(breakpoint)}: ${count}`));
480
+ }
481
+ /**
482
+ * Cadre titré regroupant des champs (port de Fieldset) : une légende et une grille de deux colonnes par défaut.
483
+ * Avec `statePath()`, les champs vivent sous ce chemin et le fieldset reçoit une clé (et un `id`).
484
+ */
485
+ export class Fieldset extends Component {
486
+ children = [];
487
+ _columns = 2;
488
+ _label;
489
+ _hiddenLabel = false;
490
+ _contained = true;
491
+ _markedAsRequired = false;
492
+ constructor(label = null) { super(); this._label = label; }
493
+ static make(label = null) { return configured(new Fieldset(label)); }
494
+ schema(components) { this.children = components; return this; }
495
+ columns(columns) { this._columns = columns; return this; }
496
+ label(label) { this._label = label; return this; }
497
+ /** Légende gardée pour les lecteurs d'écran seulement. */
498
+ hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
499
+ /** `false` : sans bordure ni marge intérieure. */
500
+ contained(condition = true) { this._contained = condition; return this; }
501
+ /** Astérisque après la légende (le fieldset lui-même n'est jamais validé). */
502
+ markAsRequired(condition = true) { this._markedAsRequired = condition; return this; }
503
+ /** Chemin d'état des champs, relatif au conteneur (port de `statePath()`). */
504
+ statePath(path) { this._name = path; return this; }
505
+ hasStatePath() { return this._name !== null; }
506
+ getChildSchemas() {
507
+ const schema = childSchema(this, this.children).columns(this._columns);
508
+ if (this.hasStatePath())
509
+ schema.key(this.getKey()).statePath(this.getStatePath());
510
+ return [schema];
511
+ }
512
+ async toHtml() {
513
+ const label = await evaluate(this._label, this.context());
514
+ const attributes = new Attributes().merge({ id: this.getKey(), ...this._extraAttributes })
515
+ .class(['fi-sc-fieldset', 'fi-fieldset', { 'fi-fieldset-label-hidden': this._hiddenLabel, 'fi-fieldset-not-contained': !this._contained }]);
516
+ const legend = isBlank(label) ? '' : `<legend>${e(label)}${this._markedAsRequired ? '<sup class="fi-fieldset-label-required-mark">*</sup>' : ''}</legend>`;
517
+ return `<fieldset ${attributes.toHtml()}>${legend}${await this.getChildSchemas()[0].toHtml()}</fieldset>`;
518
+ }
519
+ }
520
+ /**
521
+ * Texte dans un schéma (port de Text) : simple `<span>`, ou pastille avec `badge()`. Le contenu est échappé ;
522
+ * une `HtmlString` (de `@navette-js/support`) est insérée telle quelle, à ne construire qu'avec des valeurs échappées.
523
+ */
524
+ export class Text extends Component {
525
+ _content;
526
+ _badge = false;
527
+ _color = null;
528
+ _size = null;
529
+ _weight = null;
530
+ _fontFamily = null;
531
+ _icon = null;
532
+ _iconPosition = 'before';
533
+ _iconSize = null;
534
+ _tooltip = null;
535
+ _copyable = false;
536
+ _copyableState = null;
537
+ _copyMessage = null;
538
+ _copyMessageDuration = 2000;
539
+ constructor(content) { super(); this._content = content; }
540
+ static make(content) { return configured(new Text(content)); }
541
+ content(content) { this._content = content; return this; }
542
+ /** Affiché en pastille. */
543
+ badge(condition = true) { this._badge = condition; return this; }
544
+ /** Couleur (`gray` par défaut ; `primary`, `danger`…). */
545
+ color(color) { this._color = color; return this; }
546
+ /** Taille du texte (`xs`, `sm`, `md`, `lg`) ou de la pastille. */
547
+ size(size) { this._size = size; return this; }
548
+ /** Graisse (`bold`, `semibold`, `medium`…). */
549
+ weight(weight) { this._weight = weight; return this; }
550
+ /** Famille de police (`sans`, `serif`, `mono`). */
551
+ fontFamily(family) { this._fontFamily = family; return this; }
552
+ /** Icône d'une pastille. */
553
+ icon(name) { this._icon = name; return this; }
554
+ iconPosition(position) { this._iconPosition = position; return this; }
555
+ iconSize(size) { this._iconSize = size; return this; }
556
+ tooltip(text) { this._tooltip = text; return this; }
557
+ /** Un clic copie le texte (ou `copyableState()`) dans le presse-papiers. */
558
+ copyable(condition = true) { this._copyable = condition; return this; }
559
+ copyableState(state) { this._copyableState = state; return this; }
560
+ copyMessage(message) { this._copyMessage = message; return this; }
561
+ copyMessageDuration(milliseconds) { this._copyMessageDuration = milliseconds; return this; }
562
+ async getContent() { return evaluate(this._content, this.context()); }
563
+ async toHtml() {
564
+ const ctx = this.context();
565
+ const content = await this.getContent();
566
+ const color = (await evaluate(this._color, ctx)) ?? 'gray';
567
+ const tooltip = tooltipExpression(await evaluate(this._tooltip, ctx));
568
+ const isCopyable = Boolean(await evaluate(this._copyable, ctx));
569
+ const copyableState = (await evaluate(this._copyableState, ctx)) ?? (content instanceof HtmlString ? content.html : content);
570
+ const copy = `window.navigator.clipboard.writeText(${js(copyableState)})\n$tooltip(${js(this._copyMessage ?? t('filament::components/copyable.messages.copied'))}, {\n theme: $store.theme,\n timeout: ${js(this._copyMessageDuration)},\n})`;
571
+ if (await evaluate(this._badge, ctx)) {
572
+ const tag = isCopyable ? 'button' : 'span';
573
+ const attributes = new Attributes().merge({ 'x-on:click': isCopyable ? copy : null, 'x-tooltip': tooltip, ...this._extraAttributes, type: isCopyable ? 'button' : null })
574
+ .class(['fi-sc-text', 'fi-badge', `fi-size-${safeClass(this._size) ?? 'md'}`, ...(color !== 'gray' ? (colorsFor(this)?.getComponentClasses('badge', color) ?? plainColorClasses(color)) : [])]);
575
+ const iconHtml = this._icon ? icon(this._icon, { size: safeClass(this._iconSize) ?? 'sm' }) : '';
576
+ return `<${tag} ${attributes.toHtml()}>${this._iconPosition === 'before' ? iconHtml : ''}<span class="fi-badge-label-ctn"><span class="fi-badge-label">${textOrHtml(content)}</span></span>${this._iconPosition === 'after' ? iconHtml : ''}</${tag}>`;
577
+ }
578
+ // Couleur du texte : même recherche de contraste que celle d'un lien (TextComponent de Filament).
579
+ const colorClasses = color !== 'gray' ? (colorsFor(this)?.getComponentClasses('link', color) ?? plainColorClasses(color)) : [];
580
+ const attributes = new Attributes().merge({
581
+ role: isCopyable ? 'button' : null, tabindex: isCopyable ? '0' : null, 'x-on:click': isCopyable ? copy : null,
582
+ 'x-on:keydown.enter.prevent': isCopyable ? '$el.click()' : null, 'x-on:keydown.space.prevent': isCopyable ? '$el.click()' : null, 'x-tooltip': tooltip,
583
+ }).class([...colorClasses, 'fi-sc-text', { 'fi-copyable': isCopyable }, this._size ? `fi-size-${safeClass(this._size)}` : null, this._weight ? `fi-font-${safeClass(this._weight)}` : null, this._fontFamily ? `fi-font-${safeClass(this._fontFamily)}` : null])
584
+ .merge(this._extraAttributes);
585
+ return `<span ${attributes.toHtml()}>${textOrHtml(content)}</span>`;
586
+ }
587
+ }
588
+ /** Icône Heroicons dans un schéma (port de Icon) : décorative, sauf si une infobulle ou un `aria-label` (attributs supplémentaires) la décrit. */
589
+ export class Icon extends Component {
590
+ _icon;
591
+ _color = null;
592
+ _size = null;
593
+ _tooltip = null;
594
+ constructor(name) { super(); this._icon = name; }
595
+ static make(name) { return configured(new Icon(name)); }
596
+ icon(name) { this._icon = name; return this; }
597
+ /** Couleur (`primary` par défaut). */
598
+ color(color) { this._color = color; return this; }
599
+ /** Taille (`xs` … `2xl`) ; `base` revient à la taille par défaut. */
600
+ size(size) { this._size = size === 'base' ? null : size; return this; }
601
+ tooltip(text) { this._tooltip = text; return this; }
602
+ async toHtml() {
603
+ const ctx = this.context();
604
+ const name = await evaluate(this._icon, ctx);
605
+ if (!name)
606
+ return '';
607
+ const tooltip = await evaluate(this._tooltip, ctx);
608
+ const { 'aria-label': ariaLabel = null, 'aria-labelledby': ariaLabelledBy = null, 'aria-describedby': ariaDescribedBy = null, ...extra } = this._extraAttributes;
609
+ const attributes = new Attributes({ 'x-tooltip': tooltipExpression(tooltip) }).merge({ ...extra, 'aria-hidden': 'true' })
610
+ .class([...plainColorClasses((await evaluate(this._color, ctx)) ?? 'primary'), 'fi-sc-icon']);
611
+ const html = icon(name, { size: safeClass(this._size) ?? 'md', attributes });
612
+ const label = ariaLabel ?? (ariaLabelledBy ? null : tooltip);
613
+ if (!label && !ariaLabelledBy)
614
+ return html;
615
+ return `${html}<span ${new Attributes().merge({ 'aria-describedby': ariaDescribedBy ? e(ariaDescribedBy) : null, 'aria-label': label ? e(label) : null, 'aria-labelledby': ariaLabelledBy ? e(ariaLabelledBy) : null, role: 'img' }).class(['fi-sr-only']).toHtml()}></span>`;
616
+ }
617
+ }
618
+ /** Image dans un schéma (port de Image). Seules une adresse du site (`/…`) ou une adresse http(s) sont affichées. */
619
+ export class Image extends Component {
620
+ _url;
621
+ _alt;
622
+ _height = null;
623
+ _width = null;
624
+ _alignment = null;
625
+ _tooltip = null;
626
+ constructor(url, alt) { super(); this._url = url; this._alt = alt; }
627
+ static make(url, alt) { return configured(new Image(url, alt)); }
628
+ url(url) { this._url = url; return this; }
629
+ alt(alt) { this._alt = alt; return this; }
630
+ static dimension(value) {
631
+ if (value === null)
632
+ return null;
633
+ const css = typeof value === 'number' ? `${value}px` : value.trim();
634
+ return /^(\d+(\.\d+)?(px|rem|em|%|vh|vw)?|auto)$/.test(css) ? css : null;
635
+ }
636
+ /** Hauteur (`8rem` par défaut) ; un nombre est en pixels. */
637
+ imageHeight(height) { this._height = Image.dimension(height); return this; }
638
+ imageWidth(width) { this._width = Image.dimension(width); return this; }
639
+ imageSize(size) { return this.imageWidth(size).imageHeight(size); }
640
+ /** Alignement (`start`, `center`, `end`). */
641
+ alignment(alignment) { this._alignment = alignment; return this; }
642
+ alignStart() { return this.alignment('start'); }
643
+ alignCenter() { return this.alignment('center'); }
644
+ alignEnd() { return this.alignment('end'); }
645
+ tooltip(text) { this._tooltip = text; return this; }
646
+ async toHtml() {
647
+ const ctx = this.context();
648
+ const url = String((await evaluate(this._url, ctx)) ?? '');
649
+ if (!/^(\/(?!\/)|https?:\/\/)/i.test(url))
650
+ return '';
651
+ const tooltip = tooltipExpression(await evaluate(this._tooltip, ctx));
652
+ const attributes = new Attributes(this._extraAttributes)
653
+ .class(['fi-sc-image', this._alignment ? `fi-align-${safeClass(this._alignment)}` : null])
654
+ .style([`height: ${this._height ?? '8rem'}`, this._width ? `width: ${this._width}` : null]);
655
+ return `<img alt="${e(await evaluate(this._alt, ctx))}" src="${e(url)}"${tooltip ? ` x-tooltip="${tooltip}"` : ''} ${attributes.toHtml()} />`;
656
+ }
657
+ }
658
+ /** Liste à puces (port de UnorderedList) : chaque élément est un composant ; une chaîne devient un `Text`. Deux colonnes à partir de `sm`. */
659
+ export class UnorderedList extends Component {
660
+ children;
661
+ _columns = { sm: 2 };
662
+ _size = null;
663
+ constructor(items = []) { super(); this.children = UnorderedList.normalize(items); }
664
+ static make(items = []) { return configured(new UnorderedList(items)); }
665
+ static normalize(items) { return items.map((item) => (item instanceof Component ? item : Text.make(item))); }
666
+ schema(items) { this.children = UnorderedList.normalize(items); return this; }
667
+ columns(columns) { this._columns = columns; return this; }
668
+ /** Taille du texte (`xs`, `sm`, `md`, `lg`). */
669
+ size(size) { this._size = size; return this; }
670
+ getChildSchemas() { return [childSchema(this, this.children)]; }
671
+ async toHtml() {
672
+ const attributes = columnGrid(this._columns).merge(this._extraAttributes).class(['fi-sc-unordered-list', this._size ? `fi-size-${safeClass(this._size)}` : null]);
673
+ const items = [];
674
+ for (const component of this.getChildSchemas()[0].getComponents()) {
675
+ items.push(`<li wire:key="${e(component.getLivewireKey() ?? '')}" x-data="filamentSchemaComponent(${js(component.getAlpineScopeConfiguration())})">${await component.toHtml()}</li>`);
676
+ }
677
+ return `<ul ${attributes.toHtml()}>${items.join('')}</ul>`;
678
+ }
679
+ }
680
+ /** État vide (port de EmptyState) : icône, titre, description et un pied (actions, composants). */
681
+ export class EmptyState extends Component {
682
+ _heading;
683
+ _description = null;
684
+ _icon = null;
685
+ _iconColor = null;
686
+ _iconSize = null;
687
+ _compact = false;
688
+ _contained = true;
689
+ _headingTag = 'h2';
690
+ _footer = [];
691
+ constructor(heading) { super(); this._heading = heading; }
692
+ static make(heading) { return configured(new EmptyState(heading)); }
693
+ heading(heading) { this._heading = heading; return this; }
694
+ description(description) { this._description = description; return this; }
695
+ icon(name) { this._icon = name; return this; }
696
+ /** Couleur de l'icône (`primary` par défaut). */
697
+ iconColor(color) { this._iconColor = color; return this; }
698
+ iconSize(size) { this._iconSize = size; return this; }
699
+ /** Version resserrée. */
700
+ compact(condition = true) { this._compact = condition; return this; }
701
+ /** `false` : sans carte autour. */
702
+ contained(condition = true) { this._contained = condition; return this; }
703
+ /** Balise du titre (`h2` par défaut). */
704
+ headingTag(tag) { this._headingTag = tag; return this; }
705
+ /** Pied : actions et composants, en ligne. */
706
+ footer(items) { this._footer = items; return this; }
707
+ /** Actions du pied, montées sur le schéma racine. */
708
+ getActions() { return actionsOf(this._footer); }
709
+ async toHtml() {
710
+ const ctx = this.context();
711
+ const description = await evaluate(this._description, ctx);
712
+ const footer = await renderInlineItems(this, this._footer);
713
+ const iconColor = this._iconColor ?? 'primary';
714
+ const iconHtml = this._icon ? `<div class="${cls(['fi-empty-state-icon-bg', ...plainColorClasses(iconColor)])}">${icon(this._icon, { size: safeClass(this._iconSize) ?? 'lg', attributes: new Attributes().class(plainColorClasses(iconColor)) })}</div>` : '';
715
+ return `<div ${new Attributes(this._extraAttributes).class(['fi-sc-empty-state']).toHtml()}><section class="${cls(['fi-empty-state', { 'fi-compact': this._compact, 'fi-empty-state-not-contained': !this._contained }])}"><div class="fi-empty-state-content">${iconHtml}<div class="fi-empty-state-text-ctn"><${this._headingTag} class="fi-empty-state-heading">${e(await evaluate(this._heading, ctx))}</${this._headingTag}>${isBlank(description) ? '' : `<p class="fi-empty-state-description">${e(description)}</p>`}${footer ? `<footer class="fi-empty-state-footer">${footer}</footer>` : ''}</div></div></section></div>`;
716
+ }
717
+ }
718
+ const CALLOUT_ICONS = { danger: 'o-x-circle', info: 'o-information-circle', success: 'o-check-circle', warning: 'o-exclamation-circle' };
719
+ /**
720
+ * Encadré d'information (port de Callout) : statut (`info()`, `success()`, `warning()`, `danger()`) qui fixe couleur et icône,
721
+ * titre, description, actions de pied (en liens) et actions de contrôle (à droite).
722
+ */
723
+ export class Callout extends Component {
724
+ _heading;
725
+ _description = null;
726
+ _status = null;
727
+ _color = null;
728
+ _hasColor = false;
729
+ _icon = null;
730
+ _iconColor = null;
731
+ _iconSize = null;
732
+ _footerActions = [];
733
+ _footerActionsAlignment = 'start';
734
+ _footer = null;
735
+ _controlActions = [];
736
+ constructor(heading = null) { super(); this._heading = heading; }
737
+ static make(heading = null) { return configured(new Callout(heading)); }
738
+ heading(heading) { this._heading = heading; return this; }
739
+ description(description) { this._description = description; return this; }
740
+ /** Statut : couleur, icône et préfixe lu par les lecteurs d'écran (« Note: »…). */
741
+ status(status) { this._status = status; return this; }
742
+ danger() { return this.status('danger'); }
743
+ info() { return this.status('info'); }
744
+ success() { return this.status('success'); }
745
+ warning() { return this.status('warning'); }
746
+ /** Couleur du fond ; `null` retire celle du statut (l'icône la garde). */
747
+ color(color) { this._color = color; this._hasColor = true; return this; }
748
+ icon(name) { this._icon = name; return this; }
749
+ iconColor(color) { this._iconColor = color; return this; }
750
+ /** Taille de l'icône (`lg` par défaut). */
751
+ iconSize(size) { this._iconSize = size; return this; }
752
+ /** Actions du pied, rendues en liens (port de `actions()` / `footerActions()`). */
753
+ actions(actions) { this._footerActions = [...this._footerActions, ...actions]; return this; }
754
+ footerActions(actions) { return this.actions(actions); }
755
+ /** Alignement des actions du pied (`start`, `center`, `end`, `between`). */
756
+ footerActionsAlignment(alignment) { this._footerActionsAlignment = alignment; return this; }
757
+ /** Pied libre (composants et actions), à la place des actions de pied. */
758
+ footer(items) { this._footer = items; return this; }
759
+ /** Actions à droite de l'encadré (fermeture…), en petite taille. */
760
+ controlActions(actions) { this._controlActions = [...this._controlActions, ...actions]; return this; }
761
+ getColor() { return this._hasColor ? this._color : (this._color ?? this._status); }
762
+ getIcon() { return this._icon ?? (this._status ? (CALLOUT_ICONS[this._status] ?? null) : null); }
763
+ /** Actions de l'encadré, montées sur le schéma racine. */
764
+ getActions() { return [...(this._footer ? actionsOf(this._footer) : this._footerActions), ...this._controlActions]; }
765
+ async toHtml() {
766
+ const ctx = this.context();
767
+ const heading = await evaluate(this._heading, ctx);
768
+ const description = await evaluate(this._description, ctx);
769
+ const color = this.getColor() ?? 'gray';
770
+ const iconName = this.getIcon();
771
+ const iconColor = this._iconColor ?? this._status ?? color;
772
+ const footer = this._footer ? await renderInlineItems(this, this._footer, { actionView: 'link' }) : await renderInlineItems(this, this._footerActions, { alignment: ['right', 'end'].includes(this._footerActionsAlignment) ? 'end' : this._footerActionsAlignment === 'justify' ? 'between' : this._footerActionsAlignment, actionView: 'link' });
773
+ const controls = await renderInlineItems(this, this._controlActions, { alignment: 'start', actionView: 'auto' });
774
+ const statusKey = this._status ? `filament-schemas::components.callout.statuses.${this._status}` : null;
775
+ const statusLabel = statusKey && t(statusKey) !== statusKey ? t(statusKey) : null;
776
+ const statusHtml = statusLabel ? `<span class="fi-sr-only">${e(statusLabel)} </span>` : '';
777
+ const hasHeading = !isBlank(heading), hasDescription = !isBlank(description);
778
+ const text = hasHeading || hasDescription ? `<div class="fi-callout-text">${hasHeading ? `<h4 class="fi-callout-heading">${statusHtml}${e(heading)}</h4>` : ''}${hasDescription ? `<p class="fi-callout-description">${hasHeading ? '' : statusHtml}${e(description)}</p>` : ''}</div>` : '';
779
+ const main = text || footer ? `<div class="fi-callout-main">${text}${footer ? `<div class="fi-callout-footer">${footer}</div>` : ''}</div>` : '';
780
+ const iconHtml = iconName ? icon(iconName, { size: safeClass(this._iconSize) ?? 'lg', attributes: new Attributes({ 'aria-hidden': 'true' }).class([...plainColorClasses(iconColor), 'fi-callout-icon']) }) : '';
781
+ const attributes = new Attributes(this._extraAttributes).class([...plainColorClasses(color), 'fi-sc-callout', 'fi-callout']);
782
+ return `<div ${attributes.toHtml()}>${iconHtml}${main}${controls ? `<div class="fi-callout-controls">${controls}</div>` : ''}</div>`;
783
+ }
784
+ }
785
+ /**
786
+ * Champs accolés sous un seul libellé (port de FusedGroup) : chaque champ n'a plus qu'un libellé pour lecteurs d'écran,
787
+ * et la première erreur de validation des champs s'affiche sous le groupe.
788
+ */
789
+ export class FusedGroup extends Component {
790
+ children;
791
+ _columns = {};
792
+ _label = null;
793
+ _hiddenLabel = false;
794
+ _markedAsRequired = false;
795
+ constructor(children = []) { super(); this.children = children; }
796
+ static make(children = []) { return configured(new FusedGroup(children)); }
797
+ schema(components) { this.children = components; return this; }
798
+ columns(columns) { this._columns = columns; return this; }
799
+ label(label) { this._label = label; return this; }
800
+ hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
801
+ /** Astérisque après le libellé (la validation reste celle de chaque champ). */
802
+ markAsRequired(condition = true) { this._markedAsRequired = condition; return this; }
803
+ getChildSchemas() { return [childSchema(this, this.children).columns(this._columns).gap(false).fieldWrapperView('filament-forms::plain-field-wrapper')]; }
804
+ /** Première erreur des champs du groupe (y compris d'un élément d'un champ à valeurs multiples). */
805
+ firstError() {
806
+ const errors = this.livewire._errors ?? {};
807
+ for (const component of this.getChildSchemas()[0].getComponents()) {
808
+ if (!component.hasStatePath())
809
+ continue;
810
+ const path = component.getStatePath();
811
+ if (errors[path]?.length)
812
+ return errors[path][0];
813
+ const nested = Object.keys(errors).find((key) => key.startsWith(`${path}.`) && errors[key].length);
814
+ if (nested)
815
+ return errors[nested][0];
816
+ }
817
+ return null;
818
+ }
819
+ async toHtml() {
820
+ const id = this.getKey();
821
+ const label = await evaluate(this._label, this.context());
822
+ const error = this.firstError();
823
+ const hasLabel = !isBlank(label);
824
+ const inner = new Attributes().merge({ id, role: 'group', 'aria-labelledby': id && hasLabel ? `${id}-label` : null, ...this._extraAttributes, 'aria-describedby': id && error !== null ? `${id}-error` : null }).class(['fi-sc-fused-group']);
825
+ const labelId = id ? `id="${e(id)}-label" ` : '';
826
+ const labelHtml = !hasLabel ? '' : this._hiddenLabel
827
+ ? `<label ${labelId}class="fi-fo-field-label fi-sr-only">${e(label)}</label>`
828
+ : `<div class="fi-fo-field-label-col"><div class="fi-fo-field-label-ctn"><label ${labelId}class="fi-fo-field-label"><span class="fi-fo-field-label-content">${e(label)}${this._markedAsRequired ? '<sup class="fi-fo-field-label-required-mark">*</sup>' : ''}</span></label></div></div>`;
829
+ const errorHtml = error !== null ? `<p data-validation-error ${id ? `id="${e(id)}-error" ` : ''}class="fi-fo-field-wrp-error-message">${e(error)}</p>` : '';
830
+ return `<div data-field-wrapper class="fi-fo-field">${labelHtml}<div class="fi-fo-field-content-col"><div ${inner.toHtml()}>${await this.getChildSchemas()[0].toHtml()}</div>${errorHtml}</div></div>`;
831
+ }
832
+ }