@navette-js/schemas 1.0.0-beta.3 → 1.0.0-beta.5

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.d.ts CHANGED
@@ -15,6 +15,9 @@ export type ComponentContext = {
15
15
  };
16
16
  type SpanValue = number | string | ((ctx: ComponentContext) => number | string | Promise<number | string>);
17
17
  type ColumnSpan = Record<string, SpanValue> | number | string;
18
+ type GridValue = number | null | ((ctx: ComponentContext) => number | null | Promise<number | null>);
19
+ /** Column start or order: a number (from `lg`), an object of breakpoints, or a function returning either (a number then applies to every screen size). */
20
+ export type ColumnPlacement = Record<string, GridValue> | number | null | ((ctx: ComponentContext) => Record<string, number | null> | number | null | Promise<Record<string, number | null> | number | null>);
18
21
  export declare abstract class Component {
19
22
  private _grow;
20
23
  /** Inside a Flex: `false` keeps the natural width instead of filling the available space. */
@@ -26,9 +29,20 @@ export declare abstract class Component {
26
29
  protected _keyName: string | null;
27
30
  protected _hidden: Lazy<boolean, ComponentContext>;
28
31
  protected _visible: Lazy<boolean, ComponentContext>;
32
+ protected _lazyLoaded: Lazy<boolean, ComponentContext>;
33
+ /**
34
+ * Renders the component's content (its child schema) only when it enters the viewport, in a separate request: a loading
35
+ * indicator is shown meanwhile. The component needs a unique key (a heading, or `key()`).
36
+ */
37
+ lazyLoaded(condition?: Lazy<boolean, ComponentContext>): this;
38
+ getLazyLoadedCondition(): Lazy<boolean, ComponentContext>;
29
39
  protected _columnSpan: ColumnSpan;
30
40
  protected _extraAttributes: Record<string, string>;
31
- protected _columnStart: number | null;
41
+ protected _columnStart: ColumnPlacement[];
42
+ protected _columnOrder: ColumnPlacement[];
43
+ protected _gridContainer: Lazy<boolean, ComponentContext>;
44
+ protected _dense: boolean | null;
45
+ protected _gap: boolean | null;
32
46
  container: Schema;
33
47
  hidden(condition?: Lazy<boolean, ComponentContext>): this;
34
48
  visible(condition?: Lazy<boolean, ComponentContext>): this;
@@ -39,8 +53,27 @@ export declare abstract class Component {
39
53
  visibleOn(operations: string | string[]): this;
40
54
  /** A number applies from the `lg` breakpoint; an object replaces the default entirely. Values may be closures. */
41
55
  columnSpan(span: ColumnSpan): this;
42
- /** Starting grid column, from the `lg` breakpoint. */
43
- columnStart(start: number | null): this;
56
+ /** Starting grid column: a number applies from the `lg` breakpoint; an object sets it per breakpoint. Successive calls merge; `null` resets it. */
57
+ columnStart(start: ColumnPlacement): this;
58
+ /** Visual order in the grid, without changing the markup order: a number applies from `lg`; an object sets it per breakpoint. */
59
+ columnOrder(order: ColumnPlacement): this;
60
+ /** Makes the component a CSS container, so that grids inside it may use container breakpoints (`@md`, `@xl`…). */
61
+ gridContainer(condition?: Lazy<boolean, ComponentContext>): this;
62
+ /** Tighter spacing between the components of its child schemas (inherited by nested layouts). */
63
+ dense(condition?: boolean | null): this;
64
+ /** `false`: no space between the components of its child schemas (inherited by nested layouts). */
65
+ gap(condition?: boolean | null): this;
66
+ isDense(): boolean;
67
+ hasGap(): boolean;
68
+ /** Heading level of the component's own headings (`index` for a second heading). */
69
+ getHeadingLevel(index?: number): number;
70
+ /** `h1`…`h6`, or `p` beyond level 6. */
71
+ getHeadingTag(index?: number): string;
72
+ /** Number of heading levels the component adds above its child schemas (1 for a section with a heading). */
73
+ getHeadingsCount(): number;
74
+ getChildSchemaRootHeadingLevel(): number;
75
+ /** Full grid width on every screen size (`columnSpan('full')` only applies from `lg`). */
76
+ columnSpanFull(): this;
44
77
  extraAttributes(attributes: Record<string, string>): this;
45
78
  get livewire(): SchemaHost;
46
79
  isLiberatedFromContainerGrid(): boolean;
@@ -74,8 +107,18 @@ export declare class Schema {
74
107
  private _extraClass;
75
108
  private _partial;
76
109
  private _dense;
110
+ private _parentComponent;
111
+ private _rootHeadingLevel;
77
112
  constructor(livewire: SchemaHost);
78
113
  static make(livewire: SchemaHost): Schema;
114
+ private _lazy;
115
+ private _lazyKey;
116
+ /** Renders the components only when the schema enters the viewport (see `Component.lazyLoaded()`). */
117
+ lazyLoaded(condition?: boolean | (() => boolean | Promise<boolean>)): this;
118
+ isLazyLoaded(): Promise<boolean>;
119
+ /** Key identifying a lazy schema (its own key by default; a layout component's child schema uses the component's key). */
120
+ lazyKey(key: string | null): this;
121
+ getLazyKey(): string;
79
122
  key(key: string): this;
80
123
  components(components: Component[]): this;
81
124
  schema(components: Component[]): this;
@@ -87,8 +130,16 @@ export declare class Schema {
87
130
  record(record: unknown): this;
88
131
  /** Class placed before the grid classes (e.g. `fi-section-content`). */
89
132
  extraClass(name: string | null): this;
90
- /** Tighter spacing between components (`fi-sc-dense`). */
91
- dense(condition?: boolean): this;
133
+ /** Tighter spacing between components (`fi-sc-dense`). Without a value set, a child schema follows its parent component. */
134
+ dense(condition?: boolean | null): this;
135
+ /** Layout component the schema belongs to: spacing and heading levels are inherited from it. */
136
+ parentComponent(component: Component | null): this;
137
+ getParentComponent(): Component | null;
138
+ /** Heading level of the schema's top-level headings (`2` by default: `<h2>`); nested schemas follow their parent. */
139
+ rootHeadingLevel(level: number): this;
140
+ getRootHeadingLevel(): number;
141
+ isDense(): boolean;
142
+ hasGap(): boolean;
92
143
  /** Partially rendered schema: its root carries `wire:partial="schema.<key>"`. */
93
144
  partiallyRendered(condition?: boolean): this;
94
145
  private _partialName;
@@ -96,7 +147,7 @@ export declare class Schema {
96
147
  partialName(name: string | null): this;
97
148
  private _gap;
98
149
  /** `false`: components touch, with no gap (no `fi-sc-has-gap`), as in a FusedGroup. */
99
- gap(condition?: boolean): this;
150
+ gap(condition?: boolean | null): this;
100
151
  private _fieldWrapperView;
101
152
  /**
102
153
  * Field wrapper for this schema: `'filament-forms::plain-field-wrapper'` keeps only a screen-reader label
@@ -116,6 +167,8 @@ export declare class Schema {
116
167
  /** Same, without hidden components or their descendants: a field inside a hidden section is neither validated nor saved. */
117
168
  getVisibleFlatComponents(): Promise<Component[]>;
118
169
  toHtml(): Promise<string>;
170
+ /** Placeholder of a lazy schema: a loading indicator that asks for the schema when it enters the viewport. */
171
+ toDeferredLoadingHtml(): string;
119
172
  }
120
173
  /** Child schema of a layout component: shares the parent's key and state path, without being a root. */
121
174
  export declare function childSchema(parent: Component, components: Component[]): Schema;
@@ -136,6 +189,9 @@ export declare class Group extends Component {
136
189
  static make(children?: Component[]): Group;
137
190
  schema(components: Component[]): this;
138
191
  columns(columns: Record<string, number> | number | null): this;
192
+ /** State path of the components, relative to the container (`address`: their state lives under `address.*`). */
193
+ statePath(path: string | null): this;
194
+ hasStatePath(): boolean;
139
195
  getChildSchemas(): Schema[];
140
196
  toHtml(): Promise<string>;
141
197
  }
@@ -159,25 +215,82 @@ export declare class Flex extends Component {
159
215
  toHtml(): Promise<string>;
160
216
  }
161
217
  export declare const slug: (text: string) => string;
162
- /** Card grouping components, with an optional header (heading, actions). */
218
+ /**
219
+ * Card grouping components, with an optional header (icon, heading, description, components after the header)
220
+ * and footer (actions or components).
221
+ */
163
222
  export declare class Section extends Component {
164
223
  private children;
165
224
  private _columns;
166
225
  private _heading;
226
+ private _description;
227
+ private _icon;
228
+ private _iconColor;
229
+ private _iconSize;
167
230
  private _afterHeader;
168
- constructor(heading?: string | null);
169
- static make(heading?: string | null): Section;
170
- schema(components: Component[]): this;
171
- columns(columns: Record<string, number> | number | null): this;
231
+ private _headerActions;
232
+ private _footer;
233
+ private _footerActions;
234
+ private _footerActionsAlignment;
235
+ private _aside;
236
+ private _contentBefore;
237
+ private _compact;
238
+ private _secondary;
239
+ private _divided;
240
+ private _contained;
172
241
  private _collapsible;
173
242
  private _collapsed;
243
+ private _persistCollapsed;
244
+ constructor(heading?: Lazy<string | null, ComponentContext>);
245
+ static make(heading?: Lazy<string | null, ComponentContext>): Section;
246
+ schema(components: Component[]): this;
247
+ columns(columns: Record<string, number> | number | null): this;
248
+ /** Heading: a string or a function of the context object. */
249
+ heading(heading: Lazy<string | null, ComponentContext>): this;
250
+ /** Text under the heading. */
251
+ description(description: Lazy<string | null, ComponentContext>): this;
252
+ /** Header icon (Heroicons name). */
253
+ icon(name: Lazy<string | null, ComponentContext>): this;
254
+ /** Header icon color (`gray` by default). */
255
+ iconColor(color: Lazy<string | null, ComponentContext>): this;
256
+ /** Header icon size (`lg` by default). */
257
+ iconSize(size: string | null): this;
258
+ /** Heading and description on the left, the content in a card on the right (from the `md` breakpoint). */
259
+ aside(condition?: Lazy<boolean, ComponentContext>): this;
260
+ /** With `aside()`: the content before the heading and description. */
261
+ contentBefore(condition?: Lazy<boolean, ComponentContext>): this;
262
+ /** Smaller paddings, for nested sections. */
263
+ compact(condition?: Lazy<boolean, ComponentContext>): this;
264
+ /** Less contrasting background, for a section on a background of the same color (nested sections…). */
265
+ secondary(condition?: Lazy<boolean, ComponentContext>): this;
266
+ /** Dividing lines between the components of the content. */
267
+ divided(condition?: Lazy<boolean, ComponentContext>): this;
268
+ /** `false`: no card around the section. */
269
+ contained(condition?: Lazy<boolean, ComponentContext>): this;
174
270
  /** Collapsible by clicking its header; `collapsed()` starts it collapsed. */
175
- collapsible(condition?: boolean): this;
176
- collapsed(condition?: boolean): this;
177
- /** Actions shown to the right of the heading. */
178
- afterHeader(actions: Renderable[]): this;
179
- /** Actions the section exposes for mounting. */
271
+ collapsible(condition?: Lazy<boolean, ComponentContext>): this;
272
+ collapsed(condition?: Lazy<boolean, ComponentContext>): this;
273
+ /** Keeps the collapsed state in the browser's local storage, under the section's key. */
274
+ persistCollapsed(condition?: Lazy<boolean, ComponentContext>): this;
275
+ /** Components and actions shown after the heading; replaces `headerActions()`. */
276
+ afterHeader(items: InlineItem[] | null): this;
277
+ /** Actions shown after the heading (when `afterHeader()` is not used). */
278
+ headerActions(actions: Renderable[]): this;
279
+ /** Components and actions in the footer; replaces `footerActions()`. */
280
+ footer(items: InlineItem[] | null): this;
281
+ /** Actions in the footer (when `footer()` is not used). */
282
+ footerActions(actions: Renderable[]): this;
283
+ /** Footer actions alignment (`start`, `center`, `end`, `between`). */
284
+ footerActionsAlignment(alignment: string): this;
285
+ private getHeaderItems;
286
+ private getFooterItems;
287
+ /** Actions the section exposes for mounting (header and footer). */
180
288
  getActions(): Renderable[];
289
+ /** Heading used for the key: a function heading is only used when it returns a string synchronously. */
290
+ private getHeadingForKey;
291
+ /** Key derived from the heading (`rate-limiting::data::section`), unless a `key()` is set. */
292
+ getKey(): string | null;
293
+ getHeadingsCount(): number;
181
294
  getChildSchemas(): Schema[];
182
295
  toHtml(): Promise<string>;
183
296
  }
@@ -191,35 +304,83 @@ export declare class Html extends Component {
191
304
  static make(content: string | ((ctx: ComponentContext) => string | Promise<string>)): Html;
192
305
  toHtml(): Promise<string>;
193
306
  }
194
- /** A tab of a Tabs component: a label, an optional icon and its own schema. */
307
+ /** A tab of a Tabs component: a label, an optional icon and badge, and its own schema. */
195
308
  export declare class Tab extends Component {
196
309
  private children;
197
310
  private _columns;
198
311
  private _icon;
312
+ private _iconPosition;
313
+ private _badge;
314
+ private _badgeColor;
315
+ private _badgeIcon;
316
+ private _badgeIconPosition;
317
+ private _badgeTooltip;
199
318
  private label;
200
319
  constructor(label: string);
201
320
  static make(label: string): Tab;
202
321
  schema(components: Component[]): this;
203
322
  columns(columns: Record<string, number> | number | null): this;
204
323
  icon(name: string | null): this;
324
+ /** Icon before (default) or after the label. */
325
+ iconPosition(position: 'before' | 'after'): this;
326
+ /** Badge after the label (a count…); a function receives the context object. */
327
+ badge(badge: Lazy<string | number | null, ComponentContext>): this;
328
+ /** Badge color (`primary` by default). */
329
+ badgeColor(color: Lazy<string | null, ComponentContext>): this;
330
+ badgeIcon(name: string | null): this;
331
+ badgeIconPosition(position: 'before' | 'after'): this;
332
+ /** Tooltip of the badge (plain text). */
333
+ badgeTooltip(text: Lazy<string | null, ComponentContext>): this;
205
334
  getLabel(): string;
206
335
  getIcon(): string | null;
336
+ getIconPosition(): 'before' | 'after';
337
+ getBadge(): Promise<{
338
+ label: string;
339
+ color: string;
340
+ icon: string | null;
341
+ iconPosition: 'before' | 'after';
342
+ tooltip: string | null;
343
+ } | null>;
207
344
  /** Browser-side tab key (no schema prefix): `::data::` follows the schema state path, and is dropped in infolists. */
208
345
  getTabKey(): string;
209
346
  getChildSchemas(): Schema[];
210
347
  toHtml(): Promise<string>;
211
348
  }
212
- /** Layout tabs: every panel is rendered, the browser only shows the active one. */
349
+ /**
350
+ * Layout tabs: every panel is rendered, the browser only shows the active one.
351
+ * The active tab may be persisted in local storage (`persistTab()`) or in the URL (`persistTabInQueryString()`):
352
+ * the browser only selects a persisted key when it matches an existing tab.
353
+ */
213
354
  export declare class Tabs extends Component {
214
355
  private tabs;
215
356
  private label;
357
+ private _activeTab;
358
+ private _persistTab;
359
+ private _tabQueryStringKey;
360
+ private _vertical;
361
+ private _contained;
362
+ private _scrollable;
216
363
  constructor(label: string);
217
364
  static make(label: string): Tabs;
218
365
  schema(tabs: Tab[]): this;
366
+ /** Tab active when the page loads (1-based, among the visible tabs). */
367
+ activeTab(tab: Lazy<number, ComponentContext>): this;
368
+ /** Remembers the active tab in the browser's local storage, under the key of the tabs. */
369
+ persistTab(condition?: Lazy<boolean, ComponentContext>): this;
370
+ /** Keeps the active tab in the URL's query string (`?tab=…`); `null` disables it. */
371
+ persistTabInQueryString(key?: string | null): this;
372
+ /** Tab headers on the side of the panels (from the `md` breakpoint). */
373
+ vertical(condition?: Lazy<boolean, ComponentContext>): this;
374
+ /** `false`: no card around the tabs. */
375
+ contained(condition?: Lazy<boolean, ComponentContext>): this;
376
+ /** `false`: tabs that do not fit are moved to a "more" dropdown instead of scrolling. */
377
+ scrollable(condition?: Lazy<boolean, ComponentContext>): this;
219
378
  getExposedMethods(): string[];
220
379
  getLazyLoadedTabBadges(): Record<string, unknown>;
221
380
  getDeferredTabBadges(): Record<string, unknown>;
222
381
  getChildSchemas(): Schema[];
382
+ /** Active tab (1-based): the query string's tab when it names a visible tab, otherwise `activeTab()`. */
383
+ private getActiveTab;
223
384
  toHtml(): Promise<string>;
224
385
  }
225
386
  /** Render hook injection point, using the host's scopes. Liberated from the container grid. */
@@ -257,16 +418,37 @@ export interface Renderable {
257
418
  toHtml(ctx: ComponentContext): Promise<string>;
258
419
  }
259
420
  /** Row of actions in a schema. */
421
+ /** Content placed around a component: text, components and actions, or a function of the context returning them. */
422
+ export type ComponentSlot = Lazy<string | HtmlString | InlineItem | (string | HtmlString | InlineItem)[] | null, ComponentContext>;
260
423
  export declare class Actions extends Component {
261
424
  private actions;
262
425
  private _fullWidth;
263
426
  private _alignment;
427
+ private _label;
428
+ private _beforeLabel;
429
+ private _afterLabel;
430
+ private _aboveContent;
431
+ private _belowContent;
264
432
  constructor(actions: Renderable[]);
265
433
  static make(actions: Renderable[]): Actions;
266
434
  fullWidth(condition?: boolean): this;
267
435
  alignment(alignment: string | null): this;
436
+ /** Label shown above the actions, like a field label. */
437
+ label(label: Lazy<string | null, ComponentContext>): this;
438
+ /** Text, components or actions before the label. */
439
+ beforeLabel(content: ComponentSlot): this;
440
+ /** Text, components or actions after the label (a status badge…). */
441
+ afterLabel(content: ComponentSlot): this;
442
+ /** Text, components or actions between the label and the actions. */
443
+ aboveContent(content: ComponentSlot): this;
444
+ /** Text, components or actions below the actions. */
445
+ belowContent(content: ComponentSlot): this;
446
+ /** Actions this component exposes for mounting (its key is sent with each of them). */
447
+ getActions(): Renderable[];
268
448
  toHtml(): Promise<string>;
269
449
  }
450
+ /** Renders a slot of a component as an inline row: strings become `Text` components. */
451
+ export declare function renderSlot(parent: Component, content: ComponentSlot, mountKey?: string | null): Promise<string>;
270
452
  /** Color manager used outside a panel; inside a panel, the host panel's manager takes precedence. */
271
453
  export declare const useColorManager: (manager: ColorManager) => void;
272
454
  /** Item in an inline row: a schema component or an action. */
@@ -330,6 +512,7 @@ export declare class Text extends Component {
330
512
  private _copyableState;
331
513
  private _copyMessage;
332
514
  private _copyMessageDuration;
515
+ private _isJs;
333
516
  constructor(content: Lazy<string | HtmlString | null, ComponentContext>);
334
517
  static make(content: Lazy<string | HtmlString | null, ComponentContext>): Text;
335
518
  content(content: Lazy<string | HtmlString | null, ComponentContext>): this;
@@ -353,6 +536,11 @@ export declare class Text extends Component {
353
536
  copyableState(state: Lazy<string | null, ComponentContext>): this;
354
537
  copyMessage(message: string | null): this;
355
538
  copyMessageDuration(milliseconds: number): this;
539
+ /**
540
+ * The content is a JavaScript expression evaluated in the browser (`$get()` and `$state` are available), re-evaluated when the state changes.
541
+ * Only use it with expressions written by the developer, never with user input.
542
+ */
543
+ js(condition?: boolean): this;
356
544
  getContent(): Promise<string | HtmlString | null>;
357
545
  toHtml(): Promise<string>;
358
546
  }
@@ -436,7 +624,8 @@ export declare class EmptyState extends Component {
436
624
  /** `false`: no surrounding card. */
437
625
  contained(condition?: boolean): this;
438
626
  /** Heading tag (`h2` by default). */
439
- headingTag(tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'): this;
627
+ /** Heading tag; by default it follows the heading level of the schema (`h2` at the root). */
628
+ headingTag(tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | null): this;
440
629
  /** Footer: inline actions and components. */
441
630
  footer(items: InlineItem[]): this;
442
631
  /** Footer actions, mounted on the root schema. */
@@ -460,6 +649,7 @@ export declare class Callout extends Component {
460
649
  private _footerActionsAlignment;
461
650
  private _footer;
462
651
  private _controlActions;
652
+ private _controls;
463
653
  constructor(heading?: Lazy<string | null, ComponentContext>);
464
654
  static make(heading?: Lazy<string | null, ComponentContext>): Callout;
465
655
  heading(heading: Lazy<string | null, ComponentContext>): this;
@@ -485,6 +675,8 @@ export declare class Callout extends Component {
485
675
  footer(items: InlineItem[] | null): this;
486
676
  /** Small actions on the right of the callout (dismiss…). */
487
677
  controlActions(actions: Renderable[]): this;
678
+ /** Free-form controls (components and actions) on the right of the callout, replacing the control actions. */
679
+ controls(items: InlineItem[] | null): this;
488
680
  getColor(): string | null;
489
681
  getIcon(): string | null;
490
682
  /** Callout actions, mounted on the root schema. */
package/lib/index.js CHANGED
@@ -1,10 +1,36 @@
1
1
  // @navette-js/schemas — Schema, Component and the layout components.
2
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';
3
+ import { Attributes, HtmlString, cls, e, evaluate, icon, js, loadingIndicator, renderHook, views, configured, configureUsing } from '@navette-js/support';
4
4
  import { t } from '@navette-js/support/i18n';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { createHash } from 'node:crypto';
7
7
  views.namespace('filament-schemas', fileURLToPath(new URL('../views', import.meta.url)));
8
+ /** Breakpoints accepted in grid objects: viewport (`sm`…`2xl`), container (`@3xs`…`@7xl`) and fallback (`!@sm`…`!@2xl`). */
9
+ const GRID_BREAKPOINT = /^(default|sm|md|lg|xl|2xl|@(3xs|2xs|xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl)|!@(sm|md|lg|xl|2xl))$/;
10
+ /** CSS variable suffix of a breakpoint (`@md` → `cmd`, `!@md` → `ncmd`). */
11
+ const breakpointVariable = (breakpoint) => breakpoint.replace(/@/g, 'c').replace(/!/g, 'n');
12
+ /** Resolves a column start or order: successive calls merge, a function's number result applies to `default`. */
13
+ async function resolvePlacement(entries, ctx, keepZero) {
14
+ const out = {};
15
+ for (const entry of entries) {
16
+ const value = typeof entry === 'function' ? await entry(ctx) : entry;
17
+ if (value === null || value === undefined)
18
+ continue;
19
+ if (typeof value === 'number') {
20
+ out[typeof entry === 'function' ? 'default' : 'lg'] = value;
21
+ continue;
22
+ }
23
+ for (const [breakpoint, item] of Object.entries(value))
24
+ out[breakpoint] = await evaluate(item, ctx);
25
+ }
26
+ const result = {};
27
+ for (const [breakpoint, value] of Object.entries(out)) {
28
+ if (!GRID_BREAKPOINT.test(breakpoint) || typeof value !== 'number' || !Number.isInteger(value) || (!keepZero && value === 0))
29
+ continue;
30
+ result[breakpoint] = value;
31
+ }
32
+ return result;
33
+ }
8
34
  export class Component {
9
35
  _grow = true;
10
36
  /** Inside a Flex: `false` keeps the natural width instead of filling the available space. */
@@ -16,9 +42,20 @@ export class Component {
16
42
  _keyName = null;
17
43
  _hidden = false;
18
44
  _visible = true;
45
+ _lazyLoaded = false;
46
+ /**
47
+ * Renders the component's content (its child schema) only when it enters the viewport, in a separate request: a loading
48
+ * indicator is shown meanwhile. The component needs a unique key (a heading, or `key()`).
49
+ */
50
+ lazyLoaded(condition = true) { this._lazyLoaded = condition; return this; }
51
+ getLazyLoadedCondition() { return this._lazyLoaded; }
19
52
  _columnSpan = { default: 1 };
20
53
  _extraAttributes = {};
21
- _columnStart = null;
54
+ _columnStart = [];
55
+ _columnOrder = [];
56
+ _gridContainer = false;
57
+ _dense = null;
58
+ _gap = null;
22
59
  container;
23
60
  hidden(condition = true) { this._hidden = condition; return this; }
24
61
  visible(condition = true) { this._visible = condition; return this; }
@@ -29,9 +66,25 @@ export class Component {
29
66
  visibleOn(operations) { const list = [].concat(operations); return this.visible(({ operation }) => operation !== null && list.includes(operation)); }
30
67
  /** A number applies from the `lg` breakpoint; an object replaces the default entirely. Values may be closures. */
31
68
  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; }
69
+ /** Starting grid column: a number applies from the `lg` breakpoint; an object sets it per breakpoint. Successive calls merge; `null` resets it. */
70
+ columnStart(start) { this._columnStart = start === null ? [] : [...this._columnStart, start]; return this; }
71
+ /** Visual order in the grid, without changing the markup order: a number applies from `lg`; an object sets it per breakpoint. */
72
+ columnOrder(order) { this._columnOrder = order === null ? [] : [...this._columnOrder, order]; return this; }
73
+ /** Makes the component a CSS container, so that grids inside it may use container breakpoints (`@md`, `@xl`…). */
74
+ gridContainer(condition = true) { this._gridContainer = condition; return this; }
75
+ /** Tighter spacing between the components of its child schemas (inherited by nested layouts). */
76
+ dense(condition = true) { this._dense = condition; return this; }
77
+ /** `false`: no space between the components of its child schemas (inherited by nested layouts). */
78
+ gap(condition = true) { this._gap = condition; return this; }
79
+ isDense() { return this._dense ?? this.container.isDense(); }
80
+ hasGap() { return this._gap ?? this.container.hasGap(); }
81
+ /** Heading level of the component's own headings (`index` for a second heading). */
82
+ getHeadingLevel(index = 0) { return Math.max(1, this.container.getRootHeadingLevel() + index); }
83
+ /** `h1`…`h6`, or `p` beyond level 6. */
84
+ getHeadingTag(index = 0) { const level = this.getHeadingLevel(index); return level > 6 ? 'p' : `h${level}`; }
85
+ /** Number of heading levels the component adds above its child schemas (1 for a section with a heading). */
86
+ getHeadingsCount() { return 0; }
87
+ getChildSchemaRootHeadingLevel() { return this.getHeadingLevel() + this.getHeadingsCount(); }
35
88
  /** Full grid width on every screen size (`columnSpan('full')` only applies from `lg`). */
36
89
  columnSpanFull() { return this.columnSpan({ default: 'full' }); }
37
90
  extraAttributes(attributes) { Object.assign(this._extraAttributes, attributes); return this; }
@@ -69,16 +122,23 @@ export class Component {
69
122
  const key = this.getKey();
70
123
  const span = {};
71
124
  const rawSpan = typeof this._columnSpan === 'object' ? this._columnSpan : { default: 1, lg: this._columnSpan };
125
+ const ctx = this.context();
72
126
  for (const [breakpoint, value] of Object.entries(rawSpan))
73
- span[breakpoint] = await evaluate(value, this.context());
127
+ span[breakpoint] = await evaluate(value, ctx);
128
+ const start = await resolvePlacement(this._columnStart, ctx, false);
129
+ const order = await resolvePlacement(this._columnOrder, ctx, true);
130
+ const placementClass = (name, values) => Object.keys(values).map((breakpoint) => (breakpoint === 'default' ? `fi-grid-col-${name}` : `${breakpoint}:fi-grid-col-${name}`));
131
+ const placementStyle = (name, values) => Object.entries(values).map(([breakpoint, value]) => `--col-${name}-${breakpointVariable(breakpoint)}: ${value}`);
74
132
  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}`]))
133
+ .when(!this.container.isInline(), (a) => a.gridColumn(span, !isVisible)
134
+ .class([...placementClass('start', start), ...placementClass('order', order)])
135
+ .style([...placementStyle('start', start), ...placementStyle('order', order)]))
77
136
  .merge({ 'wire:key': this.getLivewireKey() });
78
137
  return views.render('filament-schemas::component', {
79
138
  key, isVisible, attributes: attributes.toHtml(),
80
139
  alpine: isVisible ? `filamentSchemaComponent(${js(this.getAlpineScopeConfiguration())})` : null,
81
140
  html: isVisible ? await this.toHtml() : '',
141
+ gridContainer: isVisible && Boolean(await evaluate(this._gridContainer, ctx)),
82
142
  });
83
143
  }
84
144
  }
@@ -94,9 +154,19 @@ export class Schema {
94
154
  _record = null;
95
155
  _extraClass = null;
96
156
  _partial = false;
97
- _dense = false;
157
+ _dense = null;
158
+ _parentComponent = null;
159
+ _rootHeadingLevel = 2;
98
160
  constructor(livewire) { this.livewire = livewire; }
99
161
  static make(livewire) { return configured(new Schema(livewire)); }
162
+ _lazy = false;
163
+ _lazyKey = null;
164
+ /** Renders the components only when the schema enters the viewport (see `Component.lazyLoaded()`). */
165
+ lazyLoaded(condition = true) { this._lazy = condition; return this; }
166
+ async isLazyLoaded() { return Boolean(typeof this._lazy === 'function' ? await this._lazy() : this._lazy); }
167
+ /** Key identifying a lazy schema (its own key by default; a layout component's child schema uses the component's key). */
168
+ lazyKey(key) { this._lazyKey = key; return this; }
169
+ getLazyKey() { return this._lazyKey ?? this._key; }
100
170
  key(key) { this._key = key; return this; }
101
171
  components(components) { this._components = components; for (const c of components)
102
172
  c.container = this; return this; }
@@ -109,14 +179,23 @@ export class Schema {
109
179
  record(record) { this._record = record; return this; }
110
180
  /** Class placed before the grid classes (e.g. `fi-section-content`). */
111
181
  extraClass(name) { this._extraClass = name; return this; }
112
- /** Tighter spacing between components (`fi-sc-dense`). */
182
+ /** Tighter spacing between components (`fi-sc-dense`). Without a value set, a child schema follows its parent component. */
113
183
  dense(condition = true) { this._dense = condition; return this; }
184
+ /** Layout component the schema belongs to: spacing and heading levels are inherited from it. */
185
+ parentComponent(component) { this._parentComponent = component; return this; }
186
+ getParentComponent() { return this._parentComponent; }
187
+ /** Heading level of the schema's top-level headings (`2` by default: `<h2>`); nested schemas follow their parent. */
188
+ rootHeadingLevel(level) { if (Number.isInteger(level) && level >= 1)
189
+ this._rootHeadingLevel = level; return this; }
190
+ getRootHeadingLevel() { return this._parentComponent ? this._parentComponent.getChildSchemaRootHeadingLevel() : this._rootHeadingLevel; }
191
+ isDense() { return this._dense ?? (this._inline || (this._parentComponent?.isDense() ?? false)); }
192
+ hasGap() { return this._gap ?? this._parentComponent?.hasGap() ?? true; }
114
193
  /** Partially rendered schema: its root carries `wire:partial="schema.<key>"`. */
115
194
  partiallyRendered(condition = true) { this._partial = condition; return this; }
116
195
  _partialName = null;
117
196
  /** Root `wire:partial` name when it differs from the schema key (filters form: `table-filters-form`). */
118
197
  partialName(name) { this._partialName = name; return this; }
119
- _gap = true;
198
+ _gap = null;
120
199
  /** `false`: components touch, with no gap (no `fi-sc-has-gap`), as in a FusedGroup. */
121
200
  gap(condition = true) { this._gap = condition; return this; }
122
201
  _fieldWrapperView = null;
@@ -150,18 +229,21 @@ export class Schema {
150
229
  return out;
151
230
  }
152
231
  async toHtml() {
232
+ const isLazy = await this.isLazyLoaded();
233
+ if (isLazy && typeof this.livewire.isDeferredSchemaLoaded === 'function' && !(await this.livewire.isDeferredSchemaLoaded(this)))
234
+ return this.toDeferredLoadingHtml();
153
235
  const withVisibility = await Promise.all(this._components.map(async (c) => [c, await c.isVisible()]));
154
236
  if (!withVisibility.some(([, v]) => v))
155
237
  return '';
156
238
  const attributes = new Attributes(this._extraClass ? { class: this._extraClass } : {})
157
239
  .when(!this._inline, (a) => a.grid(this._columns))
158
240
  .merge({
159
- 'wire:partial': this._partialName ?? (this._partial ? `schema.${this._key}` : null),
241
+ 'wire:partial': this._partialName ?? (this._partial ? `schema.${this._key}` : isLazy ? `schema.${this.getLazyKey()}` : null),
160
242
  'x-data': this._root ? `filamentSchema({ livewireId: ${js(this.livewire.id)}, schemaKey: ${js(this._key)} })` : null,
161
243
  'x-on:form-validation-error.window': this._root ? 'handleFormValidationError' : null,
162
244
  'x-on:reset-schema-component-state.window': this._root ? 'handleClientSideStateReset' : null,
163
245
  })
164
- .class(['fi-sc', { 'fi-inline': this._inline }, { 'fi-sc-has-gap': this._gap }, { 'fi-sc-dense': this._dense }]);
246
+ .class(['fi-sc', { 'fi-inline': this._inline }, { 'fi-sc-has-gap': this.hasGap() }, { 'fi-sc-dense': this.isDense() }]);
165
247
  const parts = [];
166
248
  for (const [component, isVisible] of withVisibility) {
167
249
  if (!component.isLiberatedFromContainerGrid())
@@ -171,11 +253,21 @@ export class Schema {
171
253
  }
172
254
  return `<div ${attributes.toHtml()}>${parts.join('')}</div>`;
173
255
  }
256
+ /** Placeholder of a lazy schema: a loading indicator that asks for the schema when it enters the viewport. */
257
+ toDeferredLoadingHtml() {
258
+ const key = this.getLazyKey();
259
+ const attributes = new Attributes(this._extraClass ? { class: this._extraClass } : {}).merge({ 'wire:partial': `schema.${key}`, 'x-data': `filamentSchema({ livewireId: ${js(this.livewire.id)}, schemaKey: ${js(key)}, isLoadingDeferred: true })`, role: 'status', 'aria-busy': 'true' }).class(['fi-sc', 'fi-sc-loading']);
260
+ return `<div ${attributes.toHtml()}>${loadingIndicator(new Attributes(), 'lg')}<span class="fi-sr-only">${e(t('filament::components/loading-section.label'))}</span></div>`;
261
+ }
174
262
  }
175
263
  /** Child schema of a layout component: shares the parent's key and state path, without being a root. */
176
264
  export function childSchema(parent, components) {
177
265
  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);
266
+ const schema = new Schema(c.livewire).key(c.getKey()).statePath(c.getStatePath() || null).operation(c.getOperation()).record(c.getRecord()).root(false).parentComponent(parent).components(components);
267
+ const lazy = parent.getLazyLoadedCondition();
268
+ if (lazy !== false)
269
+ schema.lazyLoaded(typeof lazy === 'function' ? () => evaluate(lazy, parent.context()) : lazy).lazyKey(parent.getKey());
270
+ return schema;
179
271
  }
180
272
  /** Stateful group: its children live under `<container path>.<name>` (used by filter forms). */
181
273
  export class StatefulGroup extends Component {
@@ -185,7 +277,7 @@ export class StatefulGroup extends Component {
185
277
  hasStatePath() { return true; }
186
278
  getChildSchemas() {
187
279
  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)];
280
+ return [new Schema(c.livewire).key(this.getKey()).statePath(this.getStatePath()).operation(c.getOperation()).record(c.getRecord()).root(false).parentComponent(this).components(this.children)];
189
281
  }
190
282
  async toHtml() { return `<div id="${e(this.getKey())}">${await this.getChildSchemas()[0].toHtml()}</div>`; }
191
283
  }
@@ -197,8 +289,19 @@ export class Group extends Component {
197
289
  static make(children = []) { return configured(new Group(children)); }
198
290
  schema(components) { this.children = components; return this; }
199
291
  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>`; }
292
+ /** State path of the components, relative to the container (`address`: their state lives under `address.*`). */
293
+ statePath(path) { this._name = path; return this; }
294
+ hasStatePath() { return this._name !== null; }
295
+ getChildSchemas() {
296
+ const schema = childSchema(this, this.children).columns(this._columns);
297
+ if (this.hasStatePath())
298
+ schema.key(this.getKey()).statePath(this.getStatePath());
299
+ return [schema];
300
+ }
301
+ async toHtml() {
302
+ const attributes = new Attributes().merge({ ...this._extraAttributes }).toHtml();
303
+ return `<div${attributes ? ` ${attributes}` : ''}>${await this.getChildSchemas()[0].toHtml()}</div>`;
304
+ }
202
305
  }
203
306
  /** Column grid: two columns from `lg` by default. */
204
307
  export class Grid extends Group {
@@ -228,55 +331,159 @@ export class Flex extends Component {
228
331
  }
229
332
  }
230
333
  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). */
334
+ /** Footer alignment (`start`, `center`, `end`, `between`; `right` and `justify` are aliases) as an inline schema alignment. */
335
+ const footerAlignment = (alignment) => (['right', 'end'].includes(alignment) ? 'end' : alignment === 'justify' ? 'between' : ['start', 'center', 'between'].includes(alignment) ? alignment : 'start');
336
+ /**
337
+ * Card grouping components, with an optional header (icon, heading, description, components after the header)
338
+ * and footer (actions or components).
339
+ */
232
340
  export class Section extends Component {
233
341
  children = [];
234
342
  _columns = {};
235
343
  _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
- }
344
+ _description = null;
345
+ _icon = null;
346
+ _iconColor = null;
347
+ _iconSize = null;
348
+ _afterHeader = null;
349
+ _headerActions = [];
350
+ _footer = null;
351
+ _footerActions = [];
352
+ _footerActionsAlignment = 'start';
353
+ _aside = false;
354
+ _contentBefore = false;
355
+ _compact = false;
356
+ _secondary = false;
357
+ _divided = false;
358
+ _contained = true;
359
+ _collapsible = null;
360
+ _collapsed = false;
361
+ _persistCollapsed = false;
362
+ constructor(heading = null) { super(); this._heading = heading; }
244
363
  static make(heading = null) { return configured(new Section(heading)); }
245
364
  schema(components) { this.children = components; return this; }
246
365
  columns(columns) { this._columns = columns; return this; }
247
- _collapsible = false;
248
- _collapsed = false;
366
+ /** Heading: a string or a function of the context object. */
367
+ heading(heading) { this._heading = heading; return this; }
368
+ /** Text under the heading. */
369
+ description(description) { this._description = description; return this; }
370
+ /** Header icon (Heroicons name). */
371
+ icon(name) { this._icon = name; return this; }
372
+ /** Header icon color (`gray` by default). */
373
+ iconColor(color) { this._iconColor = color; return this; }
374
+ /** Header icon size (`lg` by default). */
375
+ iconSize(size) { this._iconSize = size; return this; }
376
+ /** Heading and description on the left, the content in a card on the right (from the `md` breakpoint). */
377
+ aside(condition = true) { this._aside = condition; return this; }
378
+ /** With `aside()`: the content before the heading and description. */
379
+ contentBefore(condition = true) { this._contentBefore = condition; return this; }
380
+ /** Smaller paddings, for nested sections. */
381
+ compact(condition = true) { this._compact = condition; return this; }
382
+ /** Less contrasting background, for a section on a background of the same color (nested sections…). */
383
+ secondary(condition = true) { this._secondary = condition; return this; }
384
+ /** Dividing lines between the components of the content. */
385
+ divided(condition = true) { this._divided = condition; return this; }
386
+ /** `false`: no card around the section. */
387
+ contained(condition = true) { this._contained = condition; return this; }
249
388
  /** Collapsible by clicking its header; `collapsed()` starts it collapsed. */
250
389
  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; }
390
+ collapsed(condition = true) { this._collapsed = condition; if (this._collapsible === null)
391
+ this._collapsible = condition; return this; }
392
+ /** Keeps the collapsed state in the browser's local storage, under the section's key. */
393
+ persistCollapsed(condition = true) { this._persistCollapsed = condition; return this; }
394
+ /** Components and actions shown after the heading; replaces `headerActions()`. */
395
+ afterHeader(items) { this._afterHeader = items; return this; }
396
+ /** Actions shown after the heading (when `afterHeader()` is not used). */
397
+ headerActions(actions) { this._headerActions = [...this._headerActions, ...actions]; return this; }
398
+ /** Components and actions in the footer; replaces `footerActions()`. */
399
+ footer(items) { this._footer = items; return this; }
400
+ /** Actions in the footer (when `footer()` is not used). */
401
+ footerActions(actions) { this._footerActions = [...this._footerActions, ...actions]; return this; }
402
+ /** Footer actions alignment (`start`, `center`, `end`, `between`). */
403
+ footerActionsAlignment(alignment) { this._footerActionsAlignment = alignment; return this; }
404
+ getHeaderItems() { return this._afterHeader ?? this._headerActions; }
405
+ getFooterItems() { return this._footer ?? this._footerActions; }
406
+ /** Actions the section exposes for mounting (header and footer). */
407
+ getActions() { return [...actionsOf(this.getHeaderItems()), ...actionsOf(this.getFooterItems())]; }
408
+ /** Heading used for the key: a function heading is only used when it returns a string synchronously. */
409
+ getHeadingForKey() {
410
+ if (typeof this._heading !== 'function')
411
+ return this._heading;
412
+ let value;
413
+ try {
414
+ value = this._heading(this.context());
415
+ }
416
+ catch {
417
+ return null;
418
+ }
419
+ // An async heading is not awaited here: its failure is reported when the section renders.
420
+ if (value instanceof Promise)
421
+ value.catch(() => { });
422
+ return typeof value === 'string' ? value : null;
423
+ }
424
+ /** Key derived from the heading (`rate-limiting::data::section`), unless a `key()` is set. */
425
+ getKey() {
426
+ if (this._keyName !== null)
427
+ return super.getKey();
428
+ const heading = this.getHeadingForKey();
429
+ if (isBlank(heading))
430
+ return null;
431
+ const statePath = this.container.getStatePath();
432
+ return `${this.container.getKey()}.${slug(heading)}::${statePath ? `${statePath}::` : ''}section`;
433
+ }
434
+ getHeadingsCount() { return typeof this._heading === 'function' || !isBlank(this._heading) ? 1 : 0; }
257
435
  getChildSchemas() { return [childSchema(this, this.children).columns(this._columns).extraClass('fi-section-content')]; }
258
436
  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
437
  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>`);
438
+ const [content] = this.getChildSchemas();
439
+ const id = this.getKey();
440
+ const heading = await evaluate(this._heading, ctx);
441
+ const description = await evaluate(this._description, ctx);
442
+ const iconName = await evaluate(this._icon, ctx);
443
+ const isAside = Boolean(await evaluate(this._aside, ctx));
444
+ const isCollapsible = Boolean(await evaluate(this._collapsible ?? false, ctx)) && !isAside;
445
+ const isCollapsed = Boolean(await evaluate(this._collapsed, ctx));
446
+ const shouldPersistCollapsed = Boolean(await evaluate(this._persistCollapsed, ctx));
447
+ const hasHeading = !isBlank(heading), hasDescription = !isBlank(description), hasIcon = !isBlank(iconName);
448
+ const afterHeader = await renderInlineItems(this, this.getHeaderItems(), { alignment: 'end' });
449
+ const footer = await renderInlineItems(this, this.getFooterItems(), { alignment: this._footer === null ? footerAlignment(this._footerActionsAlignment) : null });
450
+ const contentHtml = await content.toHtml();
451
+ const hasHeader = hasIcon || hasHeading || hasDescription || isCollapsible || afterHeader !== '';
452
+ const hasContent = contentHtml !== '' || footer !== '';
453
+ const contentId = id !== null && hasContent ? `${id}-content` : null;
454
+ const headingId = id !== null && hasHeading ? `${id}-heading` : null;
455
+ const descriptionId = id !== null && hasDescription ? `${id}-description` : null;
456
+ const outer = new Attributes().merge({ id, ...this._extraAttributes }).class(['fi-sc-section']);
273
457
  // 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>`;
458
+ // A persisted state lives in local storage, under the section's key (or its element id).
459
+ const idTest = `if ($event.detail.id == (${js(id)} ?? $el.id))`;
460
+ const collapse = isCollapsible ? ` 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`)}"${shouldPersistCollapsed ? '' : ` x-on:reset-schema-component-state.window="${e(`if (($event.detail.livewireId === ${js(this.livewire.id)}) && ($event.detail.schemaKey === ${js(this.container.getKey().split('.')[0])})) $nextTick(() => isCollapsed = ${isCollapsed ? 'true' : 'false'})`)}"`} x-bind:class="isCollapsed &amp;&amp; 'fi-collapsed'"` : '';
461
+ const collapsedState = shouldPersistCollapsed ? e(`$persist(${isCollapsed ? 'true' : 'false'}).as(\`filament:section:collapsed:\${${js(id)} ?? $el.id}\`)`) : (isCollapsed ? 'true' : 'false');
462
+ const sectionClass = cls(['fi-section', {
463
+ 'fi-section-not-contained': !(await evaluate(this._contained, ctx)),
464
+ 'fi-section-has-content-before': Boolean(await evaluate(this._contentBefore, ctx)),
465
+ 'fi-section-has-header': hasHeader,
466
+ 'fi-aside': isAside,
467
+ 'fi-compact': Boolean(await evaluate(this._compact, ctx)),
468
+ 'fi-collapsible': isCollapsible,
469
+ 'fi-collapsed': isCollapsible && isCollapsed,
470
+ 'fi-divided': Boolean(await evaluate(this._divided, ctx)),
471
+ 'fi-secondary': Boolean(await evaluate(this._secondary, ctx)),
472
+ }]);
473
+ let header = '';
474
+ if (hasHeader) {
475
+ const iconColor = (await evaluate(this._iconColor, ctx)) ?? 'gray';
476
+ const iconHtml = hasIcon ? icon(iconName, { size: safeClass(this._iconSize) ?? 'lg', attributes: new Attributes().class(plainColorClasses(iconColor)) }) : '';
477
+ const tag = this.getHeadingTag();
478
+ const text = hasHeading || hasDescription ? `<div class="fi-section-header-text-ctn">${hasHeading ? `<${tag}${headingId ? ` id="${e(headingId)}"` : ''} class="fi-section-header-heading">${e(heading)}</${tag}>` : ''}${hasDescription ? `<p${descriptionId ? ` id="${e(descriptionId)}"` : ''} class="fi-section-header-description">${e(description)}</p>` : ''}</div>` : '';
479
+ const headerContent = hasHeading || hasDescription || afterHeader ? `<div class="${cls(['fi-section-header-content', { 'fi-section-header-content-has-heading': hasHeading || hasDescription }])}">${text}${afterHeader ? `<div class="fi-section-header-after-ctn">${afterHeader}</div>` : ''}</div>` : '';
480
+ const collapseButton = isCollapsible ? `<button type="button" x-on:click.stop="isCollapsed = ! isCollapsed" aria-label="${e(t(isCollapsed ? '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="${isCollapsed ? 'false' : 'true'}" x-bind:aria-expanded="(! isCollapsed).toString()"${contentId ? ` aria-controls="${e(contentId)}"` : ''} class="fi-icon-btn fi-size-md fi-section-collapse-btn">${icon('m-chevron-up')}</button>` : '';
481
+ header = `<header${isCollapsible ? ` x-on:click="if (! $event.target.closest('.fi-section-header-after-ctn')) isCollapsed = ! isCollapsed"` : ''} class="fi-section-header">${iconHtml}${headerContent}${collapseButton}</header>`;
482
+ }
483
+ const body = hasContent ? `<div${contentId ? ` id="${e(contentId)}"` : ''}${isCollapsible && (isCollapsed || shouldPersistCollapsed) ? ' x-cloak' : ''} class="fi-section-content-ctn">${contentHtml}${footer ? `<footer class="fi-section-footer">${footer}</footer>` : ''}</div>` : '';
484
+ return `<div ${outer.toHtml()}><section x-data="{
485
+ isCollapsed: ${collapsedState},
486
+ }"${collapse}${headingId ? ` aria-labelledby="${e(headingId)}"` : ''}${descriptionId ? ` aria-describedby="${e(descriptionId)}"` : ''} class="${sectionClass}">${header}${body}</section></div>`;
280
487
  }
281
488
  }
282
489
  /**
@@ -289,71 +496,167 @@ export class Html extends Component {
289
496
  static make(content) { return new Html(content); }
290
497
  async toHtml() { return typeof this.content === 'function' ? this.content(this.context()) : this.content; }
291
498
  }
292
- /** A tab of a Tabs component: a label, an optional icon and its own schema. */
499
+ /** A tab of a Tabs component: a label, an optional icon and badge, and its own schema. */
293
500
  export class Tab extends Component {
294
501
  children = [];
295
502
  _columns = {};
296
503
  _icon = null;
504
+ _iconPosition = 'before';
505
+ _badge = null;
506
+ _badgeColor = null;
507
+ _badgeIcon = null;
508
+ _badgeIconPosition = 'before';
509
+ _badgeTooltip = null;
297
510
  label;
298
511
  constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::tab`; }
299
512
  static make(label) { return configured(new Tab(label)); }
300
513
  schema(components) { this.children = components; return this; }
301
514
  columns(columns) { this._columns = columns; return this; }
302
515
  icon(name) { this._icon = name; return this; }
516
+ /** Icon before (default) or after the label. */
517
+ iconPosition(position) { this._iconPosition = position === 'after' ? 'after' : 'before'; return this; }
518
+ /** Badge after the label (a count…); a function receives the context object. */
519
+ badge(badge) { this._badge = badge; return this; }
520
+ /** Badge color (`primary` by default). */
521
+ badgeColor(color) { this._badgeColor = color; return this; }
522
+ badgeIcon(name) { this._badgeIcon = name; return this; }
523
+ badgeIconPosition(position) { this._badgeIconPosition = position === 'after' ? 'after' : 'before'; return this; }
524
+ /** Tooltip of the badge (plain text). */
525
+ badgeTooltip(text) { this._badgeTooltip = text; return this; }
303
526
  getLabel() { return this.label; }
304
527
  getIcon() { return this._icon; }
528
+ getIconPosition() { return this._iconPosition; }
529
+ async getBadge() {
530
+ const ctx = this.context();
531
+ const badge = await evaluate(this._badge, ctx);
532
+ if (isBlank(badge))
533
+ return null;
534
+ return { label: String(badge), color: (await evaluate(this._badgeColor, ctx)) ?? 'primary', icon: this._badgeIcon, iconPosition: this._badgeIconPosition, tooltip: await evaluate(this._badgeTooltip, ctx) };
535
+ }
305
536
  /** Browser-side tab key (no schema prefix): `::data::` follows the schema state path, and is dropped in infolists. */
306
537
  getTabKey() { const statePath = this.container.getStatePath(); return this._keyName.replace('::data::', statePath ? `::${statePath}::` : '::'); }
307
538
  getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
308
539
  async toHtml() { return this.getChildSchemas()[0].toHtml(); }
309
540
  }
310
- /** Layout tabs: every panel is rendered, the browser only shows the active one. */
541
+ /** Badge colors of a tab: computed shades when a color manager is available. */
542
+ const badgeColorClasses = (component, color) => colorsFor(component)?.getComponentClasses('badge', color) ?? plainColorClasses(color);
543
+ const badgeTooltipAttribute = (tooltip) => (tooltip ? ` x-tooltip="${e(`{ content: ${js(tooltip)}, theme: $store.theme, allowHTML: false }`)}"` : '');
544
+ /**
545
+ * Layout tabs: every panel is rendered, the browser only shows the active one.
546
+ * The active tab may be persisted in local storage (`persistTab()`) or in the URL (`persistTabInQueryString()`):
547
+ * the browser only selects a persisted key when it matches an existing tab.
548
+ */
311
549
  export class Tabs extends Component {
312
550
  tabs = [];
313
551
  label;
552
+ _activeTab = 1;
553
+ _persistTab = false;
554
+ _tabQueryStringKey = null;
555
+ _vertical = false;
556
+ _contained = true;
557
+ _scrollable = true;
314
558
  constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::tabs`; }
315
559
  static make(label) { return configured(new Tabs(label)); }
316
560
  schema(tabs) { this.tabs = tabs; return this; }
561
+ /** Tab active when the page loads (1-based, among the visible tabs). */
562
+ activeTab(tab) { this._activeTab = tab; return this; }
563
+ /** Remembers the active tab in the browser's local storage, under the key of the tabs. */
564
+ persistTab(condition = true) { this._persistTab = condition; return this; }
565
+ /** Keeps the active tab in the URL's query string (`?tab=…`); `null` disables it. */
566
+ persistTabInQueryString(key = 'tab') { this._tabQueryStringKey = key === null || key.trim() === '' ? null : key; return this; }
567
+ /** Tab headers on the side of the panels (from the `md` breakpoint). */
568
+ vertical(condition = true) { this._vertical = condition; return this; }
569
+ /** `false`: no card around the tabs. */
570
+ contained(condition = true) { this._contained = condition; return this; }
571
+ /** `false`: tabs that do not fit are moved to a "more" dropdown instead of scrolling. */
572
+ scrollable(condition = true) { this._scrollable = condition; return this; }
317
573
  getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
318
574
  getLazyLoadedTabBadges() { return {}; }
319
575
  getDeferredTabBadges() { return {}; }
320
576
  getChildSchemas() { return [childSchema(this, this.tabs)]; }
577
+ /** Active tab (1-based): the query string's tab when it names a visible tab, otherwise `activeTab()`. */
578
+ async getActiveTab(visible) {
579
+ if (this._tabQueryStringKey !== null) {
580
+ const requested = this.livewire._ctx?.req?.query?.[this._tabQueryStringKey];
581
+ const index = typeof requested === 'string' ? visible.findIndex((tab) => tab.getTabKey() === requested) : -1;
582
+ if (index !== -1)
583
+ return index + 1;
584
+ }
585
+ const active = await evaluate(this._activeTab, this.context());
586
+ return Number.isInteger(active) && active >= 1 ? active : 1;
587
+ }
321
588
  async toHtml() {
322
589
  // Building the child schema attaches the tabs to their container, which is what gives them their keys.
323
590
  this.getChildSchemas();
591
+ const ctx = this.context();
324
592
  const key = this.getKey();
325
593
  const id = this.livewire.id;
326
- const q = (k) => `'${k.replace(/[^A-Za-z0-9:_-]/g, '')}'`;
594
+ const q = (k) => js(k);
327
595
  const tabId = (tab) => `fi-sc-tab-${createHash('md5').update(tab.getKey()).digest('hex')}`;
328
596
  const visible = [];
329
597
  for (const tab of this.tabs)
330
598
  if (await tab.isVisible())
331
599
  visible.push(tab);
332
- const buttons = visible.map((tab) => {
600
+ const isContained = Boolean(await evaluate(this._contained, ctx));
601
+ const isVertical = Boolean(await evaluate(this._vertical, ctx));
602
+ const isScrollable = Boolean(await evaluate(this._scrollable, ctx));
603
+ const isTabPersisted = Boolean(await evaluate(this._persistTab, ctx));
604
+ const activeTab = await this.getActiveTab(visible);
605
+ const badges = await Promise.all(visible.map((tab) => tab.getBadge()));
606
+ const badgeHtml = (badge, withIcon) => {
607
+ const badgeIcon = withIcon && badge.icon ? icon(badge.icon, { size: 'sm' }) : '';
608
+ return `<span${badgeTooltipAttribute(badge.tooltip)} class="${cls(['fi-badge', 'fi-size-sm', ...badgeColorClasses(this, badge.color)])}">${badge.iconPosition === 'before' ? badgeIcon : ''}<span class="fi-badge-label-ctn"><span class="fi-badge-label">${e(badge.label)}</span></span>${badge.iconPosition === 'after' ? badgeIcon : ''}</span>`;
609
+ };
610
+ const buttons = visible.map((tab, index) => {
333
611
  const k = tab.getTabKey();
612
+ const tabIcon = tab.getIcon() ? icon(tab.getIcon()) : '';
613
+ const badge = badges[index];
334
614
  return `<button type="button" x-bind:class="{
335
615
  'fi-active': isTabSelected(${q(k)}),
336
616
  'fi-sc-tabs-header-overflow': overflowTabs.includes(${q(k)}),
337
617
  '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>`;
618
+ }" 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.getIconPosition() === 'before' ? tabIcon : ''}<span class="fi-tabs-item-label">${e(tab.getLabel())}</span>${tab.getIconPosition() === 'after' ? tabIcon : ''}${badge ? badgeHtml(badge, true) : ''}</button>`;
339
619
  });
620
+ // Not scrollable: tabs that do not fit move to an overflow dropdown, opened from a "more" trigger.
621
+ let overflow = '';
622
+ if (!isScrollable) {
623
+ const more = t('filament-schemas::components.tabs.actions.more.label');
624
+ const triggers = visible.map((tab, index) => {
625
+ const k = tab.getTabKey();
626
+ const badge = badges[index];
627
+ return `<button type="button" tabindex="-1" aria-hidden="true" data-tabs-overflow-trigger="data-tabs-overflow-trigger" data-tab-trigger-key="${e(k)}" aria-label="${e(`${more}: ${tab.getLabel()}`)}" x-show="overflowReady &amp;&amp; overflowTabs.includes(${q(k)}) &amp;&amp; isTabSelected(${q(k)})" class="fi-tabs-item fi-active">${icon('m-chevron-down')}<span class="fi-tabs-item-label">${e(tab.getLabel())}</span>${badge ? badgeHtml(badge, false) : ''}</button>`;
628
+ });
629
+ const items = visible.map((tab, index) => {
630
+ const k = tab.getTabKey();
631
+ const badge = badges[index];
632
+ const listBadge = badge ? `<span${badgeTooltipAttribute(badge.tooltip)} class="${cls(['fi-badge', ...badgeColorClasses(this, badge.color)])}">${e(badge.label)}</span>` : '';
633
+ return `<div data-tab-menu-key="${e(k)}" x-bind:aria-disabled="availableTabs.includes(${q(k)}) ? 'false' : 'true'" x-show="(true) &amp;&amp; overflowTabs.includes(${q(k)})" class="fi-dropdown-list-item fi-sc-tabs-overflow-placeholder">${tab.getIcon() ? icon(tab.getIcon()) : ''}<span class="fi-dropdown-list-item-label">${e(tab.getLabel())}</span>${listBadge}</div>`;
634
+ });
635
+ overflow = `<div aria-hidden="true" class="fi-dropdown"><div x-on:mousedown="if ($event.button === 0) { $event.preventDefault(); toggleOverflow() }" class="fi-dropdown-trigger">${triggers.join('')}<button type="button" data-tabs-overflow-trigger data-tabs-overflow-ellipsis tabindex="-1" aria-hidden="true" aria-label="${e(more)}" class="fi-tabs-item" x-show="! overflowReady || (overflowTabs.some(key =&gt; availableTabs.includes(key)) &amp;&amp; ! overflowTabs.includes(tab))"><span class="fi-tabs-item-label">${icon('m-ellipsis-horizontal')}</span></button></div><div x-cloak x-ref="overflowPanel" x-show="isOverflowOpen" aria-hidden="true" x-on:mousedown.self.prevent x-on:scroll="scheduleUpdate()" x-on:click.outside="if (! $el.closest('.fi-sc-tabs').querySelector('[x-ref=tablist]').contains($event.target) &amp;&amp; ! getOverflowTrigger()?.contains($event.target)) dismissOverflow()" class="fi-dropdown-panel fi-sc-tabs-overflow-panel"><div class="fi-dropdown-list">${items.join('')}</div></div></div>`;
636
+ }
340
637
  const panels = [];
341
638
  for (const tab of visible) {
342
639
  const k = tab.getTabKey();
343
640
  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
641
  }
642
+ const outer = new Attributes().merge({ id: key, 'wire:key': `${id}.${key}.container`, ...this._extraAttributes }).class(['fi-sc-tabs', { 'fi-contained': isContained, 'fi-vertical': isVertical }]);
643
+ const nav = new Attributes({ 'x-ref': 'tabsHeader' }).class(['fi-tabs', { 'fi-contained': isContained, 'fi-vertical': isVertical }])
644
+ .when(!isScrollable, (a) => a.merge({ 'x-bind:class': "{ 'fi-invisible': ! overflowReady }" }))
645
+ .merge({ 'x-cloak': true });
646
+ const tablistExtra = `${isVertical ? ` x-bind:aria-orientation="hasVisibleTabHeaders ? 'vertical' : null"` : ''} x-on:keydown="handleKeydown($event)"${isScrollable ? '' : ` x-on:dropdown-escape="if (isOverflowOpen) { $event.preventDefault(); $event.stopPropagation(); dismissOverflow() }" x-on:wheel="scrollOverflow($event)" x-on:touchstart="scrollOverflow($event)" x-on:touchmove="scrollOverflow($event)"`}`;
647
+ const persistedTab = isTabPersisted ? e(`$persist(null).as(${js(`filament:tabs:active:${key}`)})`) : 'null';
345
648
  return `<div x-data="tabsSchemaComponent({
346
- activeTab: 1,
649
+ activeTab: ${activeTab},
347
650
  hasTabPanels: true,
348
- isScrollable: true,
349
- isVertical: false,
350
- isTabPersisted: false,
351
- isTabPersistedInQueryString: false,
651
+ isScrollable: ${isScrollable ? 'true' : 'false'},
652
+ isVertical: ${isVertical ? 'true' : 'false'},
653
+ isTabPersisted: ${isTabPersisted ? 'true' : 'false'},
654
+ isTabPersistedInQueryString: ${this._tabQueryStringKey !== null ? 'true' : 'false'},
352
655
  livewireId: ${e(js(id))},
353
656
  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>`;
657
+ tab: ${persistedTab},
658
+ tabQueryStringKey: ${e(js(this._tabQueryStringKey))},
659
+ })" x-load x-load-src="${e(views.render('filament-schemas::asset', { path: 'tabs' }).trim())}"${isScrollable ? '' : ' x-on:resize.window="scheduleUpdate(true)"'} wire:ignore.self ${outer.toHtml()}><div ${nav.toHtml()}><div x-ref="tablist" role="tablist" x-bind:role="hasVisibleTabHeaders ? 'tablist' : 'group'" aria-label="${e(this.label)}"${tablistExtra} class="fi-sc-tabs-tablist">${buttons.join('')}</div>${overflow}</div>${panels.join('')}</div>`;
357
660
  }
358
661
  }
359
662
  /** Render hook injection point, using the host's scopes. Liberated from the container grid. */
@@ -389,27 +692,57 @@ export class Form extends Component {
389
692
  return `<form ${attributes.toHtml()}>${await content.toHtml()}${await footer.toHtml()}</form>`;
390
693
  }
391
694
  }
392
- /** Row of actions in a schema. */
393
695
  export class Actions extends Component {
394
696
  actions;
395
697
  _fullWidth = false;
396
698
  _alignment = null;
699
+ _label = null;
700
+ _beforeLabel = null;
701
+ _afterLabel = null;
702
+ _aboveContent = null;
703
+ _belowContent = null;
397
704
  constructor(actions) { super(); this.actions = actions; }
398
705
  static make(actions) { return configured(new Actions(actions)); }
399
706
  fullWidth(condition = true) { this._fullWidth = condition; return this; }
400
707
  alignment(alignment) { this._alignment = alignment; return this; }
708
+ /** Label shown above the actions, like a field label. */
709
+ label(label) { this._label = label; return this; }
710
+ /** Text, components or actions before the label. */
711
+ beforeLabel(content) { this._beforeLabel = content; return this; }
712
+ /** Text, components or actions after the label (a status badge…). */
713
+ afterLabel(content) { this._afterLabel = content; return this; }
714
+ /** Text, components or actions between the label and the actions. */
715
+ aboveContent(content) { this._aboveContent = content; return this; }
716
+ /** Text, components or actions below the actions. */
717
+ belowContent(content) { this._belowContent = content; return this; }
718
+ /** Actions this component exposes for mounting (its key is sent with each of them). */
719
+ getActions() { return this.actions; }
401
720
  async toHtml() {
402
721
  const ctx = this.context();
722
+ const key = this.getKey();
723
+ const mountContext = key !== null ? { ...(this.livewire?.getActionMountContext?.() ?? {}), schemaComponent: key } : null;
403
724
  const visible = [];
404
725
  for (const a of this.actions)
405
726
  if (await a.isVisible(ctx))
406
- visible.push(await a.toHtml(ctx));
407
- if (!visible.length)
727
+ visible.push(await a.toHtml(ctx, mountContext));
728
+ const label = await evaluate(this._label, ctx);
729
+ const slot = (content) => renderSlot(this, content, key);
730
+ const labelHtml = isBlank(label) ? '' : `<div class="fi-sc-actions-label-ctn">${await slot(this._beforeLabel)}<div class="fi-sc-actions-label">${e(label)}</div>${await slot(this._afterLabel)}</div>`;
731
+ const above = await slot(this._aboveContent), below = await slot(this._belowContent);
732
+ if (!visible.length && !labelHtml && !above && !below)
408
733
  return '';
409
734
  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>`;
735
+ return `<div ${new Attributes().merge({ id: key, ...this._extraAttributes }).class(['fi-sc-actions']).toHtml()}>${labelHtml}${above}${visible.length ? `<div class="${inner}">${visible.join('')}</div>` : ''}${below}</div>`;
411
736
  }
412
737
  }
738
+ /** Renders a slot of a component as an inline row: strings become `Text` components. */
739
+ export async function renderSlot(parent, content, mountKey = null) {
740
+ const value = await evaluate(content, parent.context());
741
+ if (value === null || value === undefined || value === '')
742
+ return '';
743
+ const items = (Array.isArray(value) ? value : [value]).map((item) => (typeof item === 'string' || item instanceof HtmlString ? Text.make(item) : item));
744
+ return renderInlineItems(parent, items, { mountKey, actionView: 'link' });
745
+ }
413
746
  let colorManager = null;
414
747
  /** Color manager used outside a panel; inside a panel, the host panel's manager takes precedence. */
415
748
  export const useColorManager = (manager) => { colorManager = manager; };
@@ -533,6 +866,7 @@ export class Text extends Component {
533
866
  _copyableState = null;
534
867
  _copyMessage = null;
535
868
  _copyMessageDuration = 2000;
869
+ _isJs = false;
536
870
  constructor(content) { super(); this._content = content; }
537
871
  static make(content) { return configured(new Text(content)); }
538
872
  content(content) { this._content = content; return this; }
@@ -556,7 +890,17 @@ export class Text extends Component {
556
890
  copyableState(state) { this._copyableState = state; return this; }
557
891
  copyMessage(message) { this._copyMessage = message; return this; }
558
892
  copyMessageDuration(milliseconds) { this._copyMessageDuration = milliseconds; return this; }
559
- async getContent() { return evaluate(this._content, this.context()); }
893
+ /**
894
+ * The content is a JavaScript expression evaluated in the browser (`$get()` and `$state` are available), re-evaluated when the state changes.
895
+ * Only use it with expressions written by the developer, never with user input.
896
+ */
897
+ js(condition = true) { this._isJs = condition; return this; }
898
+ async getContent() {
899
+ const content = await evaluate(this._content, this.context());
900
+ if (!this._isJs || content === null)
901
+ return content;
902
+ return new HtmlString(`<span x-text="() => eval(${js(content instanceof HtmlString ? content.html : content)})"></span>`);
903
+ }
560
904
  async toHtml() {
561
905
  const ctx = this.context();
562
906
  const content = await this.getContent();
@@ -643,7 +987,8 @@ export class Image extends Component {
643
987
  async toHtml() {
644
988
  const ctx = this.context();
645
989
  const url = String((await evaluate(this._url, ctx)) ?? '');
646
- if (!/^(\/(?!\/)|https?:\/\/)/i.test(url))
990
+ // Relative and http(s) URLs, or an inline image (`data:image/…;base64,`: scripts never run in an <img>).
991
+ if (!/^(\/(?!\/)|https?:\/\/|data:image\/(png|jpeg|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+$)/i.test(url))
647
992
  return '';
648
993
  const tooltip = tooltipExpression(await evaluate(this._tooltip, ctx));
649
994
  const attributes = new Attributes(this._extraAttributes)
@@ -683,7 +1028,7 @@ export class EmptyState extends Component {
683
1028
  _iconSize = null;
684
1029
  _compact = false;
685
1030
  _contained = true;
686
- _headingTag = 'h2';
1031
+ _headingTag = null;
687
1032
  _footer = [];
688
1033
  constructor(heading) { super(); this._heading = heading; }
689
1034
  static make(heading) { return configured(new EmptyState(heading)); }
@@ -698,6 +1043,7 @@ export class EmptyState extends Component {
698
1043
  /** `false`: no surrounding card. */
699
1044
  contained(condition = true) { this._contained = condition; return this; }
700
1045
  /** Heading tag (`h2` by default). */
1046
+ /** Heading tag; by default it follows the heading level of the schema (`h2` at the root). */
701
1047
  headingTag(tag) { this._headingTag = tag; return this; }
702
1048
  /** Footer: inline actions and components. */
703
1049
  footer(items) { this._footer = items; return this; }
@@ -708,8 +1054,9 @@ export class EmptyState extends Component {
708
1054
  const description = await evaluate(this._description, ctx);
709
1055
  const footer = await renderInlineItems(this, this._footer);
710
1056
  const iconColor = this._iconColor ?? 'primary';
1057
+ const headingTag = this._headingTag ?? this.getHeadingTag();
711
1058
  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>`;
1059
+ 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"><${headingTag} class="fi-empty-state-heading">${e(await evaluate(this._heading, ctx))}</${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
1060
  }
714
1061
  }
715
1062
  const CALLOUT_ICONS = { danger: 'o-x-circle', info: 'o-information-circle', success: 'o-check-circle', warning: 'o-exclamation-circle' };
@@ -730,6 +1077,7 @@ export class Callout extends Component {
730
1077
  _footerActionsAlignment = 'start';
731
1078
  _footer = null;
732
1079
  _controlActions = [];
1080
+ _controls = null;
733
1081
  constructor(heading = null) { super(); this._heading = heading; }
734
1082
  static make(heading = null) { return configured(new Callout(heading)); }
735
1083
  heading(heading) { this._heading = heading; return this; }
@@ -755,10 +1103,12 @@ export class Callout extends Component {
755
1103
  footer(items) { this._footer = items; return this; }
756
1104
  /** Small actions on the right of the callout (dismiss…). */
757
1105
  controlActions(actions) { this._controlActions = [...this._controlActions, ...actions]; return this; }
1106
+ /** Free-form controls (components and actions) on the right of the callout, replacing the control actions. */
1107
+ controls(items) { this._controls = items; return this; }
758
1108
  getColor() { return this._hasColor ? this._color : (this._color ?? this._status); }
759
1109
  getIcon() { return this._icon ?? (this._status ? (CALLOUT_ICONS[this._status] ?? null) : null); }
760
1110
  /** Callout actions, mounted on the root schema. */
761
- getActions() { return [...(this._footer ? actionsOf(this._footer) : this._footerActions), ...this._controlActions]; }
1111
+ getActions() { return [...(this._footer ? actionsOf(this._footer) : this._footerActions), ...(this._controls ? actionsOf(this._controls) : this._controlActions)]; }
762
1112
  async toHtml() {
763
1113
  const ctx = this.context();
764
1114
  const heading = await evaluate(this._heading, ctx);
@@ -767,7 +1117,7 @@ export class Callout extends Component {
767
1117
  const iconName = this.getIcon();
768
1118
  const iconColor = this._iconColor ?? this._status ?? color;
769
1119
  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' });
1120
+ const controls = this._controls ? await renderInlineItems(this, this._controls, { actionView: 'auto' }) : await renderInlineItems(this, this._controlActions, { alignment: 'start', actionView: 'auto' });
771
1121
  const statusKey = this._status ? `filament-schemas::components.callout.statuses.${this._status}` : null;
772
1122
  const statusLabel = statusKey && t(statusKey) !== statusKey ? t(statusKey) : null;
773
1123
  const statusHtml = statusLabel ? `<span class="fi-sr-only">${e(statusLabel)} </span>` : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navette-js/schemas",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Layout components and schemas for Navette admin panels.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "node"
39
39
  ],
40
40
  "dependencies": {
41
- "@navette-js/support": "1.0.0-beta.3"
41
+ "@navette-js/support": "1.0.0-beta.5"
42
42
  },
43
43
  "publishConfig": {
44
44
  "access": "public",
@@ -1,2 +1,2 @@
1
1
  <%# Grid column wrapper of a schema component. %>
2
- <div<% if (key !== null) { %> wire:partial="schema-component::<%- key %>"<% } %><% if (isVisible) { %> x-data="<%- alpine %>"<% } %> <%- attributes %>><% if (isVisible) { %><div class="fi-sc-component"><%- html %></div><% } %></div>
2
+ <div<% if (key !== null) { %> wire:partial="schema-component::<%- key %>"<% } %><% if (isVisible) { %> x-data="<%- alpine %>"<% } %> <%- attributes %>><% if (isVisible) { %><div class="fi-sc-component<% if (locals.gridContainer) { %> fi-grid-ctn<% } %>"><%- html %></div><% } %></div>