@navette-js/schemas 1.0.0-beta.4 → 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
@@ -29,6 +29,13 @@ export declare abstract class Component {
29
29
  protected _keyName: string | null;
30
30
  protected _hidden: Lazy<boolean, ComponentContext>;
31
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>;
32
39
  protected _columnSpan: ColumnSpan;
33
40
  protected _extraAttributes: Record<string, string>;
34
41
  protected _columnStart: ColumnPlacement[];
@@ -104,6 +111,14 @@ export declare class Schema {
104
111
  private _rootHeadingLevel;
105
112
  constructor(livewire: SchemaHost);
106
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;
107
122
  key(key: string): this;
108
123
  components(components: Component[]): this;
109
124
  schema(components: Component[]): this;
@@ -152,6 +167,8 @@ export declare class Schema {
152
167
  /** Same, without hidden components or their descendants: a field inside a hidden section is neither validated nor saved. */
153
168
  getVisibleFlatComponents(): Promise<Component[]>;
154
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;
155
172
  }
156
173
  /** Child schema of a layout component: shares the parent's key and state path, without being a root. */
157
174
  export declare function childSchema(parent: Component, components: Component[]): Schema;
@@ -172,6 +189,9 @@ export declare class Group extends Component {
172
189
  static make(children?: Component[]): Group;
173
190
  schema(components: Component[]): this;
174
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;
175
195
  getChildSchemas(): Schema[];
176
196
  toHtml(): Promise<string>;
177
197
  }
@@ -216,6 +236,7 @@ export declare class Section extends Component {
216
236
  private _contentBefore;
217
237
  private _compact;
218
238
  private _secondary;
239
+ private _divided;
219
240
  private _contained;
220
241
  private _collapsible;
221
242
  private _collapsed;
@@ -242,6 +263,8 @@ export declare class Section extends Component {
242
263
  compact(condition?: Lazy<boolean, ComponentContext>): this;
243
264
  /** Less contrasting background, for a section on a background of the same color (nested sections…). */
244
265
  secondary(condition?: Lazy<boolean, ComponentContext>): this;
266
+ /** Dividing lines between the components of the content. */
267
+ divided(condition?: Lazy<boolean, ComponentContext>): this;
245
268
  /** `false`: no card around the section. */
246
269
  contained(condition?: Lazy<boolean, ComponentContext>): this;
247
270
  /** Collapsible by clicking its header; `collapsed()` starts it collapsed. */
@@ -395,16 +418,37 @@ export interface Renderable {
395
418
  toHtml(ctx: ComponentContext): Promise<string>;
396
419
  }
397
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>;
398
423
  export declare class Actions extends Component {
399
424
  private actions;
400
425
  private _fullWidth;
401
426
  private _alignment;
427
+ private _label;
428
+ private _beforeLabel;
429
+ private _afterLabel;
430
+ private _aboveContent;
431
+ private _belowContent;
402
432
  constructor(actions: Renderable[]);
403
433
  static make(actions: Renderable[]): Actions;
404
434
  fullWidth(condition?: boolean): this;
405
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[];
406
448
  toHtml(): Promise<string>;
407
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>;
408
452
  /** Color manager used outside a panel; inside a panel, the host panel's manager takes precedence. */
409
453
  export declare const useColorManager: (manager: ColorManager) => void;
410
454
  /** Item in an inline row: a schema component or an action. */
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
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';
@@ -42,6 +42,13 @@ export class Component {
42
42
  _keyName = null;
43
43
  _hidden = false;
44
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; }
45
52
  _columnSpan = { default: 1 };
46
53
  _extraAttributes = {};
47
54
  _columnStart = [];
@@ -152,6 +159,14 @@ export class Schema {
152
159
  _rootHeadingLevel = 2;
153
160
  constructor(livewire) { this.livewire = livewire; }
154
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; }
155
170
  key(key) { this._key = key; return this; }
156
171
  components(components) { this._components = components; for (const c of components)
157
172
  c.container = this; return this; }
@@ -214,13 +229,16 @@ export class Schema {
214
229
  return out;
215
230
  }
216
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();
217
235
  const withVisibility = await Promise.all(this._components.map(async (c) => [c, await c.isVisible()]));
218
236
  if (!withVisibility.some(([, v]) => v))
219
237
  return '';
220
238
  const attributes = new Attributes(this._extraClass ? { class: this._extraClass } : {})
221
239
  .when(!this._inline, (a) => a.grid(this._columns))
222
240
  .merge({
223
- '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),
224
242
  'x-data': this._root ? `filamentSchema({ livewireId: ${js(this.livewire.id)}, schemaKey: ${js(this._key)} })` : null,
225
243
  'x-on:form-validation-error.window': this._root ? 'handleFormValidationError' : null,
226
244
  'x-on:reset-schema-component-state.window': this._root ? 'handleClientSideStateReset' : null,
@@ -235,11 +253,21 @@ export class Schema {
235
253
  }
236
254
  return `<div ${attributes.toHtml()}>${parts.join('')}</div>`;
237
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
+ }
238
262
  }
239
263
  /** Child schema of a layout component: shares the parent's key and state path, without being a root. */
240
264
  export function childSchema(parent, components) {
241
265
  const c = parent.container;
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);
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;
243
271
  }
244
272
  /** Stateful group: its children live under `<container path>.<name>` (used by filter forms). */
245
273
  export class StatefulGroup extends Component {
@@ -261,7 +289,15 @@ export class Group extends Component {
261
289
  static make(children = []) { return configured(new Group(children)); }
262
290
  schema(components) { this.children = components; return this; }
263
291
  columns(columns) { this._columns = columns; return this; }
264
- getChildSchemas() { return [childSchema(this, this.children).columns(this._columns)]; }
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
+ }
265
301
  async toHtml() {
266
302
  const attributes = new Attributes().merge({ ...this._extraAttributes }).toHtml();
267
303
  return `<div${attributes ? ` ${attributes}` : ''}>${await this.getChildSchemas()[0].toHtml()}</div>`;
@@ -318,6 +354,7 @@ export class Section extends Component {
318
354
  _contentBefore = false;
319
355
  _compact = false;
320
356
  _secondary = false;
357
+ _divided = false;
321
358
  _contained = true;
322
359
  _collapsible = null;
323
360
  _collapsed = false;
@@ -344,6 +381,8 @@ export class Section extends Component {
344
381
  compact(condition = true) { this._compact = condition; return this; }
345
382
  /** Less contrasting background, for a section on a background of the same color (nested sections…). */
346
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; }
347
386
  /** `false`: no card around the section. */
348
387
  contained(condition = true) { this._contained = condition; return this; }
349
388
  /** Collapsible by clicking its header; `collapsed()` starts it collapsed. */
@@ -428,6 +467,7 @@ export class Section extends Component {
428
467
  'fi-compact': Boolean(await evaluate(this._compact, ctx)),
429
468
  'fi-collapsible': isCollapsible,
430
469
  'fi-collapsed': isCollapsible && isCollapsed,
470
+ 'fi-divided': Boolean(await evaluate(this._divided, ctx)),
431
471
  'fi-secondary': Boolean(await evaluate(this._secondary, ctx)),
432
472
  }]);
433
473
  let header = '';
@@ -652,27 +692,57 @@ export class Form extends Component {
652
692
  return `<form ${attributes.toHtml()}>${await content.toHtml()}${await footer.toHtml()}</form>`;
653
693
  }
654
694
  }
655
- /** Row of actions in a schema. */
656
695
  export class Actions extends Component {
657
696
  actions;
658
697
  _fullWidth = false;
659
698
  _alignment = null;
699
+ _label = null;
700
+ _beforeLabel = null;
701
+ _afterLabel = null;
702
+ _aboveContent = null;
703
+ _belowContent = null;
660
704
  constructor(actions) { super(); this.actions = actions; }
661
705
  static make(actions) { return configured(new Actions(actions)); }
662
706
  fullWidth(condition = true) { this._fullWidth = condition; return this; }
663
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; }
664
720
  async toHtml() {
665
721
  const ctx = this.context();
722
+ const key = this.getKey();
723
+ const mountContext = key !== null ? { ...(this.livewire?.getActionMountContext?.() ?? {}), schemaComponent: key } : null;
666
724
  const visible = [];
667
725
  for (const a of this.actions)
668
726
  if (await a.isVisible(ctx))
669
- visible.push(await a.toHtml(ctx));
670
- 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)
671
733
  return '';
672
734
  const inner = cls(['fi-ac', { 'fi-width-full': this._fullWidth }, this._alignment ? `fi-align-${this._alignment}` : null]);
673
- 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>`;
674
736
  }
675
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
+ }
676
746
  let colorManager = null;
677
747
  /** Color manager used outside a panel; inside a panel, the host panel's manager takes precedence. */
678
748
  export const useColorManager = (manager) => { colorManager = manager; };
@@ -917,7 +987,8 @@ export class Image extends Component {
917
987
  async toHtml() {
918
988
  const ctx = this.context();
919
989
  const url = String((await evaluate(this._url, ctx)) ?? '');
920
- 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))
921
992
  return '';
922
993
  const tooltip = tooltipExpression(await evaluate(this._tooltip, ctx));
923
994
  const attributes = new Attributes(this._extraAttributes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navette-js/schemas",
3
- "version": "1.0.0-beta.4",
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.4"
41
+ "@navette-js/support": "1.0.0-beta.5"
42
42
  },
43
43
  "publishConfig": {
44
44
  "access": "public",