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

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. */
@@ -28,7 +31,11 @@ export declare abstract class Component {
28
31
  protected _visible: Lazy<boolean, ComponentContext>;
29
32
  protected _columnSpan: ColumnSpan;
30
33
  protected _extraAttributes: Record<string, string>;
31
- protected _columnStart: number | null;
34
+ protected _columnStart: ColumnPlacement[];
35
+ protected _columnOrder: ColumnPlacement[];
36
+ protected _gridContainer: Lazy<boolean, ComponentContext>;
37
+ protected _dense: boolean | null;
38
+ protected _gap: boolean | null;
32
39
  container: Schema;
33
40
  hidden(condition?: Lazy<boolean, ComponentContext>): this;
34
41
  visible(condition?: Lazy<boolean, ComponentContext>): this;
@@ -39,8 +46,27 @@ export declare abstract class Component {
39
46
  visibleOn(operations: string | string[]): this;
40
47
  /** A number applies from the `lg` breakpoint; an object replaces the default entirely. Values may be closures. */
41
48
  columnSpan(span: ColumnSpan): this;
42
- /** Starting grid column, from the `lg` breakpoint. */
43
- columnStart(start: number | null): this;
49
+ /** Starting grid column: a number applies from the `lg` breakpoint; an object sets it per breakpoint. Successive calls merge; `null` resets it. */
50
+ columnStart(start: ColumnPlacement): this;
51
+ /** Visual order in the grid, without changing the markup order: a number applies from `lg`; an object sets it per breakpoint. */
52
+ columnOrder(order: ColumnPlacement): this;
53
+ /** Makes the component a CSS container, so that grids inside it may use container breakpoints (`@md`, `@xl`…). */
54
+ gridContainer(condition?: Lazy<boolean, ComponentContext>): this;
55
+ /** Tighter spacing between the components of its child schemas (inherited by nested layouts). */
56
+ dense(condition?: boolean | null): this;
57
+ /** `false`: no space between the components of its child schemas (inherited by nested layouts). */
58
+ gap(condition?: boolean | null): this;
59
+ isDense(): boolean;
60
+ hasGap(): boolean;
61
+ /** Heading level of the component's own headings (`index` for a second heading). */
62
+ getHeadingLevel(index?: number): number;
63
+ /** `h1`…`h6`, or `p` beyond level 6. */
64
+ getHeadingTag(index?: number): string;
65
+ /** Number of heading levels the component adds above its child schemas (1 for a section with a heading). */
66
+ getHeadingsCount(): number;
67
+ getChildSchemaRootHeadingLevel(): number;
68
+ /** Full grid width on every screen size (`columnSpan('full')` only applies from `lg`). */
69
+ columnSpanFull(): this;
44
70
  extraAttributes(attributes: Record<string, string>): this;
45
71
  get livewire(): SchemaHost;
46
72
  isLiberatedFromContainerGrid(): boolean;
@@ -74,6 +100,8 @@ export declare class Schema {
74
100
  private _extraClass;
75
101
  private _partial;
76
102
  private _dense;
103
+ private _parentComponent;
104
+ private _rootHeadingLevel;
77
105
  constructor(livewire: SchemaHost);
78
106
  static make(livewire: SchemaHost): Schema;
79
107
  key(key: string): this;
@@ -87,8 +115,16 @@ export declare class Schema {
87
115
  record(record: unknown): this;
88
116
  /** Class placed before the grid classes (e.g. `fi-section-content`). */
89
117
  extraClass(name: string | null): this;
90
- /** Tighter spacing between components (`fi-sc-dense`). */
91
- dense(condition?: boolean): this;
118
+ /** Tighter spacing between components (`fi-sc-dense`). Without a value set, a child schema follows its parent component. */
119
+ dense(condition?: boolean | null): this;
120
+ /** Layout component the schema belongs to: spacing and heading levels are inherited from it. */
121
+ parentComponent(component: Component | null): this;
122
+ getParentComponent(): Component | null;
123
+ /** Heading level of the schema's top-level headings (`2` by default: `<h2>`); nested schemas follow their parent. */
124
+ rootHeadingLevel(level: number): this;
125
+ getRootHeadingLevel(): number;
126
+ isDense(): boolean;
127
+ hasGap(): boolean;
92
128
  /** Partially rendered schema: its root carries `wire:partial="schema.<key>"`. */
93
129
  partiallyRendered(condition?: boolean): this;
94
130
  private _partialName;
@@ -96,7 +132,7 @@ export declare class Schema {
96
132
  partialName(name: string | null): this;
97
133
  private _gap;
98
134
  /** `false`: components touch, with no gap (no `fi-sc-has-gap`), as in a FusedGroup. */
99
- gap(condition?: boolean): this;
135
+ gap(condition?: boolean | null): this;
100
136
  private _fieldWrapperView;
101
137
  /**
102
138
  * Field wrapper for this schema: `'filament-forms::plain-field-wrapper'` keeps only a screen-reader label
@@ -159,25 +195,79 @@ export declare class Flex extends Component {
159
195
  toHtml(): Promise<string>;
160
196
  }
161
197
  export declare const slug: (text: string) => string;
162
- /** Card grouping components, with an optional header (heading, actions). */
198
+ /**
199
+ * Card grouping components, with an optional header (icon, heading, description, components after the header)
200
+ * and footer (actions or components).
201
+ */
163
202
  export declare class Section extends Component {
164
203
  private children;
165
204
  private _columns;
166
205
  private _heading;
206
+ private _description;
207
+ private _icon;
208
+ private _iconColor;
209
+ private _iconSize;
167
210
  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;
211
+ private _headerActions;
212
+ private _footer;
213
+ private _footerActions;
214
+ private _footerActionsAlignment;
215
+ private _aside;
216
+ private _contentBefore;
217
+ private _compact;
218
+ private _secondary;
219
+ private _contained;
172
220
  private _collapsible;
173
221
  private _collapsed;
222
+ private _persistCollapsed;
223
+ constructor(heading?: Lazy<string | null, ComponentContext>);
224
+ static make(heading?: Lazy<string | null, ComponentContext>): Section;
225
+ schema(components: Component[]): this;
226
+ columns(columns: Record<string, number> | number | null): this;
227
+ /** Heading: a string or a function of the context object. */
228
+ heading(heading: Lazy<string | null, ComponentContext>): this;
229
+ /** Text under the heading. */
230
+ description(description: Lazy<string | null, ComponentContext>): this;
231
+ /** Header icon (Heroicons name). */
232
+ icon(name: Lazy<string | null, ComponentContext>): this;
233
+ /** Header icon color (`gray` by default). */
234
+ iconColor(color: Lazy<string | null, ComponentContext>): this;
235
+ /** Header icon size (`lg` by default). */
236
+ iconSize(size: string | null): this;
237
+ /** Heading and description on the left, the content in a card on the right (from the `md` breakpoint). */
238
+ aside(condition?: Lazy<boolean, ComponentContext>): this;
239
+ /** With `aside()`: the content before the heading and description. */
240
+ contentBefore(condition?: Lazy<boolean, ComponentContext>): this;
241
+ /** Smaller paddings, for nested sections. */
242
+ compact(condition?: Lazy<boolean, ComponentContext>): this;
243
+ /** Less contrasting background, for a section on a background of the same color (nested sections…). */
244
+ secondary(condition?: Lazy<boolean, ComponentContext>): this;
245
+ /** `false`: no card around the section. */
246
+ contained(condition?: Lazy<boolean, ComponentContext>): this;
174
247
  /** 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. */
248
+ collapsible(condition?: Lazy<boolean, ComponentContext>): this;
249
+ collapsed(condition?: Lazy<boolean, ComponentContext>): this;
250
+ /** Keeps the collapsed state in the browser's local storage, under the section's key. */
251
+ persistCollapsed(condition?: Lazy<boolean, ComponentContext>): this;
252
+ /** Components and actions shown after the heading; replaces `headerActions()`. */
253
+ afterHeader(items: InlineItem[] | null): this;
254
+ /** Actions shown after the heading (when `afterHeader()` is not used). */
255
+ headerActions(actions: Renderable[]): this;
256
+ /** Components and actions in the footer; replaces `footerActions()`. */
257
+ footer(items: InlineItem[] | null): this;
258
+ /** Actions in the footer (when `footer()` is not used). */
259
+ footerActions(actions: Renderable[]): this;
260
+ /** Footer actions alignment (`start`, `center`, `end`, `between`). */
261
+ footerActionsAlignment(alignment: string): this;
262
+ private getHeaderItems;
263
+ private getFooterItems;
264
+ /** Actions the section exposes for mounting (header and footer). */
180
265
  getActions(): Renderable[];
266
+ /** Heading used for the key: a function heading is only used when it returns a string synchronously. */
267
+ private getHeadingForKey;
268
+ /** Key derived from the heading (`rate-limiting::data::section`), unless a `key()` is set. */
269
+ getKey(): string | null;
270
+ getHeadingsCount(): number;
181
271
  getChildSchemas(): Schema[];
182
272
  toHtml(): Promise<string>;
183
273
  }
@@ -191,35 +281,83 @@ export declare class Html extends Component {
191
281
  static make(content: string | ((ctx: ComponentContext) => string | Promise<string>)): Html;
192
282
  toHtml(): Promise<string>;
193
283
  }
194
- /** A tab of a Tabs component: a label, an optional icon and its own schema. */
284
+ /** A tab of a Tabs component: a label, an optional icon and badge, and its own schema. */
195
285
  export declare class Tab extends Component {
196
286
  private children;
197
287
  private _columns;
198
288
  private _icon;
289
+ private _iconPosition;
290
+ private _badge;
291
+ private _badgeColor;
292
+ private _badgeIcon;
293
+ private _badgeIconPosition;
294
+ private _badgeTooltip;
199
295
  private label;
200
296
  constructor(label: string);
201
297
  static make(label: string): Tab;
202
298
  schema(components: Component[]): this;
203
299
  columns(columns: Record<string, number> | number | null): this;
204
300
  icon(name: string | null): this;
301
+ /** Icon before (default) or after the label. */
302
+ iconPosition(position: 'before' | 'after'): this;
303
+ /** Badge after the label (a count…); a function receives the context object. */
304
+ badge(badge: Lazy<string | number | null, ComponentContext>): this;
305
+ /** Badge color (`primary` by default). */
306
+ badgeColor(color: Lazy<string | null, ComponentContext>): this;
307
+ badgeIcon(name: string | null): this;
308
+ badgeIconPosition(position: 'before' | 'after'): this;
309
+ /** Tooltip of the badge (plain text). */
310
+ badgeTooltip(text: Lazy<string | null, ComponentContext>): this;
205
311
  getLabel(): string;
206
312
  getIcon(): string | null;
313
+ getIconPosition(): 'before' | 'after';
314
+ getBadge(): Promise<{
315
+ label: string;
316
+ color: string;
317
+ icon: string | null;
318
+ iconPosition: 'before' | 'after';
319
+ tooltip: string | null;
320
+ } | null>;
207
321
  /** Browser-side tab key (no schema prefix): `::data::` follows the schema state path, and is dropped in infolists. */
208
322
  getTabKey(): string;
209
323
  getChildSchemas(): Schema[];
210
324
  toHtml(): Promise<string>;
211
325
  }
212
- /** Layout tabs: every panel is rendered, the browser only shows the active one. */
326
+ /**
327
+ * Layout tabs: every panel is rendered, the browser only shows the active one.
328
+ * The active tab may be persisted in local storage (`persistTab()`) or in the URL (`persistTabInQueryString()`):
329
+ * the browser only selects a persisted key when it matches an existing tab.
330
+ */
213
331
  export declare class Tabs extends Component {
214
332
  private tabs;
215
333
  private label;
334
+ private _activeTab;
335
+ private _persistTab;
336
+ private _tabQueryStringKey;
337
+ private _vertical;
338
+ private _contained;
339
+ private _scrollable;
216
340
  constructor(label: string);
217
341
  static make(label: string): Tabs;
218
342
  schema(tabs: Tab[]): this;
343
+ /** Tab active when the page loads (1-based, among the visible tabs). */
344
+ activeTab(tab: Lazy<number, ComponentContext>): this;
345
+ /** Remembers the active tab in the browser's local storage, under the key of the tabs. */
346
+ persistTab(condition?: Lazy<boolean, ComponentContext>): this;
347
+ /** Keeps the active tab in the URL's query string (`?tab=…`); `null` disables it. */
348
+ persistTabInQueryString(key?: string | null): this;
349
+ /** Tab headers on the side of the panels (from the `md` breakpoint). */
350
+ vertical(condition?: Lazy<boolean, ComponentContext>): this;
351
+ /** `false`: no card around the tabs. */
352
+ contained(condition?: Lazy<boolean, ComponentContext>): this;
353
+ /** `false`: tabs that do not fit are moved to a "more" dropdown instead of scrolling. */
354
+ scrollable(condition?: Lazy<boolean, ComponentContext>): this;
219
355
  getExposedMethods(): string[];
220
356
  getLazyLoadedTabBadges(): Record<string, unknown>;
221
357
  getDeferredTabBadges(): Record<string, unknown>;
222
358
  getChildSchemas(): Schema[];
359
+ /** Active tab (1-based): the query string's tab when it names a visible tab, otherwise `activeTab()`. */
360
+ private getActiveTab;
223
361
  toHtml(): Promise<string>;
224
362
  }
225
363
  /** Render hook injection point, using the host's scopes. Liberated from the container grid. */
@@ -330,6 +468,7 @@ export declare class Text extends Component {
330
468
  private _copyableState;
331
469
  private _copyMessage;
332
470
  private _copyMessageDuration;
471
+ private _isJs;
333
472
  constructor(content: Lazy<string | HtmlString | null, ComponentContext>);
334
473
  static make(content: Lazy<string | HtmlString | null, ComponentContext>): Text;
335
474
  content(content: Lazy<string | HtmlString | null, ComponentContext>): this;
@@ -353,6 +492,11 @@ export declare class Text extends Component {
353
492
  copyableState(state: Lazy<string | null, ComponentContext>): this;
354
493
  copyMessage(message: string | null): this;
355
494
  copyMessageDuration(milliseconds: number): this;
495
+ /**
496
+ * The content is a JavaScript expression evaluated in the browser (`$get()` and `$state` are available), re-evaluated when the state changes.
497
+ * Only use it with expressions written by the developer, never with user input.
498
+ */
499
+ js(condition?: boolean): this;
356
500
  getContent(): Promise<string | HtmlString | null>;
357
501
  toHtml(): Promise<string>;
358
502
  }
@@ -436,7 +580,8 @@ export declare class EmptyState extends Component {
436
580
  /** `false`: no surrounding card. */
437
581
  contained(condition?: boolean): this;
438
582
  /** Heading tag (`h2` by default). */
439
- headingTag(tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'): this;
583
+ /** Heading tag; by default it follows the heading level of the schema (`h2` at the root). */
584
+ headingTag(tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | null): this;
440
585
  /** Footer: inline actions and components. */
441
586
  footer(items: InlineItem[]): this;
442
587
  /** Footer actions, mounted on the root schema. */
@@ -460,6 +605,7 @@ export declare class Callout extends Component {
460
605
  private _footerActionsAlignment;
461
606
  private _footer;
462
607
  private _controlActions;
608
+ private _controls;
463
609
  constructor(heading?: Lazy<string | null, ComponentContext>);
464
610
  static make(heading?: Lazy<string | null, ComponentContext>): Callout;
465
611
  heading(heading: Lazy<string | null, ComponentContext>): this;
@@ -485,6 +631,8 @@ export declare class Callout extends Component {
485
631
  footer(items: InlineItem[] | null): this;
486
632
  /** Small actions on the right of the callout (dismiss…). */
487
633
  controlActions(actions: Renderable[]): this;
634
+ /** Free-form controls (components and actions) on the right of the callout, replacing the control actions. */
635
+ controls(items: InlineItem[] | null): this;
488
636
  getColor(): string | null;
489
637
  getIcon(): string | null;
490
638
  /** Callout actions, mounted on the root schema. */
package/lib/index.js CHANGED
@@ -5,6 +5,32 @@ 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. */
@@ -18,7 +44,11 @@ export class Component {
18
44
  _visible = true;
19
45
  _columnSpan = { default: 1 };
20
46
  _extraAttributes = {};
21
- _columnStart = null;
47
+ _columnStart = [];
48
+ _columnOrder = [];
49
+ _gridContainer = false;
50
+ _dense = null;
51
+ _gap = null;
22
52
  container;
23
53
  hidden(condition = true) { this._hidden = condition; return this; }
24
54
  visible(condition = true) { this._visible = condition; return this; }
@@ -29,9 +59,25 @@ export class Component {
29
59
  visibleOn(operations) { const list = [].concat(operations); return this.visible(({ operation }) => operation !== null && list.includes(operation)); }
30
60
  /** A number applies from the `lg` breakpoint; an object replaces the default entirely. Values may be closures. */
31
61
  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; }
62
+ /** Starting grid column: a number applies from the `lg` breakpoint; an object sets it per breakpoint. Successive calls merge; `null` resets it. */
63
+ columnStart(start) { this._columnStart = start === null ? [] : [...this._columnStart, start]; return this; }
64
+ /** Visual order in the grid, without changing the markup order: a number applies from `lg`; an object sets it per breakpoint. */
65
+ columnOrder(order) { this._columnOrder = order === null ? [] : [...this._columnOrder, order]; return this; }
66
+ /** Makes the component a CSS container, so that grids inside it may use container breakpoints (`@md`, `@xl`…). */
67
+ gridContainer(condition = true) { this._gridContainer = condition; return this; }
68
+ /** Tighter spacing between the components of its child schemas (inherited by nested layouts). */
69
+ dense(condition = true) { this._dense = condition; return this; }
70
+ /** `false`: no space between the components of its child schemas (inherited by nested layouts). */
71
+ gap(condition = true) { this._gap = condition; return this; }
72
+ isDense() { return this._dense ?? this.container.isDense(); }
73
+ hasGap() { return this._gap ?? this.container.hasGap(); }
74
+ /** Heading level of the component's own headings (`index` for a second heading). */
75
+ getHeadingLevel(index = 0) { return Math.max(1, this.container.getRootHeadingLevel() + index); }
76
+ /** `h1`…`h6`, or `p` beyond level 6. */
77
+ getHeadingTag(index = 0) { const level = this.getHeadingLevel(index); return level > 6 ? 'p' : `h${level}`; }
78
+ /** Number of heading levels the component adds above its child schemas (1 for a section with a heading). */
79
+ getHeadingsCount() { return 0; }
80
+ getChildSchemaRootHeadingLevel() { return this.getHeadingLevel() + this.getHeadingsCount(); }
35
81
  /** Full grid width on every screen size (`columnSpan('full')` only applies from `lg`). */
36
82
  columnSpanFull() { return this.columnSpan({ default: 'full' }); }
37
83
  extraAttributes(attributes) { Object.assign(this._extraAttributes, attributes); return this; }
@@ -69,16 +115,23 @@ export class Component {
69
115
  const key = this.getKey();
70
116
  const span = {};
71
117
  const rawSpan = typeof this._columnSpan === 'object' ? this._columnSpan : { default: 1, lg: this._columnSpan };
118
+ const ctx = this.context();
72
119
  for (const [breakpoint, value] of Object.entries(rawSpan))
73
- span[breakpoint] = await evaluate(value, this.context());
120
+ span[breakpoint] = await evaluate(value, ctx);
121
+ const start = await resolvePlacement(this._columnStart, ctx, false);
122
+ const order = await resolvePlacement(this._columnOrder, ctx, true);
123
+ const placementClass = (name, values) => Object.keys(values).map((breakpoint) => (breakpoint === 'default' ? `fi-grid-col-${name}` : `${breakpoint}:fi-grid-col-${name}`));
124
+ const placementStyle = (name, values) => Object.entries(values).map(([breakpoint, value]) => `--col-${name}-${breakpointVariable(breakpoint)}: ${value}`);
74
125
  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}`]))
126
+ .when(!this.container.isInline(), (a) => a.gridColumn(span, !isVisible)
127
+ .class([...placementClass('start', start), ...placementClass('order', order)])
128
+ .style([...placementStyle('start', start), ...placementStyle('order', order)]))
77
129
  .merge({ 'wire:key': this.getLivewireKey() });
78
130
  return views.render('filament-schemas::component', {
79
131
  key, isVisible, attributes: attributes.toHtml(),
80
132
  alpine: isVisible ? `filamentSchemaComponent(${js(this.getAlpineScopeConfiguration())})` : null,
81
133
  html: isVisible ? await this.toHtml() : '',
134
+ gridContainer: isVisible && Boolean(await evaluate(this._gridContainer, ctx)),
82
135
  });
83
136
  }
84
137
  }
@@ -94,7 +147,9 @@ export class Schema {
94
147
  _record = null;
95
148
  _extraClass = null;
96
149
  _partial = false;
97
- _dense = false;
150
+ _dense = null;
151
+ _parentComponent = null;
152
+ _rootHeadingLevel = 2;
98
153
  constructor(livewire) { this.livewire = livewire; }
99
154
  static make(livewire) { return configured(new Schema(livewire)); }
100
155
  key(key) { this._key = key; return this; }
@@ -109,14 +164,23 @@ export class Schema {
109
164
  record(record) { this._record = record; return this; }
110
165
  /** Class placed before the grid classes (e.g. `fi-section-content`). */
111
166
  extraClass(name) { this._extraClass = name; return this; }
112
- /** Tighter spacing between components (`fi-sc-dense`). */
167
+ /** Tighter spacing between components (`fi-sc-dense`). Without a value set, a child schema follows its parent component. */
113
168
  dense(condition = true) { this._dense = condition; return this; }
169
+ /** Layout component the schema belongs to: spacing and heading levels are inherited from it. */
170
+ parentComponent(component) { this._parentComponent = component; return this; }
171
+ getParentComponent() { return this._parentComponent; }
172
+ /** Heading level of the schema's top-level headings (`2` by default: `<h2>`); nested schemas follow their parent. */
173
+ rootHeadingLevel(level) { if (Number.isInteger(level) && level >= 1)
174
+ this._rootHeadingLevel = level; return this; }
175
+ getRootHeadingLevel() { return this._parentComponent ? this._parentComponent.getChildSchemaRootHeadingLevel() : this._rootHeadingLevel; }
176
+ isDense() { return this._dense ?? (this._inline || (this._parentComponent?.isDense() ?? false)); }
177
+ hasGap() { return this._gap ?? this._parentComponent?.hasGap() ?? true; }
114
178
  /** Partially rendered schema: its root carries `wire:partial="schema.<key>"`. */
115
179
  partiallyRendered(condition = true) { this._partial = condition; return this; }
116
180
  _partialName = null;
117
181
  /** Root `wire:partial` name when it differs from the schema key (filters form: `table-filters-form`). */
118
182
  partialName(name) { this._partialName = name; return this; }
119
- _gap = true;
183
+ _gap = null;
120
184
  /** `false`: components touch, with no gap (no `fi-sc-has-gap`), as in a FusedGroup. */
121
185
  gap(condition = true) { this._gap = condition; return this; }
122
186
  _fieldWrapperView = null;
@@ -161,7 +225,7 @@ export class Schema {
161
225
  'x-on:form-validation-error.window': this._root ? 'handleFormValidationError' : null,
162
226
  'x-on:reset-schema-component-state.window': this._root ? 'handleClientSideStateReset' : null,
163
227
  })
164
- .class(['fi-sc', { 'fi-inline': this._inline }, { 'fi-sc-has-gap': this._gap }, { 'fi-sc-dense': this._dense }]);
228
+ .class(['fi-sc', { 'fi-inline': this._inline }, { 'fi-sc-has-gap': this.hasGap() }, { 'fi-sc-dense': this.isDense() }]);
165
229
  const parts = [];
166
230
  for (const [component, isVisible] of withVisibility) {
167
231
  if (!component.isLiberatedFromContainerGrid())
@@ -175,7 +239,7 @@ export class Schema {
175
239
  /** Child schema of a layout component: shares the parent's key and state path, without being a root. */
176
240
  export function childSchema(parent, components) {
177
241
  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);
242
+ return new Schema(c.livewire).key(c.getKey()).statePath(c.getStatePath() || null).operation(c.getOperation()).record(c.getRecord()).root(false).parentComponent(parent).components(components);
179
243
  }
180
244
  /** Stateful group: its children live under `<container path>.<name>` (used by filter forms). */
181
245
  export class StatefulGroup extends Component {
@@ -185,7 +249,7 @@ export class StatefulGroup extends Component {
185
249
  hasStatePath() { return true; }
186
250
  getChildSchemas() {
187
251
  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)];
252
+ 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
253
  }
190
254
  async toHtml() { return `<div id="${e(this.getKey())}">${await this.getChildSchemas()[0].toHtml()}</div>`; }
191
255
  }
@@ -198,7 +262,10 @@ export class Group extends Component {
198
262
  schema(components) { this.children = components; return this; }
199
263
  columns(columns) { this._columns = columns; return this; }
200
264
  getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
201
- async toHtml() { return `<div>${await this.getChildSchemas()[0].toHtml()}</div>`; }
265
+ async toHtml() {
266
+ const attributes = new Attributes().merge({ ...this._extraAttributes }).toHtml();
267
+ return `<div${attributes ? ` ${attributes}` : ''}>${await this.getChildSchemas()[0].toHtml()}</div>`;
268
+ }
202
269
  }
203
270
  /** Column grid: two columns from `lg` by default. */
204
271
  export class Grid extends Group {
@@ -228,55 +295,155 @@ export class Flex extends Component {
228
295
  }
229
296
  }
230
297
  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). */
298
+ /** Footer alignment (`start`, `center`, `end`, `between`; `right` and `justify` are aliases) as an inline schema alignment. */
299
+ const footerAlignment = (alignment) => (['right', 'end'].includes(alignment) ? 'end' : alignment === 'justify' ? 'between' : ['start', 'center', 'between'].includes(alignment) ? alignment : 'start');
300
+ /**
301
+ * Card grouping components, with an optional header (icon, heading, description, components after the header)
302
+ * and footer (actions or components).
303
+ */
232
304
  export class Section extends Component {
233
305
  children = [];
234
306
  _columns = {};
235
307
  _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
- }
308
+ _description = null;
309
+ _icon = null;
310
+ _iconColor = null;
311
+ _iconSize = null;
312
+ _afterHeader = null;
313
+ _headerActions = [];
314
+ _footer = null;
315
+ _footerActions = [];
316
+ _footerActionsAlignment = 'start';
317
+ _aside = false;
318
+ _contentBefore = false;
319
+ _compact = false;
320
+ _secondary = false;
321
+ _contained = true;
322
+ _collapsible = null;
323
+ _collapsed = false;
324
+ _persistCollapsed = false;
325
+ constructor(heading = null) { super(); this._heading = heading; }
244
326
  static make(heading = null) { return configured(new Section(heading)); }
245
327
  schema(components) { this.children = components; return this; }
246
328
  columns(columns) { this._columns = columns; return this; }
247
- _collapsible = false;
248
- _collapsed = false;
329
+ /** Heading: a string or a function of the context object. */
330
+ heading(heading) { this._heading = heading; return this; }
331
+ /** Text under the heading. */
332
+ description(description) { this._description = description; return this; }
333
+ /** Header icon (Heroicons name). */
334
+ icon(name) { this._icon = name; return this; }
335
+ /** Header icon color (`gray` by default). */
336
+ iconColor(color) { this._iconColor = color; return this; }
337
+ /** Header icon size (`lg` by default). */
338
+ iconSize(size) { this._iconSize = size; return this; }
339
+ /** Heading and description on the left, the content in a card on the right (from the `md` breakpoint). */
340
+ aside(condition = true) { this._aside = condition; return this; }
341
+ /** With `aside()`: the content before the heading and description. */
342
+ contentBefore(condition = true) { this._contentBefore = condition; return this; }
343
+ /** Smaller paddings, for nested sections. */
344
+ compact(condition = true) { this._compact = condition; return this; }
345
+ /** Less contrasting background, for a section on a background of the same color (nested sections…). */
346
+ secondary(condition = true) { this._secondary = condition; return this; }
347
+ /** `false`: no card around the section. */
348
+ contained(condition = true) { this._contained = condition; return this; }
249
349
  /** Collapsible by clicking its header; `collapsed()` starts it collapsed. */
250
350
  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; }
351
+ collapsed(condition = true) { this._collapsed = condition; if (this._collapsible === null)
352
+ this._collapsible = condition; return this; }
353
+ /** Keeps the collapsed state in the browser's local storage, under the section's key. */
354
+ persistCollapsed(condition = true) { this._persistCollapsed = condition; return this; }
355
+ /** Components and actions shown after the heading; replaces `headerActions()`. */
356
+ afterHeader(items) { this._afterHeader = items; return this; }
357
+ /** Actions shown after the heading (when `afterHeader()` is not used). */
358
+ headerActions(actions) { this._headerActions = [...this._headerActions, ...actions]; return this; }
359
+ /** Components and actions in the footer; replaces `footerActions()`. */
360
+ footer(items) { this._footer = items; return this; }
361
+ /** Actions in the footer (when `footer()` is not used). */
362
+ footerActions(actions) { this._footerActions = [...this._footerActions, ...actions]; return this; }
363
+ /** Footer actions alignment (`start`, `center`, `end`, `between`). */
364
+ footerActionsAlignment(alignment) { this._footerActionsAlignment = alignment; return this; }
365
+ getHeaderItems() { return this._afterHeader ?? this._headerActions; }
366
+ getFooterItems() { return this._footer ?? this._footerActions; }
367
+ /** Actions the section exposes for mounting (header and footer). */
368
+ getActions() { return [...actionsOf(this.getHeaderItems()), ...actionsOf(this.getFooterItems())]; }
369
+ /** Heading used for the key: a function heading is only used when it returns a string synchronously. */
370
+ getHeadingForKey() {
371
+ if (typeof this._heading !== 'function')
372
+ return this._heading;
373
+ let value;
374
+ try {
375
+ value = this._heading(this.context());
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ // An async heading is not awaited here: its failure is reported when the section renders.
381
+ if (value instanceof Promise)
382
+ value.catch(() => { });
383
+ return typeof value === 'string' ? value : null;
384
+ }
385
+ /** Key derived from the heading (`rate-limiting::data::section`), unless a `key()` is set. */
386
+ getKey() {
387
+ if (this._keyName !== null)
388
+ return super.getKey();
389
+ const heading = this.getHeadingForKey();
390
+ if (isBlank(heading))
391
+ return null;
392
+ const statePath = this.container.getStatePath();
393
+ return `${this.container.getKey()}.${slug(heading)}::${statePath ? `${statePath}::` : ''}section`;
394
+ }
395
+ getHeadingsCount() { return typeof this._heading === 'function' || !isBlank(this._heading) ? 1 : 0; }
257
396
  getChildSchemas() { return [childSchema(this, this.children).columns(this._columns).extraClass('fi-section-content')]; }
258
397
  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
398
  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>`);
399
+ const [content] = this.getChildSchemas();
400
+ const id = this.getKey();
401
+ const heading = await evaluate(this._heading, ctx);
402
+ const description = await evaluate(this._description, ctx);
403
+ const iconName = await evaluate(this._icon, ctx);
404
+ const isAside = Boolean(await evaluate(this._aside, ctx));
405
+ const isCollapsible = Boolean(await evaluate(this._collapsible ?? false, ctx)) && !isAside;
406
+ const isCollapsed = Boolean(await evaluate(this._collapsed, ctx));
407
+ const shouldPersistCollapsed = Boolean(await evaluate(this._persistCollapsed, ctx));
408
+ const hasHeading = !isBlank(heading), hasDescription = !isBlank(description), hasIcon = !isBlank(iconName);
409
+ const afterHeader = await renderInlineItems(this, this.getHeaderItems(), { alignment: 'end' });
410
+ const footer = await renderInlineItems(this, this.getFooterItems(), { alignment: this._footer === null ? footerAlignment(this._footerActionsAlignment) : null });
411
+ const contentHtml = await content.toHtml();
412
+ const hasHeader = hasIcon || hasHeading || hasDescription || isCollapsible || afterHeader !== '';
413
+ const hasContent = contentHtml !== '' || footer !== '';
414
+ const contentId = id !== null && hasContent ? `${id}-content` : null;
415
+ const headingId = id !== null && hasHeading ? `${id}-heading` : null;
416
+ const descriptionId = id !== null && hasDescription ? `${id}-description` : null;
417
+ const outer = new Attributes().merge({ id, ...this._extraAttributes }).class(['fi-sc-section']);
273
418
  // 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>`;
419
+ // A persisted state lives in local storage, under the section's key (or its element id).
420
+ const idTest = `if ($event.detail.id == (${js(id)} ?? $el.id))`;
421
+ 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'"` : '';
422
+ const collapsedState = shouldPersistCollapsed ? e(`$persist(${isCollapsed ? 'true' : 'false'}).as(\`filament:section:collapsed:\${${js(id)} ?? $el.id}\`)`) : (isCollapsed ? 'true' : 'false');
423
+ const sectionClass = cls(['fi-section', {
424
+ 'fi-section-not-contained': !(await evaluate(this._contained, ctx)),
425
+ 'fi-section-has-content-before': Boolean(await evaluate(this._contentBefore, ctx)),
426
+ 'fi-section-has-header': hasHeader,
427
+ 'fi-aside': isAside,
428
+ 'fi-compact': Boolean(await evaluate(this._compact, ctx)),
429
+ 'fi-collapsible': isCollapsible,
430
+ 'fi-collapsed': isCollapsible && isCollapsed,
431
+ 'fi-secondary': Boolean(await evaluate(this._secondary, ctx)),
432
+ }]);
433
+ let header = '';
434
+ if (hasHeader) {
435
+ const iconColor = (await evaluate(this._iconColor, ctx)) ?? 'gray';
436
+ const iconHtml = hasIcon ? icon(iconName, { size: safeClass(this._iconSize) ?? 'lg', attributes: new Attributes().class(plainColorClasses(iconColor)) }) : '';
437
+ const tag = this.getHeadingTag();
438
+ 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>` : '';
439
+ 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>` : '';
440
+ 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>` : '';
441
+ 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>`;
442
+ }
443
+ 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>` : '';
444
+ return `<div ${outer.toHtml()}><section x-data="{
445
+ isCollapsed: ${collapsedState},
446
+ }"${collapse}${headingId ? ` aria-labelledby="${e(headingId)}"` : ''}${descriptionId ? ` aria-describedby="${e(descriptionId)}"` : ''} class="${sectionClass}">${header}${body}</section></div>`;
280
447
  }
281
448
  }
282
449
  /**
@@ -289,71 +456,167 @@ export class Html extends Component {
289
456
  static make(content) { return new Html(content); }
290
457
  async toHtml() { return typeof this.content === 'function' ? this.content(this.context()) : this.content; }
291
458
  }
292
- /** A tab of a Tabs component: a label, an optional icon and its own schema. */
459
+ /** A tab of a Tabs component: a label, an optional icon and badge, and its own schema. */
293
460
  export class Tab extends Component {
294
461
  children = [];
295
462
  _columns = {};
296
463
  _icon = null;
464
+ _iconPosition = 'before';
465
+ _badge = null;
466
+ _badgeColor = null;
467
+ _badgeIcon = null;
468
+ _badgeIconPosition = 'before';
469
+ _badgeTooltip = null;
297
470
  label;
298
471
  constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::tab`; }
299
472
  static make(label) { return configured(new Tab(label)); }
300
473
  schema(components) { this.children = components; return this; }
301
474
  columns(columns) { this._columns = columns; return this; }
302
475
  icon(name) { this._icon = name; return this; }
476
+ /** Icon before (default) or after the label. */
477
+ iconPosition(position) { this._iconPosition = position === 'after' ? 'after' : 'before'; return this; }
478
+ /** Badge after the label (a count…); a function receives the context object. */
479
+ badge(badge) { this._badge = badge; return this; }
480
+ /** Badge color (`primary` by default). */
481
+ badgeColor(color) { this._badgeColor = color; return this; }
482
+ badgeIcon(name) { this._badgeIcon = name; return this; }
483
+ badgeIconPosition(position) { this._badgeIconPosition = position === 'after' ? 'after' : 'before'; return this; }
484
+ /** Tooltip of the badge (plain text). */
485
+ badgeTooltip(text) { this._badgeTooltip = text; return this; }
303
486
  getLabel() { return this.label; }
304
487
  getIcon() { return this._icon; }
488
+ getIconPosition() { return this._iconPosition; }
489
+ async getBadge() {
490
+ const ctx = this.context();
491
+ const badge = await evaluate(this._badge, ctx);
492
+ if (isBlank(badge))
493
+ return null;
494
+ return { label: String(badge), color: (await evaluate(this._badgeColor, ctx)) ?? 'primary', icon: this._badgeIcon, iconPosition: this._badgeIconPosition, tooltip: await evaluate(this._badgeTooltip, ctx) };
495
+ }
305
496
  /** Browser-side tab key (no schema prefix): `::data::` follows the schema state path, and is dropped in infolists. */
306
497
  getTabKey() { const statePath = this.container.getStatePath(); return this._keyName.replace('::data::', statePath ? `::${statePath}::` : '::'); }
307
498
  getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
308
499
  async toHtml() { return this.getChildSchemas()[0].toHtml(); }
309
500
  }
310
- /** Layout tabs: every panel is rendered, the browser only shows the active one. */
501
+ /** Badge colors of a tab: computed shades when a color manager is available. */
502
+ const badgeColorClasses = (component, color) => colorsFor(component)?.getComponentClasses('badge', color) ?? plainColorClasses(color);
503
+ const badgeTooltipAttribute = (tooltip) => (tooltip ? ` x-tooltip="${e(`{ content: ${js(tooltip)}, theme: $store.theme, allowHTML: false }`)}"` : '');
504
+ /**
505
+ * Layout tabs: every panel is rendered, the browser only shows the active one.
506
+ * The active tab may be persisted in local storage (`persistTab()`) or in the URL (`persistTabInQueryString()`):
507
+ * the browser only selects a persisted key when it matches an existing tab.
508
+ */
311
509
  export class Tabs extends Component {
312
510
  tabs = [];
313
511
  label;
512
+ _activeTab = 1;
513
+ _persistTab = false;
514
+ _tabQueryStringKey = null;
515
+ _vertical = false;
516
+ _contained = true;
517
+ _scrollable = true;
314
518
  constructor(label) { super(); this.label = label; this._keyName = `${slug(label)}::data::tabs`; }
315
519
  static make(label) { return configured(new Tabs(label)); }
316
520
  schema(tabs) { this.tabs = tabs; return this; }
521
+ /** Tab active when the page loads (1-based, among the visible tabs). */
522
+ activeTab(tab) { this._activeTab = tab; return this; }
523
+ /** Remembers the active tab in the browser's local storage, under the key of the tabs. */
524
+ persistTab(condition = true) { this._persistTab = condition; return this; }
525
+ /** Keeps the active tab in the URL's query string (`?tab=…`); `null` disables it. */
526
+ persistTabInQueryString(key = 'tab') { this._tabQueryStringKey = key === null || key.trim() === '' ? null : key; return this; }
527
+ /** Tab headers on the side of the panels (from the `md` breakpoint). */
528
+ vertical(condition = true) { this._vertical = condition; return this; }
529
+ /** `false`: no card around the tabs. */
530
+ contained(condition = true) { this._contained = condition; return this; }
531
+ /** `false`: tabs that do not fit are moved to a "more" dropdown instead of scrolling. */
532
+ scrollable(condition = true) { this._scrollable = condition; return this; }
317
533
  getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
318
534
  getLazyLoadedTabBadges() { return {}; }
319
535
  getDeferredTabBadges() { return {}; }
320
536
  getChildSchemas() { return [childSchema(this, this.tabs)]; }
537
+ /** Active tab (1-based): the query string's tab when it names a visible tab, otherwise `activeTab()`. */
538
+ async getActiveTab(visible) {
539
+ if (this._tabQueryStringKey !== null) {
540
+ const requested = this.livewire._ctx?.req?.query?.[this._tabQueryStringKey];
541
+ const index = typeof requested === 'string' ? visible.findIndex((tab) => tab.getTabKey() === requested) : -1;
542
+ if (index !== -1)
543
+ return index + 1;
544
+ }
545
+ const active = await evaluate(this._activeTab, this.context());
546
+ return Number.isInteger(active) && active >= 1 ? active : 1;
547
+ }
321
548
  async toHtml() {
322
549
  // Building the child schema attaches the tabs to their container, which is what gives them their keys.
323
550
  this.getChildSchemas();
551
+ const ctx = this.context();
324
552
  const key = this.getKey();
325
553
  const id = this.livewire.id;
326
- const q = (k) => `'${k.replace(/[^A-Za-z0-9:_-]/g, '')}'`;
554
+ const q = (k) => js(k);
327
555
  const tabId = (tab) => `fi-sc-tab-${createHash('md5').update(tab.getKey()).digest('hex')}`;
328
556
  const visible = [];
329
557
  for (const tab of this.tabs)
330
558
  if (await tab.isVisible())
331
559
  visible.push(tab);
332
- const buttons = visible.map((tab) => {
560
+ const isContained = Boolean(await evaluate(this._contained, ctx));
561
+ const isVertical = Boolean(await evaluate(this._vertical, ctx));
562
+ const isScrollable = Boolean(await evaluate(this._scrollable, ctx));
563
+ const isTabPersisted = Boolean(await evaluate(this._persistTab, ctx));
564
+ const activeTab = await this.getActiveTab(visible);
565
+ const badges = await Promise.all(visible.map((tab) => tab.getBadge()));
566
+ const badgeHtml = (badge, withIcon) => {
567
+ const badgeIcon = withIcon && badge.icon ? icon(badge.icon, { size: 'sm' }) : '';
568
+ 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>`;
569
+ };
570
+ const buttons = visible.map((tab, index) => {
333
571
  const k = tab.getTabKey();
572
+ const tabIcon = tab.getIcon() ? icon(tab.getIcon()) : '';
573
+ const badge = badges[index];
334
574
  return `<button type="button" x-bind:class="{
335
575
  'fi-active': isTabSelected(${q(k)}),
336
576
  'fi-sc-tabs-header-overflow': overflowTabs.includes(${q(k)}),
337
577
  '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>`;
578
+ }" 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
579
  });
580
+ // Not scrollable: tabs that do not fit move to an overflow dropdown, opened from a "more" trigger.
581
+ let overflow = '';
582
+ if (!isScrollable) {
583
+ const more = t('filament-schemas::components.tabs.actions.more.label');
584
+ const triggers = visible.map((tab, index) => {
585
+ const k = tab.getTabKey();
586
+ const badge = badges[index];
587
+ 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>`;
588
+ });
589
+ const items = visible.map((tab, index) => {
590
+ const k = tab.getTabKey();
591
+ const badge = badges[index];
592
+ const listBadge = badge ? `<span${badgeTooltipAttribute(badge.tooltip)} class="${cls(['fi-badge', ...badgeColorClasses(this, badge.color)])}">${e(badge.label)}</span>` : '';
593
+ 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>`;
594
+ });
595
+ 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>`;
596
+ }
340
597
  const panels = [];
341
598
  for (const tab of visible) {
342
599
  const k = tab.getTabKey();
343
600
  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
601
  }
602
+ const outer = new Attributes().merge({ id: key, 'wire:key': `${id}.${key}.container`, ...this._extraAttributes }).class(['fi-sc-tabs', { 'fi-contained': isContained, 'fi-vertical': isVertical }]);
603
+ const nav = new Attributes({ 'x-ref': 'tabsHeader' }).class(['fi-tabs', { 'fi-contained': isContained, 'fi-vertical': isVertical }])
604
+ .when(!isScrollable, (a) => a.merge({ 'x-bind:class': "{ 'fi-invisible': ! overflowReady }" }))
605
+ .merge({ 'x-cloak': true });
606
+ 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)"`}`;
607
+ const persistedTab = isTabPersisted ? e(`$persist(null).as(${js(`filament:tabs:active:${key}`)})`) : 'null';
345
608
  return `<div x-data="tabsSchemaComponent({
346
- activeTab: 1,
609
+ activeTab: ${activeTab},
347
610
  hasTabPanels: true,
348
- isScrollable: true,
349
- isVertical: false,
350
- isTabPersisted: false,
351
- isTabPersistedInQueryString: false,
611
+ isScrollable: ${isScrollable ? 'true' : 'false'},
612
+ isVertical: ${isVertical ? 'true' : 'false'},
613
+ isTabPersisted: ${isTabPersisted ? 'true' : 'false'},
614
+ isTabPersistedInQueryString: ${this._tabQueryStringKey !== null ? 'true' : 'false'},
352
615
  livewireId: ${e(js(id))},
353
616
  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>`;
617
+ tab: ${persistedTab},
618
+ tabQueryStringKey: ${e(js(this._tabQueryStringKey))},
619
+ })" 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
620
  }
358
621
  }
359
622
  /** Render hook injection point, using the host's scopes. Liberated from the container grid. */
@@ -533,6 +796,7 @@ export class Text extends Component {
533
796
  _copyableState = null;
534
797
  _copyMessage = null;
535
798
  _copyMessageDuration = 2000;
799
+ _isJs = false;
536
800
  constructor(content) { super(); this._content = content; }
537
801
  static make(content) { return configured(new Text(content)); }
538
802
  content(content) { this._content = content; return this; }
@@ -556,7 +820,17 @@ export class Text extends Component {
556
820
  copyableState(state) { this._copyableState = state; return this; }
557
821
  copyMessage(message) { this._copyMessage = message; return this; }
558
822
  copyMessageDuration(milliseconds) { this._copyMessageDuration = milliseconds; return this; }
559
- async getContent() { return evaluate(this._content, this.context()); }
823
+ /**
824
+ * The content is a JavaScript expression evaluated in the browser (`$get()` and `$state` are available), re-evaluated when the state changes.
825
+ * Only use it with expressions written by the developer, never with user input.
826
+ */
827
+ js(condition = true) { this._isJs = condition; return this; }
828
+ async getContent() {
829
+ const content = await evaluate(this._content, this.context());
830
+ if (!this._isJs || content === null)
831
+ return content;
832
+ return new HtmlString(`<span x-text="() => eval(${js(content instanceof HtmlString ? content.html : content)})"></span>`);
833
+ }
560
834
  async toHtml() {
561
835
  const ctx = this.context();
562
836
  const content = await this.getContent();
@@ -683,7 +957,7 @@ export class EmptyState extends Component {
683
957
  _iconSize = null;
684
958
  _compact = false;
685
959
  _contained = true;
686
- _headingTag = 'h2';
960
+ _headingTag = null;
687
961
  _footer = [];
688
962
  constructor(heading) { super(); this._heading = heading; }
689
963
  static make(heading) { return configured(new EmptyState(heading)); }
@@ -698,6 +972,7 @@ export class EmptyState extends Component {
698
972
  /** `false`: no surrounding card. */
699
973
  contained(condition = true) { this._contained = condition; return this; }
700
974
  /** Heading tag (`h2` by default). */
975
+ /** Heading tag; by default it follows the heading level of the schema (`h2` at the root). */
701
976
  headingTag(tag) { this._headingTag = tag; return this; }
702
977
  /** Footer: inline actions and components. */
703
978
  footer(items) { this._footer = items; return this; }
@@ -708,8 +983,9 @@ export class EmptyState extends Component {
708
983
  const description = await evaluate(this._description, ctx);
709
984
  const footer = await renderInlineItems(this, this._footer);
710
985
  const iconColor = this._iconColor ?? 'primary';
986
+ const headingTag = this._headingTag ?? this.getHeadingTag();
711
987
  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>`;
988
+ 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
989
  }
714
990
  }
715
991
  const CALLOUT_ICONS = { danger: 'o-x-circle', info: 'o-information-circle', success: 'o-check-circle', warning: 'o-exclamation-circle' };
@@ -730,6 +1006,7 @@ export class Callout extends Component {
730
1006
  _footerActionsAlignment = 'start';
731
1007
  _footer = null;
732
1008
  _controlActions = [];
1009
+ _controls = null;
733
1010
  constructor(heading = null) { super(); this._heading = heading; }
734
1011
  static make(heading = null) { return configured(new Callout(heading)); }
735
1012
  heading(heading) { this._heading = heading; return this; }
@@ -755,10 +1032,12 @@ export class Callout extends Component {
755
1032
  footer(items) { this._footer = items; return this; }
756
1033
  /** Small actions on the right of the callout (dismiss…). */
757
1034
  controlActions(actions) { this._controlActions = [...this._controlActions, ...actions]; return this; }
1035
+ /** Free-form controls (components and actions) on the right of the callout, replacing the control actions. */
1036
+ controls(items) { this._controls = items; return this; }
758
1037
  getColor() { return this._hasColor ? this._color : (this._color ?? this._status); }
759
1038
  getIcon() { return this._icon ?? (this._status ? (CALLOUT_ICONS[this._status] ?? null) : null); }
760
1039
  /** Callout actions, mounted on the root schema. */
761
- getActions() { return [...(this._footer ? actionsOf(this._footer) : this._footerActions), ...this._controlActions]; }
1040
+ getActions() { return [...(this._footer ? actionsOf(this._footer) : this._footerActions), ...(this._controls ? actionsOf(this._controls) : this._controlActions)]; }
762
1041
  async toHtml() {
763
1042
  const ctx = this.context();
764
1043
  const heading = await evaluate(this._heading, ctx);
@@ -767,7 +1046,7 @@ export class Callout extends Component {
767
1046
  const iconName = this.getIcon();
768
1047
  const iconColor = this._iconColor ?? this._status ?? color;
769
1048
  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' });
1049
+ const controls = this._controls ? await renderInlineItems(this, this._controls, { actionView: 'auto' }) : await renderInlineItems(this, this._controlActions, { alignment: 'start', actionView: 'auto' });
771
1050
  const statusKey = this._status ? `filament-schemas::components.callout.statuses.${this._status}` : null;
772
1051
  const statusLabel = statusKey && t(statusKey) !== statusKey ? t(statusKey) : null;
773
1052
  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.4",
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.4"
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>