@navette-js/schemas 0.0.0-stage → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,829 @@
1
+ // @navette-js/schemas — Schema, Component and the layout components.
2
+ // Each component renders in two steps: async property resolution, then a synchronous EJS view (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
+ /** Inside a Flex: `false` keeps the natural width instead of filling the available space. */
11
+ grow(condition = true) { this._grow = condition; return this; }
12
+ canGrow() { return this._grow; }
13
+ /** Default configuration for every instance later created by `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
+ /** Hidden for the given operation(s) (`'create'`, `'edit'`…). */
27
+ hiddenOn(operations) { const list = [].concat(operations); return this.hidden(({ operation }) => operation !== null && list.includes(operation)); }
28
+ /** Visible only for the given operation(s). */
29
+ visibleOn(operations) { const list = [].concat(operations); return this.visible(({ operation }) => operation !== null && list.includes(operation)); }
30
+ /** A number applies from the `lg` breakpoint; an object replaces the default entirely. Values may be 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
+ /** Starting grid column, from the `lg` breakpoint. */
34
+ columnStart(start) { this._columnStart = start; return this; }
35
+ /** Full grid width on every screen size (`columnSpan('full')` only applies from `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
+ /** Server methods the component's JavaScript may call (announced to the browser). */
41
+ getExposedMethods() { return []; }
42
+ /** False for an exposed method after which the host must re-render (e.g. next wizard step, to show validation errors). */
43
+ isRenderlessMethod(_method) { return true; }
44
+ hasStatePath() { return false; }
45
+ /** Absolute component key: `<schema key>.<key or name>`, or null. */
46
+ getKey() {
47
+ // `::data::` in a generated key (section, tabs) stands for the schema state path, and is dropped when there is none (infolists).
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
+ // A stateless (layout) component exposes its container's state path.
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
+ /** Grid column wrapper, rendered even when the component is hidden. */
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
+ /** Class placed before the grid classes (e.g. `fi-section-content`). */
111
+ extraClass(name) { this._extraClass = name; return this; }
112
+ /** Tighter spacing between components (`fi-sc-dense`). */
113
+ dense(condition = true) { this._dense = condition; return this; }
114
+ /** Partially rendered schema: its root carries `wire:partial="schema.<key>"`. */
115
+ partiallyRendered(condition = true) { this._partial = condition; return this; }
116
+ _partialName = null;
117
+ /** Root `wire:partial` name when it differs from the schema key (filters form: `table-filters-form`). */
118
+ partialName(name) { this._partialName = name; return this; }
119
+ _gap = true;
120
+ /** `false`: components touch, with no gap (no `fi-sc-has-gap`), as in a FusedGroup. */
121
+ gap(condition = true) { this._gap = condition; return this; }
122
+ _fieldWrapperView = null;
123
+ /**
124
+ * Field wrapper for this schema: `'filament-forms::plain-field-wrapper'` keeps only a screen-reader label
125
+ * (FusedGroup fields). `null`: the usual wrapper.
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
+ /** All components, depth-first. */
137
+ getFlatComponents() {
138
+ return this._components.flatMap((c) => [c, ...(c.getChildSchemas?.() ?? []).flatMap((s) => s.getFlatComponents())]);
139
+ }
140
+ /** Same, without hidden components or their descendants: a field inside a hidden section is neither validated nor saved. */
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
+ async toHtml() {
153
+ const withVisibility = await Promise.all(this._components.map(async (c) => [c, await c.isVisible()]));
154
+ if (!withVisibility.some(([, v]) => v))
155
+ return '';
156
+ const attributes = new Attributes(this._extraClass ? { class: this._extraClass } : {})
157
+ .when(!this._inline, (a) => a.grid(this._columns))
158
+ .merge({
159
+ 'wire:partial': this._partialName ?? (this._partial ? `schema.${this._key}` : null),
160
+ 'x-data': this._root ? `filamentSchema({ livewireId: ${js(this.livewire.id)}, schemaKey: ${js(this._key)} })` : null,
161
+ 'x-on:form-validation-error.window': this._root ? 'handleFormValidationError' : null,
162
+ 'x-on:reset-schema-component-state.window': this._root ? 'handleClientSideStateReset' : null,
163
+ })
164
+ .class(['fi-sc', { 'fi-inline': this._inline }, { 'fi-sc-has-gap': this._gap }, { 'fi-sc-dense': this._dense }]);
165
+ const parts = [];
166
+ for (const [component, isVisible] of withVisibility) {
167
+ if (!component.isLiberatedFromContainerGrid())
168
+ parts.push(await component.toSchemaHtml(isVisible));
169
+ else if (isVisible)
170
+ parts.push(`<div class="fi-sc-liberated" wire:key="${e(component.getLivewireKey() ?? '')}" x-data="filamentSchemaComponent(${js(component.getAlpineScopeConfiguration())})">${await component.toHtml()}</div>`);
171
+ }
172
+ return `<div ${attributes.toHtml()}>${parts.join('')}</div>`;
173
+ }
174
+ }
175
+ /** Child schema of a layout component: shares the parent's key and state path, without being a root. */
176
+ export function childSchema(parent, components) {
177
+ const c = parent.container;
178
+ return new Schema(c.livewire).key(c.getKey()).statePath(c.getStatePath() || null).operation(c.getOperation()).record(c.getRecord()).root(false).components(components);
179
+ }
180
+ /** Stateful group: its children live under `<container path>.<name>` (used by filter forms). */
181
+ export class StatefulGroup extends Component {
182
+ children;
183
+ constructor(name, children) { super(); this._name = name; this.children = children; }
184
+ static make(name, children) { return configured(new StatefulGroup(name, children)); }
185
+ hasStatePath() { return true; }
186
+ getChildSchemas() {
187
+ const c = this.container;
188
+ return [new Schema(c.livewire).key(this.getKey()).statePath(this.getStatePath()).operation(c.getOperation()).record(c.getRecord()).root(false).components(this.children)];
189
+ }
190
+ async toHtml() { return `<div id="${e(this.getKey())}">${await this.getChildSchemas()[0].toHtml()}</div>`; }
191
+ }
192
+ /** Groups components in a grid, without any chrome. */
193
+ export class Group extends Component {
194
+ children;
195
+ _columns = {};
196
+ constructor(children = []) { super(); this.children = children; }
197
+ static make(children = []) { return configured(new Group(children)); }
198
+ schema(components) { this.children = components; return this; }
199
+ columns(columns) { this._columns = columns; return this; }
200
+ getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
201
+ async toHtml() { return `<div>${await this.getChildSchemas()[0].toHtml()}</div>`; }
202
+ }
203
+ /** Column grid: two columns from `lg` by default. */
204
+ export class Grid extends Group {
205
+ constructor(columns = 2) { super(); this._columns = columns; }
206
+ static make(columns = 2) { return configured(new Grid(Array.isArray(columns) ? 2 : columns)); }
207
+ }
208
+ /**
209
+ * Components side by side from a breakpoint: each fills the available space,
210
+ * except those marked `grow(false)`, which keep their natural width.
211
+ */
212
+ export class Flex extends Component {
213
+ children;
214
+ _from = 'sm';
215
+ constructor(children = []) { super(); this.children = children; }
216
+ static make(children = []) { return configured(new Flex(children)); }
217
+ schema(components) { this.children = components; return this; }
218
+ from(breakpoint) { this._from = breakpoint; return this; }
219
+ getChildSchemas() { return [childSchema(this, this.children)]; }
220
+ async toHtml() {
221
+ const parts = [];
222
+ for (const component of this.getChildSchemas()[0].getComponents()) {
223
+ if (!(await component.isVisible()))
224
+ continue;
225
+ parts.push(`<div wire:key="${e(component.getLivewireKey() ?? '')}" x-data="filamentSchemaComponent(${js(component.getAlpineScopeConfiguration())})"${component.canGrow() ? ' class="fi-growable"' : ''}>${await component.toHtml()}</div>`);
226
+ }
227
+ return `<div class="fi-sc-flex fi-from-${this._from}">${parts.join('')}</div>`;
228
+ }
229
+ }
230
+ export const slug = (text) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
231
+ /** Card grouping components, with an optional header (heading, actions). */
232
+ export class Section extends Component {
233
+ children = [];
234
+ _columns = {};
235
+ _heading;
236
+ _afterHeader = [];
237
+ constructor(heading = null) {
238
+ super();
239
+ this._heading = heading;
240
+ // A section with a heading gets a key derived from it.
241
+ if (heading !== null)
242
+ this._keyName = `${slug(heading)}::data::section`;
243
+ }
244
+ static make(heading = null) { return configured(new Section(heading)); }
245
+ schema(components) { this.children = components; return this; }
246
+ columns(columns) { this._columns = columns; return this; }
247
+ _collapsible = false;
248
+ _collapsed = false;
249
+ /** Collapsible by clicking its header; `collapsed()` starts it collapsed. */
250
+ collapsible(condition = true) { this._collapsible = condition; return this; }
251
+ collapsed(condition = true) { this._collapsed = condition; if (condition)
252
+ this._collapsible = true; return this; }
253
+ /** Actions shown to the right of the heading. */
254
+ afterHeader(actions) { this._afterHeader = actions; return this; }
255
+ /** Actions the section exposes for mounting. */
256
+ getActions() { return this._afterHeader; }
257
+ getChildSchemas() { return [childSchema(this, this.children).columns(this._columns).extraClass('fi-section-content')]; }
258
+ async toHtml() {
259
+ const [content] = this.getChildSchemas();
260
+ if (this._heading === null) {
261
+ return `<div class="fi-sc-section"><section x-data="{
262
+ isCollapsed: false,
263
+ }" class="fi-section"><div class="fi-section-content-ctn">${await content.toHtml()}</div></section></div>`;
264
+ }
265
+ const key = this.getKey();
266
+ const ctx = this.context();
267
+ const after = [];
268
+ // Mount context: the host's, plus the schema the section belongs to.
269
+ const mountContext = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.container.getKey() };
270
+ for (const action of this._afterHeader)
271
+ if (await action.isVisible(ctx))
272
+ after.push(`<div class="fi-sc-action">${await action.toHtml(ctx, mountContext)}</div>`);
273
+ // Collapsible section: listens to `collapse-section` / `expand-section`… window events, with a toggle button in the header.
274
+ const idTest = `if ($event.detail.id == (${js(key)} ?? $el.id))`;
275
+ 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'"` : '';
276
+ 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>` : '';
277
+ return `<div id="${e(key)}" class="fi-sc-section"><section x-data="{
278
+ isCollapsed: ${this._collapsed ? 'true' : 'false'},
279
+ }"${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>`;
280
+ }
281
+ }
282
+ /**
283
+ * Raw markup in a schema. The content is inserted AS IS: never put a user- or database-supplied value in it
284
+ * without escaping it with `e()` from `@navette-js/support`.
285
+ */
286
+ export class Html extends Component {
287
+ content;
288
+ constructor(content) { super(); this.content = content; }
289
+ static make(content) { return new Html(content); }
290
+ async toHtml() { return typeof this.content === 'function' ? this.content(this.context()) : this.content; }
291
+ }
292
+ /** A tab of a Tabs component: a label, an optional icon and its own schema. */
293
+ export class Tab extends Component {
294
+ children = [];
295
+ _columns = {};
296
+ _icon = null;
297
+ label;
298
+ constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::tab`; }
299
+ static make(label) { return configured(new Tab(label)); }
300
+ schema(components) { this.children = components; return this; }
301
+ columns(columns) { this._columns = columns; return this; }
302
+ icon(name) { this._icon = name; return this; }
303
+ getLabel() { return this.label; }
304
+ getIcon() { return this._icon; }
305
+ /** Browser-side tab key (no schema prefix): `::data::` follows the schema state path, and is dropped in infolists. */
306
+ getTabKey() { const statePath = this.container.getStatePath(); return this._keyName.replace('::data::', statePath ? `::${statePath}::` : '::'); }
307
+ getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
308
+ async toHtml() { return this.getChildSchemas()[0].toHtml(); }
309
+ }
310
+ /** Layout tabs: every panel is rendered, the browser only shows the active one. */
311
+ export class Tabs extends Component {
312
+ tabs = [];
313
+ label;
314
+ constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::tabs`; }
315
+ static make(label) { return configured(new Tabs(label)); }
316
+ schema(tabs) { this.tabs = tabs; return this; }
317
+ getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
318
+ getLazyLoadedTabBadges() { return {}; }
319
+ getDeferredTabBadges() { return {}; }
320
+ getChildSchemas() { return [childSchema(this, this.tabs)]; }
321
+ async toHtml() {
322
+ // Building the child schema attaches the tabs to their container, which is what gives them their keys.
323
+ this.getChildSchemas();
324
+ const key = this.getKey();
325
+ const id = this.livewire.id;
326
+ const q = (k) => `'${k.replace(/[^A-Za-z0-9:_-]/g, '')}'`;
327
+ const tabId = (tab) => `fi-sc-tab-${createHash('md5').update(tab.getKey()).digest('hex')}`;
328
+ const visible = [];
329
+ for (const tab of this.tabs)
330
+ if (await tab.isVisible())
331
+ visible.push(tab);
332
+ const buttons = visible.map((tab) => {
333
+ const k = tab.getTabKey();
334
+ return `<button type="button" x-bind:class="{
335
+ 'fi-active': isTabSelected(${q(k)}),
336
+ 'fi-sc-tabs-header-overflow': overflowTabs.includes(${q(k)}),
337
+ 'fi-sc-tabs-header-overflow-open': isOverflowOpen &amp;&amp; overflowTabs.includes(${q(k)}),
338
+ }" 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>`;
339
+ });
340
+ const panels = [];
341
+ for (const tab of visible) {
342
+ const k = tab.getTabKey();
343
+ 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>`);
344
+ }
345
+ return `<div x-data="tabsSchemaComponent({
346
+ activeTab: 1,
347
+ hasTabPanels: true,
348
+ isScrollable: true,
349
+ isVertical: false,
350
+ isTabPersisted: false,
351
+ isTabPersistedInQueryString: false,
352
+ livewireId: ${e(js(id))},
353
+ schemaKey: ${e(js(key.split('.')[0]))},
354
+ tab: null,
355
+ tabQueryStringKey: null,
356
+ })" 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>`;
357
+ }
358
+ }
359
+ /** Render hook injection point, using the host's scopes. Liberated from the container grid. */
360
+ export class RenderHook extends Component {
361
+ hook;
362
+ constructor(hook) { super(); this.hook = hook; }
363
+ static make(hook) { return configured(new RenderHook(hook)); }
364
+ isLiberatedFromContainerGrid() { return true; }
365
+ async toHtml() { return renderHook(this.hook, this.livewire.getRenderHookScopes?.() ?? []); }
366
+ }
367
+ /** Embeds another schema of the host (`form`, `infolist`…) as a root schema. */
368
+ export class EmbeddedSchema extends Component {
369
+ schemaName;
370
+ constructor(name) { super(); this.schemaName = name; }
371
+ static make(name) { return configured(new EmbeddedSchema(name)); }
372
+ async toHtml() { return (await this.livewire.getSchema(this.schemaName)).toHtml(); }
373
+ }
374
+ /** `<form>` element: content and footer (actions), submitted to a host method. */
375
+ export class Form extends Component {
376
+ children;
377
+ _footer = [];
378
+ _id = null;
379
+ _submit = null;
380
+ constructor(children) { super(); this.children = children; }
381
+ static make(children = []) { return configured(new Form(children)); }
382
+ id(id) { this._id = id; return this; }
383
+ livewireSubmitHandler(method) { this._submit = method; return this; }
384
+ footer(components) { this._footer = components; return this; }
385
+ getChildSchemas() { return [childSchema(this, this.children), childSchema(this, this._footer)]; }
386
+ async toHtml() {
387
+ const attributes = new Attributes().merge({ id: this._id, 'wire:submit': this._submit }).class(['fi-sc-form']);
388
+ const [content, footer] = this.getChildSchemas();
389
+ return `<form ${attributes.toHtml()}>${await content.toHtml()}${await footer.toHtml()}</form>`;
390
+ }
391
+ }
392
+ /** Row of actions in a schema. */
393
+ export class Actions extends Component {
394
+ actions;
395
+ _fullWidth = false;
396
+ _alignment = null;
397
+ constructor(actions) { super(); this.actions = actions; }
398
+ static make(actions) { return configured(new Actions(actions)); }
399
+ fullWidth(condition = true) { this._fullWidth = condition; return this; }
400
+ alignment(alignment) { this._alignment = alignment; return this; }
401
+ async toHtml() {
402
+ const ctx = this.context();
403
+ const visible = [];
404
+ for (const a of this.actions)
405
+ if (await a.isVisible(ctx))
406
+ visible.push(await a.toHtml(ctx));
407
+ if (!visible.length)
408
+ return '';
409
+ const inner = cls(['fi-ac', { 'fi-width-full': this._fullWidth }, this._alignment ? `fi-align-${this._alignment}` : null]);
410
+ return `<div ${new Attributes().merge({ id: this.getKey() }).class(['fi-sc-actions']).toHtml()}><div class="${inner}">${visible.join('')}</div></div>`;
411
+ }
412
+ }
413
+ let colorManager = null;
414
+ /** Color manager used outside a panel; inside a panel, the host panel's manager takes precedence. */
415
+ export const useColorManager = (manager) => { colorManager = manager; };
416
+ const colorsFor = (component) => component.livewire?.panel?.colors ?? colorManager;
417
+ /** Color classes without computed shades (icon, callout, empty state); none for gray, the default. */
418
+ const plainColorClasses = (color) => (color && color !== 'gray' ? ['fi-color', `fi-color-${color.replace(/[^a-zA-Z0-9_-]/g, '')}`] : []);
419
+ /** Size or weight class: only class-name characters are kept. */
420
+ const safeClass = (value) => (value ? value.replace(/[^a-zA-Z0-9_:-]/g, '') || null : null);
421
+ const tooltipExpression = (text) => (text ? `{ content: ${js(text)}, theme: $store.theme, allowHTML: false }` : null);
422
+ const isBlank = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (value instanceof HtmlString && value.html.trim() === '');
423
+ /** Escaped text, or an `HtmlString`'s HTML inserted as is (build it only from escaped values). */
424
+ const textOrHtml = (value) => (value instanceof HtmlString ? value.html : e(value));
425
+ /**
426
+ * Inline row mixing components and actions (footers, controls, after a label).
427
+ * Actions are mounted with `schemaComponent: mountKey` (the root schema by default, which finds them through the parent's `getActions()`).
428
+ * `actionView`: `'button'` (full button), `'link'` (link, or icon button for an `iconButton()` action), `'auto'` (icon button if `iconButton()`, button otherwise).
429
+ * `scope` overrides keys of the components' Alpine configuration (`path`, `containerPath`).
430
+ */
431
+ export async function renderInlineItems(parent, items, options = {}) {
432
+ const components = items.filter((item) => item instanceof Component);
433
+ if (components.length)
434
+ childSchema(parent, components).inline();
435
+ const ctx = parent.context();
436
+ const mountContext = { ...(parent.livewire.getActionMountContext?.() ?? {}), schemaComponent: options.mountKey ?? parent.container.getKey() };
437
+ const parts = [];
438
+ for (const item of items) {
439
+ if (item instanceof Component) {
440
+ if (!(await item.isVisible()))
441
+ continue;
442
+ const key = item.getKey();
443
+ const attributes = new Attributes().merge({
444
+ 'wire:partial': key !== null ? `schema-component::${key}` : null,
445
+ 'x-data': `filamentSchemaComponent(${js({ ...item.getAlpineScopeConfiguration(), ...(options.scope ?? {}) })})`,
446
+ 'wire:key': item.getLivewireKey(),
447
+ });
448
+ parts.push(`<div ${attributes.toHtml()}><div class="fi-sc-component">${await item.toHtml()}</div></div>`);
449
+ continue;
450
+ }
451
+ if (!(await item.isVisible(ctx)))
452
+ continue;
453
+ const action = item;
454
+ const view = options.actionView ?? 'button';
455
+ const asLink = typeof action.toLinkHtml === 'function' && (view === 'link' || (view === 'auto' && action._iconButton === true));
456
+ // The link rendering is shared with table rows: its row key, empty here, is stripped.
457
+ const html = asLink ? String(await action.toLinkHtml(ctx, mountContext, '')).replace(' wire:key=""', '') : await action.toHtml(ctx, mountContext);
458
+ parts.push(`<div class="fi-sc-action">${html}</div>`);
459
+ }
460
+ if (!parts.length)
461
+ return '';
462
+ 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>`;
463
+ }
464
+ /** Actions among inline items (expose them through `getActions()` so they can be mounted). */
465
+ const actionsOf = (items) => items.filter((item) => !(item instanceof Component));
466
+ /** Column-direction grid: items fill column by column. */
467
+ function columnGrid(columns) {
468
+ const cols = typeof columns === 'number' ? { lg: columns } : { ...(columns ?? {}) };
469
+ for (const key of Object.keys(cols))
470
+ if (!cols[key])
471
+ delete cols[key];
472
+ cols.default ??= 1;
473
+ const name = (breakpoint) => breakpoint.replace(/@/g, 'c').replace(/!/g, 'n');
474
+ return new Attributes()
475
+ .class(['fi-grid', 'fi-grid-direction-col', ...Object.keys(cols).map((breakpoint) => (breakpoint === 'default' ? (cols[breakpoint] > 1 ? 'fi-grid-cols' : '') : `${breakpoint}:fi-grid-cols`))])
476
+ .style(Object.entries(cols).map(([breakpoint, count]) => `--cols-${name(breakpoint)}: ${count}`));
477
+ }
478
+ /**
479
+ * Titled frame grouping fields: a legend and a two-column grid by default.
480
+ * With `statePath()`, the fields live under that path and the fieldset gets a key (and an `id`).
481
+ */
482
+ export class Fieldset extends Component {
483
+ children = [];
484
+ _columns = 2;
485
+ _label;
486
+ _hiddenLabel = false;
487
+ _contained = true;
488
+ _markedAsRequired = false;
489
+ constructor(label = null) { super(); this._label = label; }
490
+ static make(label = null) { return configured(new Fieldset(label)); }
491
+ schema(components) { this.children = components; return this; }
492
+ columns(columns) { this._columns = columns; return this; }
493
+ label(label) { this._label = label; return this; }
494
+ /** Keeps the legend for screen readers only. */
495
+ hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
496
+ /** `false`: no border or padding. */
497
+ contained(condition = true) { this._contained = condition; return this; }
498
+ /** Asterisk after the legend (the fieldset itself is never validated). */
499
+ markAsRequired(condition = true) { this._markedAsRequired = condition; return this; }
500
+ /** State path of the fields, relative to the container. */
501
+ statePath(path) { this._name = path; return this; }
502
+ hasStatePath() { return this._name !== null; }
503
+ getChildSchemas() {
504
+ const schema = childSchema(this, this.children).columns(this._columns);
505
+ if (this.hasStatePath())
506
+ schema.key(this.getKey()).statePath(this.getStatePath());
507
+ return [schema];
508
+ }
509
+ async toHtml() {
510
+ const label = await evaluate(this._label, this.context());
511
+ const attributes = new Attributes().merge({ id: this.getKey(), ...this._extraAttributes })
512
+ .class(['fi-sc-fieldset', 'fi-fieldset', { 'fi-fieldset-label-hidden': this._hiddenLabel, 'fi-fieldset-not-contained': !this._contained }]);
513
+ const legend = isBlank(label) ? '' : `<legend>${e(label)}${this._markedAsRequired ? '<sup class="fi-fieldset-label-required-mark">*</sup>' : ''}</legend>`;
514
+ return `<fieldset ${attributes.toHtml()}>${legend}${await this.getChildSchemas()[0].toHtml()}</fieldset>`;
515
+ }
516
+ }
517
+ /**
518
+ * Text in a schema: a plain `<span>`, or a badge with `badge()`. The content is escaped;
519
+ * an `HtmlString` (from `@navette-js/support`) is inserted as is, so build it only from escaped values.
520
+ */
521
+ export class Text extends Component {
522
+ _content;
523
+ _badge = false;
524
+ _color = null;
525
+ _size = null;
526
+ _weight = null;
527
+ _fontFamily = null;
528
+ _icon = null;
529
+ _iconPosition = 'before';
530
+ _iconSize = null;
531
+ _tooltip = null;
532
+ _copyable = false;
533
+ _copyableState = null;
534
+ _copyMessage = null;
535
+ _copyMessageDuration = 2000;
536
+ constructor(content) { super(); this._content = content; }
537
+ static make(content) { return configured(new Text(content)); }
538
+ content(content) { this._content = content; return this; }
539
+ /** Renders as a badge. */
540
+ badge(condition = true) { this._badge = condition; return this; }
541
+ /** Color (`gray` by default; `primary`, `danger`…). */
542
+ color(color) { this._color = color; return this; }
543
+ /** Text or badge size (`xs`, `sm`, `md`, `lg`). */
544
+ size(size) { this._size = size; return this; }
545
+ /** Font weight (`bold`, `semibold`, `medium`…). */
546
+ weight(weight) { this._weight = weight; return this; }
547
+ /** Font family (`sans`, `serif`, `mono`). */
548
+ fontFamily(family) { this._fontFamily = family; return this; }
549
+ /** Badge icon. */
550
+ icon(name) { this._icon = name; return this; }
551
+ iconPosition(position) { this._iconPosition = position; return this; }
552
+ iconSize(size) { this._iconSize = size; return this; }
553
+ tooltip(text) { this._tooltip = text; return this; }
554
+ /** Clicking copies the text (or `copyableState()`) to the clipboard. */
555
+ copyable(condition = true) { this._copyable = condition; return this; }
556
+ copyableState(state) { this._copyableState = state; return this; }
557
+ copyMessage(message) { this._copyMessage = message; return this; }
558
+ copyMessageDuration(milliseconds) { this._copyMessageDuration = milliseconds; return this; }
559
+ async getContent() { return evaluate(this._content, this.context()); }
560
+ async toHtml() {
561
+ const ctx = this.context();
562
+ const content = await this.getContent();
563
+ const color = (await evaluate(this._color, ctx)) ?? 'gray';
564
+ const tooltip = tooltipExpression(await evaluate(this._tooltip, ctx));
565
+ const isCopyable = Boolean(await evaluate(this._copyable, ctx));
566
+ const copyableState = (await evaluate(this._copyableState, ctx)) ?? (content instanceof HtmlString ? content.html : content);
567
+ 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})`;
568
+ if (await evaluate(this._badge, ctx)) {
569
+ const tag = isCopyable ? 'button' : 'span';
570
+ const attributes = new Attributes().merge({ 'x-on:click': isCopyable ? copy : null, 'x-tooltip': tooltip, ...this._extraAttributes, type: isCopyable ? 'button' : null })
571
+ .class(['fi-sc-text', 'fi-badge', `fi-size-${safeClass(this._size) ?? 'md'}`, ...(color !== 'gray' ? (colorsFor(this)?.getComponentClasses('badge', color) ?? plainColorClasses(color)) : [])]);
572
+ const iconHtml = this._icon ? icon(this._icon, { size: safeClass(this._iconSize) ?? 'sm' }) : '';
573
+ 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}>`;
574
+ }
575
+ // Text color: same contrast-aware classes as a link.
576
+ const colorClasses = color !== 'gray' ? (colorsFor(this)?.getComponentClasses('link', color) ?? plainColorClasses(color)) : [];
577
+ const attributes = new Attributes().merge({
578
+ role: isCopyable ? 'button' : null, tabindex: isCopyable ? '0' : null, 'x-on:click': isCopyable ? copy : null,
579
+ 'x-on:keydown.enter.prevent': isCopyable ? '$el.click()' : null, 'x-on:keydown.space.prevent': isCopyable ? '$el.click()' : null, 'x-tooltip': tooltip,
580
+ }).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])
581
+ .merge(this._extraAttributes);
582
+ return `<span ${attributes.toHtml()}>${textOrHtml(content)}</span>`;
583
+ }
584
+ }
585
+ /** Heroicon in a schema: decorative, unless a tooltip or an `aria-label` (extra attribute) describes it. */
586
+ export class Icon extends Component {
587
+ _icon;
588
+ _color = null;
589
+ _size = null;
590
+ _tooltip = null;
591
+ constructor(name) { super(); this._icon = name; }
592
+ static make(name) { return configured(new Icon(name)); }
593
+ icon(name) { this._icon = name; return this; }
594
+ /** Color (`primary` by default). */
595
+ color(color) { this._color = color; return this; }
596
+ /** Size (`xs` … `2xl`); `base` resets to the default size. */
597
+ size(size) { this._size = size === 'base' ? null : size; return this; }
598
+ tooltip(text) { this._tooltip = text; return this; }
599
+ async toHtml() {
600
+ const ctx = this.context();
601
+ const name = await evaluate(this._icon, ctx);
602
+ if (!name)
603
+ return '';
604
+ const tooltip = await evaluate(this._tooltip, ctx);
605
+ const { 'aria-label': ariaLabel = null, 'aria-labelledby': ariaLabelledBy = null, 'aria-describedby': ariaDescribedBy = null, ...extra } = this._extraAttributes;
606
+ const attributes = new Attributes({ 'x-tooltip': tooltipExpression(tooltip) }).merge({ ...extra, 'aria-hidden': 'true' })
607
+ .class([...plainColorClasses((await evaluate(this._color, ctx)) ?? 'primary'), 'fi-sc-icon']);
608
+ const html = icon(name, { size: safeClass(this._size) ?? 'md', attributes });
609
+ const label = ariaLabel ?? (ariaLabelledBy ? null : tooltip);
610
+ if (!label && !ariaLabelledBy)
611
+ return html;
612
+ 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>`;
613
+ }
614
+ }
615
+ /** Image in a schema. Only same-site (`/…`) or http(s) URLs are rendered. */
616
+ export class Image extends Component {
617
+ _url;
618
+ _alt;
619
+ _height = null;
620
+ _width = null;
621
+ _alignment = null;
622
+ _tooltip = null;
623
+ constructor(url, alt) { super(); this._url = url; this._alt = alt; }
624
+ static make(url, alt) { return configured(new Image(url, alt)); }
625
+ url(url) { this._url = url; return this; }
626
+ alt(alt) { this._alt = alt; return this; }
627
+ static dimension(value) {
628
+ if (value === null)
629
+ return null;
630
+ const css = typeof value === 'number' ? `${value}px` : value.trim();
631
+ return /^(\d+(\.\d+)?(px|rem|em|%|vh|vw)?|auto)$/.test(css) ? css : null;
632
+ }
633
+ /** Height (`8rem` by default); a number is in pixels. */
634
+ imageHeight(height) { this._height = Image.dimension(height); return this; }
635
+ imageWidth(width) { this._width = Image.dimension(width); return this; }
636
+ imageSize(size) { return this.imageWidth(size).imageHeight(size); }
637
+ /** Alignment (`start`, `center`, `end`). */
638
+ alignment(alignment) { this._alignment = alignment; return this; }
639
+ alignStart() { return this.alignment('start'); }
640
+ alignCenter() { return this.alignment('center'); }
641
+ alignEnd() { return this.alignment('end'); }
642
+ tooltip(text) { this._tooltip = text; return this; }
643
+ async toHtml() {
644
+ const ctx = this.context();
645
+ const url = String((await evaluate(this._url, ctx)) ?? '');
646
+ if (!/^(\/(?!\/)|https?:\/\/)/i.test(url))
647
+ return '';
648
+ const tooltip = tooltipExpression(await evaluate(this._tooltip, ctx));
649
+ const attributes = new Attributes(this._extraAttributes)
650
+ .class(['fi-sc-image', this._alignment ? `fi-align-${safeClass(this._alignment)}` : null])
651
+ .style([`height: ${this._height ?? '8rem'}`, this._width ? `width: ${this._width}` : null]);
652
+ return `<img alt="${e(await evaluate(this._alt, ctx))}" src="${e(url)}"${tooltip ? ` x-tooltip="${tooltip}"` : ''} ${attributes.toHtml()} />`;
653
+ }
654
+ }
655
+ /** Bulleted list: each item is a component; a string becomes a `Text`. Two columns from `sm`. */
656
+ export class UnorderedList extends Component {
657
+ children;
658
+ _columns = { sm: 2 };
659
+ _size = null;
660
+ constructor(items = []) { super(); this.children = UnorderedList.normalize(items); }
661
+ static make(items = []) { return configured(new UnorderedList(items)); }
662
+ static normalize(items) { return items.map((item) => (item instanceof Component ? item : Text.make(item))); }
663
+ schema(items) { this.children = UnorderedList.normalize(items); return this; }
664
+ columns(columns) { this._columns = columns; return this; }
665
+ /** Text size (`xs`, `sm`, `md`, `lg`). */
666
+ size(size) { this._size = size; return this; }
667
+ getChildSchemas() { return [childSchema(this, this.children)]; }
668
+ async toHtml() {
669
+ const attributes = columnGrid(this._columns).merge(this._extraAttributes).class(['fi-sc-unordered-list', this._size ? `fi-size-${safeClass(this._size)}` : null]);
670
+ const items = [];
671
+ for (const component of this.getChildSchemas()[0].getComponents()) {
672
+ items.push(`<li wire:key="${e(component.getLivewireKey() ?? '')}" x-data="filamentSchemaComponent(${js(component.getAlpineScopeConfiguration())})">${await component.toHtml()}</li>`);
673
+ }
674
+ return `<ul ${attributes.toHtml()}>${items.join('')}</ul>`;
675
+ }
676
+ }
677
+ /** Empty state: icon, heading, description and a footer (actions, components). */
678
+ export class EmptyState extends Component {
679
+ _heading;
680
+ _description = null;
681
+ _icon = null;
682
+ _iconColor = null;
683
+ _iconSize = null;
684
+ _compact = false;
685
+ _contained = true;
686
+ _headingTag = 'h2';
687
+ _footer = [];
688
+ constructor(heading) { super(); this._heading = heading; }
689
+ static make(heading) { return configured(new EmptyState(heading)); }
690
+ heading(heading) { this._heading = heading; return this; }
691
+ description(description) { this._description = description; return this; }
692
+ icon(name) { this._icon = name; return this; }
693
+ /** Icon color (`primary` by default). */
694
+ iconColor(color) { this._iconColor = color; return this; }
695
+ iconSize(size) { this._iconSize = size; return this; }
696
+ /** Compact variant. */
697
+ compact(condition = true) { this._compact = condition; return this; }
698
+ /** `false`: no surrounding card. */
699
+ contained(condition = true) { this._contained = condition; return this; }
700
+ /** Heading tag (`h2` by default). */
701
+ headingTag(tag) { this._headingTag = tag; return this; }
702
+ /** Footer: inline actions and components. */
703
+ footer(items) { this._footer = items; return this; }
704
+ /** Footer actions, mounted on the root schema. */
705
+ getActions() { return actionsOf(this._footer); }
706
+ async toHtml() {
707
+ const ctx = this.context();
708
+ const description = await evaluate(this._description, ctx);
709
+ const footer = await renderInlineItems(this, this._footer);
710
+ const iconColor = this._iconColor ?? 'primary';
711
+ 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>` : '';
712
+ 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>`;
713
+ }
714
+ }
715
+ const CALLOUT_ICONS = { danger: 'o-x-circle', info: 'o-information-circle', success: 'o-check-circle', warning: 'o-exclamation-circle' };
716
+ /**
717
+ * Information box: a status (`info()`, `success()`, `warning()`, `danger()`) setting color and icon,
718
+ * heading, description, footer actions (as links) and control actions (on the right).
719
+ */
720
+ export class Callout extends Component {
721
+ _heading;
722
+ _description = null;
723
+ _status = null;
724
+ _color = null;
725
+ _hasColor = false;
726
+ _icon = null;
727
+ _iconColor = null;
728
+ _iconSize = null;
729
+ _footerActions = [];
730
+ _footerActionsAlignment = 'start';
731
+ _footer = null;
732
+ _controlActions = [];
733
+ constructor(heading = null) { super(); this._heading = heading; }
734
+ static make(heading = null) { return configured(new Callout(heading)); }
735
+ heading(heading) { this._heading = heading; return this; }
736
+ description(description) { this._description = description; return this; }
737
+ /** Status: sets color, icon and a screen-reader prefix ("Note:"…). */
738
+ status(status) { this._status = status; return this; }
739
+ danger() { return this.status('danger'); }
740
+ info() { return this.status('info'); }
741
+ success() { return this.status('success'); }
742
+ warning() { return this.status('warning'); }
743
+ /** Background color; `null` removes the status color (the icon keeps it). */
744
+ color(color) { this._color = color; this._hasColor = true; return this; }
745
+ icon(name) { this._icon = name; return this; }
746
+ iconColor(color) { this._iconColor = color; return this; }
747
+ /** Icon size (`lg` by default). */
748
+ iconSize(size) { this._iconSize = size; return this; }
749
+ /** Footer actions, rendered as links. */
750
+ actions(actions) { this._footerActions = [...this._footerActions, ...actions]; return this; }
751
+ footerActions(actions) { return this.actions(actions); }
752
+ /** Footer actions alignment (`start`, `center`, `end`, `between`). */
753
+ footerActionsAlignment(alignment) { this._footerActionsAlignment = alignment; return this; }
754
+ /** Free-form footer (components and actions), replacing the footer actions. */
755
+ footer(items) { this._footer = items; return this; }
756
+ /** Small actions on the right of the callout (dismiss…). */
757
+ controlActions(actions) { this._controlActions = [...this._controlActions, ...actions]; return this; }
758
+ getColor() { return this._hasColor ? this._color : (this._color ?? this._status); }
759
+ getIcon() { return this._icon ?? (this._status ? (CALLOUT_ICONS[this._status] ?? null) : null); }
760
+ /** Callout actions, mounted on the root schema. */
761
+ getActions() { return [...(this._footer ? actionsOf(this._footer) : this._footerActions), ...this._controlActions]; }
762
+ async toHtml() {
763
+ const ctx = this.context();
764
+ const heading = await evaluate(this._heading, ctx);
765
+ const description = await evaluate(this._description, ctx);
766
+ const color = this.getColor() ?? 'gray';
767
+ const iconName = this.getIcon();
768
+ const iconColor = this._iconColor ?? this._status ?? color;
769
+ 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' });
770
+ const controls = await renderInlineItems(this, this._controlActions, { alignment: 'start', actionView: 'auto' });
771
+ const statusKey = this._status ? `filament-schemas::components.callout.statuses.${this._status}` : null;
772
+ const statusLabel = statusKey && t(statusKey) !== statusKey ? t(statusKey) : null;
773
+ const statusHtml = statusLabel ? `<span class="fi-sr-only">${e(statusLabel)} </span>` : '';
774
+ const hasHeading = !isBlank(heading), hasDescription = !isBlank(description);
775
+ 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>` : '';
776
+ const main = text || footer ? `<div class="fi-callout-main">${text}${footer ? `<div class="fi-callout-footer">${footer}</div>` : ''}</div>` : '';
777
+ const iconHtml = iconName ? icon(iconName, { size: safeClass(this._iconSize) ?? 'lg', attributes: new Attributes({ 'aria-hidden': 'true' }).class([...plainColorClasses(iconColor), 'fi-callout-icon']) }) : '';
778
+ const attributes = new Attributes(this._extraAttributes).class([...plainColorClasses(color), 'fi-sc-callout', 'fi-callout']);
779
+ return `<div ${attributes.toHtml()}>${iconHtml}${main}${controls ? `<div class="fi-callout-controls">${controls}</div>` : ''}</div>`;
780
+ }
781
+ }
782
+ /**
783
+ * Fields joined under a single label: each field keeps only a screen-reader label,
784
+ * and the first validation error among the fields is shown under the group.
785
+ */
786
+ export class FusedGroup extends Component {
787
+ children;
788
+ _columns = {};
789
+ _label = null;
790
+ _hiddenLabel = false;
791
+ _markedAsRequired = false;
792
+ constructor(children = []) { super(); this.children = children; }
793
+ static make(children = []) { return configured(new FusedGroup(children)); }
794
+ schema(components) { this.children = components; return this; }
795
+ columns(columns) { this._columns = columns; return this; }
796
+ label(label) { this._label = label; return this; }
797
+ hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
798
+ /** Asterisk after the label (validation stays per field). */
799
+ markAsRequired(condition = true) { this._markedAsRequired = condition; return this; }
800
+ getChildSchemas() { return [childSchema(this, this.children).columns(this._columns).gap(false).fieldWrapperView('filament-forms::plain-field-wrapper')]; }
801
+ /** First error among the group's fields (including one on an item of a multi-valued field). */
802
+ firstError() {
803
+ const errors = this.livewire._errors ?? {};
804
+ for (const component of this.getChildSchemas()[0].getComponents()) {
805
+ if (!component.hasStatePath())
806
+ continue;
807
+ const path = component.getStatePath();
808
+ if (errors[path]?.length)
809
+ return errors[path][0];
810
+ const nested = Object.keys(errors).find((key) => key.startsWith(`${path}.`) && errors[key].length);
811
+ if (nested)
812
+ return errors[nested][0];
813
+ }
814
+ return null;
815
+ }
816
+ async toHtml() {
817
+ const id = this.getKey();
818
+ const label = await evaluate(this._label, this.context());
819
+ const error = this.firstError();
820
+ const hasLabel = !isBlank(label);
821
+ 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']);
822
+ const labelId = id ? `id="${e(id)}-label" ` : '';
823
+ const labelHtml = !hasLabel ? '' : this._hiddenLabel
824
+ ? `<label ${labelId}class="fi-fo-field-label fi-sr-only">${e(label)}</label>`
825
+ : `<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>`;
826
+ const errorHtml = error !== null ? `<p data-validation-error ${id ? `id="${e(id)}-error" ` : ''}class="fi-fo-field-wrp-error-message">${e(error)}</p>` : '';
827
+ 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>`;
828
+ }
829
+ }