@navette-js/forms 1.0.0-beta.2 → 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/builder.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ColorManager } from '@navette-js/support';
2
2
  import { Action } from '@navette-js/actions';
3
3
  import { Schema, type Component } from '@navette-js/schemas';
4
- import { Field } from './index.ts';
4
+ import { Field, type Rule } from './index.ts';
5
5
  /** Whether an item key is acceptable: keys come from the browser. */
6
6
  export declare const isItemKey: (key: string) => boolean;
7
7
  /** Random four-character alphanumeric key. */
@@ -32,9 +32,17 @@ export declare class Block {
32
32
  private _icon;
33
33
  private _schema;
34
34
  private _defaults;
35
+ private _pickerGroup;
36
+ private _maxItems;
35
37
  constructor(name: string);
38
+ /** How many items of this block the builder may hold; at the limit, the block leaves the picker (checked on the server). */
39
+ maxItems(count: number | null): this;
40
+ getMaxItems(): number | null;
36
41
  static make(name: string): Block;
37
42
  label(label: string | null): this;
43
+ /** Group of the block in the block picker (also set by passing `{ 'Group label': [blocks] }` to `blocks()`). */
44
+ pickerGroup(label: string | null): this;
45
+ getPickerGroup(): string | null;
38
46
  icon(name: string | null): this;
39
47
  /** Item components: a function called for each item (with its current data). */
40
48
  schema(factory: (ctx: BlockContext) => Component[]): this;
@@ -66,10 +74,20 @@ export declare class Builder extends Field {
66
74
  private _addIcon;
67
75
  private _addAlignment;
68
76
  private _maxItems;
77
+ private _minItems;
78
+ private _addable;
79
+ private _deletable;
80
+ private _reorderable;
81
+ private _reorderableWithButtons;
82
+ private _collapsible;
83
+ private _collapsed;
84
+ private _persistCollapsed;
85
+ private _searchable;
69
86
  private _extraClass;
70
87
  private _uuid;
71
88
  static make(name: string): Builder;
72
- blocks(blocks: Block[] | (() => Block[])): this;
89
+ /** Blocks, or `{ 'Group label': [blocks] }` to group them in the block picker. */
90
+ blocks(blocks: Block[] | Record<string, Block[]> | (() => Block[])): this;
73
91
  blockPickerColumns(columns?: number | null): this;
74
92
  blockPickerWidth(width: string | null): this;
75
93
  labelBetweenItems(label: string | null): this;
@@ -89,12 +107,35 @@ export declare class Builder extends Field {
89
107
  addActionIcon(name: string | null): this;
90
108
  addActionAlignment(alignment: 'start' | 'center' | 'end' | null): this;
91
109
  maxItems(count: number): this;
110
+ /** Minimum number of items, checked on the server. */
111
+ minItems(count: number | null): this;
112
+ /** `false`: no block picker (adding is refused on the server). */
113
+ addable(condition?: boolean): this;
114
+ /** `false`: no delete button (deleting is refused on the server). */
115
+ deletable(condition?: boolean): this;
116
+ /** `false`: items keep their order. */
117
+ reorderable(condition?: boolean): this;
118
+ private _dragAndDrop;
119
+ /** `false`: no drag and drop (buttons may still reorder, with `reorderableWithButtons()`). */
120
+ reorderableWithDragAndDrop(condition?: boolean): this;
121
+ private canDrag;
122
+ private canMove;
123
+ /** Move up / move down buttons in each item header. */
124
+ reorderableWithButtons(condition?: boolean): this;
125
+ /** Items can be collapsed from their header; `collapsed()` collapses them on load. */
126
+ collapsible(condition?: boolean): this;
127
+ collapsed(condition?: boolean): this;
128
+ /** Keeps each item's collapsed state in the browser (local storage). */
129
+ persistCollapsed(condition?: boolean): this;
130
+ /** Search field in the block picker. */
131
+ searchable(condition?: boolean): this;
132
+ getValidationRules(): Promise<Rule[]>;
92
133
  /** Class added to the root (e.g. `fi-fo-builder-not-contained`). */
93
134
  extraClass(name: string | null): this;
94
135
  generateUuidUsing(generator: () => string): this;
95
136
  getBlocks(): Block[];
96
137
  getBlock(name: unknown): Block | undefined;
97
- /** Blocks offered in the picker (a subclass may remove some). */
138
+ /** Blocks offered in the picker: those under their `maxItems()` (a subclass may remove more). */
98
139
  getBlockPickerBlocks(): Block[];
99
140
  /** Well-formed items only: simple key, declared type, object data. Anything else the browser sends is ignored. */
100
141
  getItems(): Record<string, Item>;
@@ -115,7 +156,7 @@ export declare class Builder extends Field {
115
156
  getChildSchemas(): Schema[];
116
157
  protected newItem(block: Block): Item;
117
158
  /** A subclass may refuse adding (a rule cap, for instance). */
118
- protected canAdd(_block: Block): boolean;
159
+ protected canAdd(block: Block): boolean;
119
160
  getActions(): Action[];
120
161
  /** Copy of an item; a subclass renews the keys of nested items. */
121
162
  protected cloneItem(item: Item): Item;
package/lib/builder.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @navette-js/forms/builder — a list of items, each one a "block" picked from a menu.
2
2
  // The state is a map `item key → { type, data }`; key order is display order.
3
- // Not supported yet: block previews, collapsing, searching the block picker.
3
+ // Not supported yet: block previews.
4
4
  import { Attributes, cls, e, icon, js, loadingIndicator, views, configured } from '@navette-js/support';
5
5
  import { t } from '@navette-js/support/i18n';
6
6
  import { Action } from '@navette-js/actions';
@@ -45,9 +45,17 @@ export class Block {
45
45
  _icon = null;
46
46
  _schema = () => [];
47
47
  _defaults = null;
48
+ _pickerGroup = null;
49
+ _maxItems = null;
48
50
  constructor(name) { this.name = name; }
51
+ /** How many items of this block the builder may hold; at the limit, the block leaves the picker (checked on the server). */
52
+ maxItems(count) { this._maxItems = count; return this; }
53
+ getMaxItems() { return this._maxItems; }
49
54
  static make(name) { return configured(new Block(name)); }
50
55
  label(label) { this._label = label; return this; }
56
+ /** Group of the block in the block picker (also set by passing `{ 'Group label': [blocks] }` to `blocks()`). */
57
+ pickerGroup(label) { this._pickerGroup = label; return this; }
58
+ getPickerGroup() { return this._pickerGroup; }
51
59
  icon(name) { this._icon = name; return this; }
52
60
  /** Item components: a function called for each item (with its current data). */
53
61
  schema(factory) { this._schema = factory; return this; }
@@ -77,10 +85,24 @@ export class Builder extends Field {
77
85
  _addIcon = null;
78
86
  _addAlignment = null;
79
87
  _maxItems = 100;
88
+ _minItems = null;
89
+ _addable = true;
90
+ _deletable = true;
91
+ _reorderable = true;
92
+ _reorderableWithButtons = false;
93
+ _collapsible = false;
94
+ _collapsed = false;
95
+ _persistCollapsed = false;
96
+ _searchable = false;
80
97
  _extraClass = null;
81
98
  _uuid = randomUUID;
82
99
  static make(name) { return configured(new Builder(name)); }
83
- blocks(blocks) { this._blocks = blocks; this._resolvedBlocks = null; return this; }
100
+ /** Blocks, or `{ 'Group label': [blocks] }` to group them in the block picker. */
101
+ blocks(blocks) {
102
+ this._blocks = typeof blocks === 'function' || Array.isArray(blocks) ? blocks : Object.entries(blocks).flatMap(([group, list]) => list.map((block) => block.pickerGroup(group)));
103
+ this._resolvedBlocks = null;
104
+ return this;
105
+ }
84
106
  blockPickerColumns(columns = 2) { this._pickerColumns = columns; return this; }
85
107
  blockPickerWidth(width) { this._pickerWidth = width; return this; }
86
108
  labelBetweenItems(label) { this._labelBetweenItems = label; return this; }
@@ -100,13 +122,49 @@ export class Builder extends Field {
100
122
  addActionIcon(name) { this._addIcon = name; return this; }
101
123
  addActionAlignment(alignment) { this._addAlignment = alignment; return this; }
102
124
  maxItems(count) { this._maxItems = count; return this; }
125
+ /** Minimum number of items, checked on the server. */
126
+ minItems(count) { this._minItems = count; return this; }
127
+ /** `false`: no block picker (adding is refused on the server). */
128
+ addable(condition = true) { this._addable = condition; return this; }
129
+ /** `false`: no delete button (deleting is refused on the server). */
130
+ deletable(condition = true) { this._deletable = condition; return this; }
131
+ /** `false`: items keep their order. */
132
+ reorderable(condition = true) { this._reorderable = condition; return this; }
133
+ _dragAndDrop = true;
134
+ /** `false`: no drag and drop (buttons may still reorder, with `reorderableWithButtons()`). */
135
+ reorderableWithDragAndDrop(condition = true) { this._dragAndDrop = condition; return this; }
136
+ canDrag() { return this._reorderable && this._dragAndDrop; }
137
+ canMove() { return this._reorderable && (this._dragAndDrop || this._reorderableWithButtons); }
138
+ /** Move up / move down buttons in each item header. */
139
+ reorderableWithButtons(condition = true) { this._reorderableWithButtons = condition; return this; }
140
+ /** Items can be collapsed from their header; `collapsed()` collapses them on load. */
141
+ collapsible(condition = true) { this._collapsible = condition; return this; }
142
+ collapsed(condition = true) { this._collapsed = condition; if (condition)
143
+ this._collapsible = true; return this; }
144
+ /** Keeps each item's collapsed state in the browser (local storage). */
145
+ persistCollapsed(condition = true) { this._persistCollapsed = condition; return this; }
146
+ /** Search field in the block picker. */
147
+ searchable(condition = true) { this._searchable = condition; return this; }
148
+ async getValidationRules() {
149
+ const count = () => Object.keys(this.getItems()).length;
150
+ const rules = [];
151
+ if (await this.isRequired())
152
+ rules.push({ name: 'required', passes: () => count() > 0, message: (a) => `The ${a} field is required.` });
153
+ if (this._minItems !== null)
154
+ rules.push({ name: 'min', implicit: true, passes: () => count() >= this._minItems, message: (a) => `The ${a} field must have at least ${this._minItems} items.` });
155
+ const items = () => Object.values(this.getItems());
156
+ for (const block of this.getBlocks())
157
+ if (block.getMaxItems() !== null)
158
+ rules.push({ name: 'max', implicit: true, passes: () => items().filter((item) => item.type === block.name).length <= block.getMaxItems(), message: (a) => `The ${a} field must not have more than ${block.getMaxItems()} ${block.getLabel()} items.` });
159
+ return [...rules, ...this._rules];
160
+ }
103
161
  /** Class added to the root (e.g. `fi-fo-builder-not-contained`). */
104
162
  extraClass(name) { this._extraClass = name; return this; }
105
163
  generateUuidUsing(generator) { this._uuid = generator; return this; }
106
164
  getBlocks() { return (this._resolvedBlocks ??= typeof this._blocks === 'function' ? this._blocks() : this._blocks); }
107
165
  getBlock(name) { return typeof name === 'string' ? this.getBlocks().find((block) => block.name === name) : undefined; }
108
- /** Blocks offered in the picker (a subclass may remove some). */
109
- getBlockPickerBlocks() { return this.getBlocks(); }
166
+ /** Blocks offered in the picker: those under their `maxItems()` (a subclass may remove more). */
167
+ getBlockPickerBlocks() { const items = Object.values(this.getItems()); return this.getBlocks().filter((block) => block.getMaxItems() === null || items.filter((item) => item.type === block.name).length < block.getMaxItems()); }
110
168
  /** Well-formed items only: simple key, declared type, object data. Anything else the browser sends is ignored. */
111
169
  getItems() {
112
170
  const state = this.context().state;
@@ -165,7 +223,7 @@ export class Builder extends Field {
165
223
  /** Item schema: key `<builder>.<item>.data`, state at the same path. */
166
224
  itemSchema(itemKey, item) {
167
225
  const container = this.container;
168
- return new Schema(container.livewire).key(`${this.getKey()}.${itemKey}.data`).statePath(`${this.getStatePath()}.${itemKey}.data`).operation(container.getOperation()).record(container.getRecord()).root(false)
226
+ return new Schema(container.livewire).key(`${this.getKey()}.${itemKey}.data`).statePath(`${this.getStatePath()}.${itemKey}.data`).operation(container.getOperation()).record(container.getRecord()).root(false).parentComponent(this)
169
227
  .components(this.getBlock(item.type).getComponents({ builder: this, itemKey, data: item.data }));
170
228
  }
171
229
  getChildSchemas() { return Object.entries(this.getItems()).map(([key, item]) => this.itemSchema(key, item)); }
@@ -179,7 +237,10 @@ export class Builder extends Field {
179
237
  return item;
180
238
  }
181
239
  /** A subclass may refuse adding (a rule cap, for instance). */
182
- canAdd(_block) { return Object.keys(this.getItems()).length < this._maxItems; }
240
+ canAdd(block) {
241
+ const items = Object.values(this.getItems());
242
+ return this._addable && items.length < this._maxItems && (block.getMaxItems() === null || items.filter((item) => item.type === block.name).length < block.getMaxItems());
243
+ }
183
244
  getActions() {
184
245
  const insert = (items, after, key, item) => {
185
246
  if (after === null || !Object.hasOwn(items, after))
@@ -187,6 +248,8 @@ export class Builder extends Field {
187
248
  return Object.fromEntries(Object.entries(items).flatMap(([k, v]) => (k === after ? [[k, v], [key, item]] : [[k, v]])));
188
249
  };
189
250
  const move = (offset) => ({ arguments: args }) => {
251
+ if (!this.canMove())
252
+ return;
190
253
  const items = this.getItems();
191
254
  const keys = Object.keys(items);
192
255
  const from = keys.indexOf(String(args.item));
@@ -211,7 +274,7 @@ export class Builder extends Field {
211
274
  return;
212
275
  this.setItems(insert(items, args.afterItem, this._uuid(), this.newItem(block)));
213
276
  }),
214
- Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!Object.hasOwn(items, key))
277
+ Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!this._deletable || !Object.hasOwn(items, key))
215
278
  return; delete items[key]; this.setItems(items); }),
216
279
  Action.make('clone').action(({ arguments: args }) => {
217
280
  const items = this.getItems();
@@ -224,6 +287,8 @@ export class Builder extends Field {
224
287
  Action.make('moveDown').action(move(1)),
225
288
  // Drag and drop: the new order is only accepted if it is a permutation of the existing keys.
226
289
  Action.make('reorder').action(({ arguments: args }) => {
290
+ if (!this.canDrag())
291
+ return;
227
292
  const items = this.getItems();
228
293
  const keys = Object.keys(items);
229
294
  const order = Array.isArray(args.items) ? args.items.map(String) : [];
@@ -249,20 +314,31 @@ export class Builder extends Field {
249
314
  const alignment = between ? null : this._addAlignment;
250
315
  const placement = alignment === 'start' ? 'bottom-start' : alignment === 'end' ? 'bottom-end' : null;
251
316
  const widthClass = this._pickerWidth ? `fi-width-${this._pickerWidth}` : null;
252
- const signature = `${widthClass ?? ''}|${placement ?? ''}|${blocks.length ? 1 : 0}|0`;
317
+ const signature = `${widthClass ?? ''}|${placement ?? ''}|${blocks.length ? 1 : 0}|${this._searchable ? 1 : 0}`;
253
318
  const wireKey = `${key}.${actionName}.${afterItem ?? ''}.block-picker.${PICKER_HASHES[signature] ?? createHash('md5').update(signature).digest('hex')}`;
254
319
  const items = blocks.map((block) => {
255
320
  const expression = `mountAction('${actionName}', ${js(between ? { block: block.name, afterItem } : { block: block.name })}, { schemaComponent: ${js(key)} })`;
256
321
  const blockIcon = block.getIcon();
257
- return `<button x-on:click="close" wire:click="${e(expression)}" type="button" wire:loading.attr="disabled" wire:target="${e(expression)}" class="fi-dropdown-list-item">${blockIcon ? icon(blockIcon, { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': expression }) }) + loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': expression })) : ''}<span class="fi-dropdown-list-item-label">${e(block.getLabel())}</span></button>`;
322
+ const search = this._searchable ? ` role="button" tabindex="0" wire:key="${e(createHash('md5').update(expression + block.getLabel().toLowerCase()).digest('hex'))}" data-block-label="${e(block.getLabel().toLowerCase())}" x-show="isBlockVisible($el)"` : '';
323
+ return `<button x-on:click="close" wire:click="${e(expression)}"${search} type="button" wire:loading.attr="disabled" wire:target="${e(expression)}" class="fi-dropdown-list-item">${blockIcon ? icon(blockIcon, { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': expression }) }) + loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': expression })) : ''}<span class="fi-dropdown-list-item-label">${e(block.getLabel())}</span></button>`;
258
324
  });
259
325
  const grid = this._pickerColumns ? ` class="fi-grid fi-grid-direction-col lg:fi-grid-cols" style="--cols-lg: ${this._pickerColumns}; --cols-default: 1;"` : ' class="fi-grid fi-grid-direction-col" style="--cols-default: 1;"';
260
- const list = blocks.length ? `<div class="fi-dropdown-list"><div><div${grid}>${items.join('')}</div></div></div>` : '';
326
+ // Grouped blocks: one labelled group per picker group (ungrouped blocks first), the groups laid out in the grid.
327
+ const groups = new Map();
328
+ blocks.forEach((block, index) => { const group = block.getPickerGroup() ?? ''; groups.set(group, [...(groups.get(group) ?? []), items[index]]); });
329
+ const grouped = [...groups.keys()].some((group) => group !== '');
330
+ const content = grouped
331
+ ? `<div${grid.replace(/class="([^"]*)"/, 'class="$1 fi-fo-builder-block-picker-groups"')}>${[...groups].map(([group, groupItems]) => `<div class="fi-fo-builder-block-picker-group"${group ? ` role="group" aria-label="${e(group)}"` : ''}>${group ? `<div class="fi-fo-builder-block-picker-group-label">${e(group)}</div>` : ''}${groupItems.join('')}</div>`).join('')}</div>`
332
+ : `<div${grid}>${items.join('')}</div>`;
333
+ const searchPrompt = tr('block_picker.search_prompt');
334
+ const searchInput = this._searchable ? `<div class="fi-fo-builder-block-picker-search-ctn"><div class="fi-input-wrp"><div class="fi-input-wrp-content-ctn"><input type="text" x-ref="searchInput" x-on:keydown.enter.prevent="" aria-label="${e(searchPrompt)}" placeholder="${e(searchPrompt)}" x-model.debounce.0="search" class="fi-input" /></div></div></div>` : '';
335
+ const noResults = this._searchable ? `<div x-cloak x-show="hasNoSearchResults" role="status" aria-live="polite" class="fi-fo-builder-block-picker-no-search-results-message">${e(tr('block_picker.no_search_results_message'))}</div>` : '';
336
+ const list = blocks.length ? `<div${this._searchable ? ' x-data="blockPicker()"' : ''} class="fi-dropdown-list">${searchInput}<div>${content}</div>${noResults}</div>` : '';
261
337
  const addIcon = !between && this._addIcon ? icon(this._addIcon, { size: 'sm' }) : '';
262
338
  const triggerLabel = between
263
339
  ? (this._addBetweenLabel ?? tr('actions.add_between.label'))
264
340
  : (this._addLabel ?? tr('actions.add.label').replace(':label', this._labelText.replace(/^./, (c) => c.toLowerCase())).trim());
265
- return `<div x-data="filamentDropdown({ isMenu: true })" wire:key="${e(wireKey)}" class="${cls(['fi-dropdown', 'fi-fo-builder-block-picker', alignment ? `fi-align-${alignment}` : null])}"><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm">${addIcon}${e(triggerLabel)}</button></div><div x-cloak="x-cloak" x-ref="panel" wire:key="${e(wireKey)}.panel" wire:ignore.self="" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" x-float${placement ? `.placement.${placement}` : ''}.flip.shift.offset="{ offset: 8 }" class="${cls(['fi-dropdown-panel', widthClass])}">${list}</div></div>`;
341
+ return `<div x-data="filamentDropdown({ isMenu: ${this._searchable ? 'false' : 'true'} })" wire:key="${e(wireKey)}" class="${cls(['fi-dropdown', 'fi-fo-builder-block-picker', alignment ? `fi-align-${alignment}` : null])}"><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm">${addIcon}${e(triggerLabel)}</button></div><div x-cloak="x-cloak" x-ref="panel" wire:key="${e(wireKey)}.panel" wire:ignore.self="" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" x-float${placement ? `.placement.${placement}` : ''}.flip.shift.offset="{ offset: 8 }" class="${cls(['fi-dropdown-panel', widthClass])}">${list}</div></div>`;
266
342
  }
267
343
  _labelText = '';
268
344
  async toHtml() {
@@ -283,13 +359,17 @@ export class Builder extends Field {
283
359
  if (this._blockHeaders) {
284
360
  const moveButton = (action, label, iconName, direction, disabled) => {
285
361
  const expression = this.actionExpression(action, { item: itemKey });
286
- return `<li x-on:click.stop class="fi-sr-only">${iconActionButton({ inHeader: true, label, icon: iconName, expression, disabled, attributes: { tabindex: '-1', 'data-reorder-hidden': 'data-reorder-hidden', 'data-reorder-direction': direction, 'data-reorder-action': expression, 'aria-describedby': descriptionId(itemKey) } })}</li>`;
362
+ return `<li x-on:click.stop${this._reorderableWithButtons ? '' : ' class="fi-sr-only"'}>${iconActionButton({ inHeader: true, label, icon: iconName, expression, disabled, attributes: { tabindex: this._reorderableWithButtons ? null : '-1', 'data-reorder-hidden': 'data-reorder-hidden', 'data-reorder-direction': direction, 'data-reorder-action': expression, 'aria-describedby': descriptionId(itemKey) } })}</li>`;
287
363
  };
288
364
  const blockIcon = block.getIcon();
289
- header = `<div class="fi-fo-builder-item-header"><ul class="fi-fo-builder-item-header-start-actions"><li x-on:click.stop><button title="${e(tr('actions.reorder.label'))}" aria-label="${e(tr('actions.reorder.label'))}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-icon-btn-action fi-icon-btn fi-size-sm" x-sortable-handle="x-sortable-handle">${icon('m-arrows-up-down')}</button></li>${moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', index === 0)}${moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', index === keys.length - 1)}</ul>${this._blockIcons && blockIcon ? icon(blockIcon, { attributes: new Attributes().class(['fi-fo-builder-item-header-icon']) }) : ''}<${this._headingTag} class="fi-fo-builder-item-header-label fi-truncated">${e(block.getLabel())}${this._blockNumbers ? ` ${index + 1}` : ''}</${this._headingTag}><ul class="fi-fo-builder-item-header-end-actions">${this._cloneable ? `<li x-on:click.stop>${iconActionButton({ inHeader: true, label: tr('actions.clone.label'), icon: 'm-square-2-stack', expression: this.actionExpression('clone', { item: itemKey }) })}</li>` : ''}<li x-on:click.stop>${iconActionButton({ inHeader: true, label: tr('actions.delete.label'), icon: 'm-trash', expression: this.actionExpression('delete', { item: itemKey }), colorClasses: danger })}</li></ul></div>`;
365
+ const gray = colorsOf(this.livewire)?.getComponentClasses('icon-button', 'gray') ?? [];
366
+ const collapse = this._collapsible ? `<li class="fi-fo-builder-item-header-collapsible-actions" x-on:click.stop="toggleCollapsed"><div class="fi-fo-builder-item-header-collapse-action"><button title="${e(tr('actions.collapse.label'))}" aria-label="${e(tr('actions.collapse.label'))}" type="button" class="${cls(['fi-ac-icon-btn-action', 'fi-icon-btn', 'fi-size-sm', ...gray])}">${icon('m-chevron-up')}</button></div><div class="fi-fo-builder-item-header-expand-action"><button title="${e(tr('actions.expand.label'))}" aria-label="${e(tr('actions.expand.label'))}" type="button" class="${cls(['fi-ac-icon-btn-action', 'fi-icon-btn', 'fi-size-sm', ...gray])}">${icon('m-chevron-down')}</button></div></li>` : '';
367
+ const reorder = this.canDrag() ? `<li x-on:click.stop><button title="${e(tr('actions.reorder.label'))}" aria-label="${e(tr('actions.reorder.label'))}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-icon-btn-action fi-icon-btn fi-size-sm" x-sortable-handle="x-sortable-handle">${icon('m-arrows-up-down')}</button></li>` : '';
368
+ const moves = this.canMove() ? `${moveButton('moveUp', tr('actions.move_up.label'), 'm-arrow-up', 'up', index === 0)}${moveButton('moveDown', tr('actions.move_down.label'), 'm-arrow-down', 'down', index === keys.length - 1)}` : '';
369
+ header = `<div${this._collapsible ? ' x-on:click.stop="toggleCollapsed"' : ''} class="fi-fo-builder-item-header">${reorder || moves ? `<ul class="fi-fo-builder-item-header-start-actions">${reorder}${moves}</ul>` : ''}${this._blockIcons && blockIcon ? icon(blockIcon, { attributes: new Attributes().class(['fi-fo-builder-item-header-icon']) }) : ''}<${this._headingTag} class="fi-fo-builder-item-header-label fi-truncated">${e(block.getLabel())}${this._blockNumbers ? ` ${index + 1}` : ''}</${this._headingTag}><ul class="fi-fo-builder-item-header-end-actions">${this._cloneable ? `<li x-on:click.stop>${iconActionButton({ inHeader: true, label: tr('actions.clone.label'), icon: 'm-square-2-stack', expression: this.actionExpression('clone', { item: itemKey }) })}</li>` : ''}${this._deletable ? `<li x-on:click.stop>${iconActionButton({ inHeader: true, label: tr('actions.delete.label'), icon: 'm-trash', expression: this.actionExpression('delete', { item: itemKey }), colorClasses: danger })}</li>` : ''}${collapse}</ul></div>`;
290
370
  }
291
- rows.push(`<li wire:ignore.self wire:key="${e(livewireKey)}.${e(itemKey)}.data.item" x-data="item(${e(js({ isCollapsed: false, collapseKey: null }))})" x-on:builder-expand.window="expandFromEvent" x-on:builder-collapse.window="collapseFromEvent" x-on:expand="expand" x-sortable-item="${e(itemKey)}" class="${cls(['fi-fo-builder-item', { 'fi-fo-builder-item-has-header': this._blockHeaders }])}" x-bind:class="itemClasses">${header}<div x-show="! isCollapsed" class="fi-fo-builder-item-content">${await this.itemSchema(itemKey, item).toHtml()}</div></li>`);
292
- if (index < keys.length - 1 && this._addBetween && this.canAdd(block))
371
+ rows.push(`<li wire:ignore.self wire:key="${e(livewireKey)}.${e(itemKey)}.data.item" x-data="item(${e(js({ isCollapsed: this._collapsed, collapseKey: this._persistCollapsed ? `filament:builder:collapsed:${key}-${itemKey}` : null }))})" x-on:builder-expand.window="expandFromEvent" x-on:builder-collapse.window="collapseFromEvent" x-on:expand="expand" x-sortable-item="${e(itemKey)}" class="${cls(['fi-fo-builder-item', { 'fi-fo-builder-item-has-header': this._blockHeaders }])}" x-bind:class="itemClasses">${header}<div x-show="! isCollapsed" class="fi-fo-builder-item-content">${await this.itemSchema(itemKey, item).toHtml()}</div></li>`);
372
+ if (index < keys.length - 1 && this._addBetween && this._addable && this.getBlockPickerBlocks().length && Object.keys(items).length < this._maxItems)
293
373
  rows.push(`<li class="fi-fo-builder-add-between-items-ctn"><div class="fi-fo-builder-add-between-items"><div class="fi-fo-builder-block-picker-ctn">${this.blockPickerHtml(itemKey)}</div></div></li>`);
294
374
  else if (index < keys.length - 1 && this._labelBetweenItems)
295
375
  rows.push(`<li class="fi-fo-builder-label-between-items-ctn"><div class="fi-fo-builder-label-between-items-divider-before"></div><span class="fi-fo-builder-label-between-items">${e(this._labelBetweenItems)}</span><div class="fi-fo-builder-label-between-items-divider-after"></div></li>`);
@@ -300,7 +380,10 @@ export class Builder extends Field {
300
380
  return `<span class="fi-sr-only" data-reorder-description="${e(itemKey)}" data-reorder-label="${e(label)}" id="${e(descriptionId(itemKey))}">${e(tr('reordering.position', { label, position: String(index + 1), count: String(keys.length) }))}</span>`;
301
381
  }).join('');
302
382
  const list = keys.length ? `<ul x-sortable x-ref="reorderItems" data-sortable-animation-duration="300" x-on:end.stop="reorder" class="fi-fo-builder-items">${rows.join('')}</ul>` : '';
303
- const html = `<div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" data-reorder-owner="data-reorder-owner" data-reorder-items="${e(JSON.stringify(keys))}" x-load="x-load" x-load-src="${e(asset('builder'))}" x-data="builderFormComponent(${e(js(config))})" class="${cls([this._extraClass, 'fi-fo-builder'])}"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span>${list}${this.blockPickerHtml()}${descriptions}</div>`;
383
+ const link = (label, click) => `<span x-on:click="${click}"><button type="button" class="${cls(['fi-ac-link-action', 'fi-link', 'fi-size-sm', ...(colorsOf(this.livewire)?.getComponentClasses('link', 'gray') ?? [])])}"><span class="fi-link-label">${e(label)}</span></button></span>`;
384
+ const collapseAll = this._collapsible ? `<div class="${cls(['fi-fo-builder-actions', { 'fi-hidden': keys.length < 2 }])}">${link(tr('actions.collapse_all.label'), 'collapseAll')}${link(tr('actions.expand_all.label'), 'expandAll')}</div>` : '';
385
+ const picker = this._addable && keys.length < this._maxItems ? this.blockPickerHtml() : '';
386
+ const html = `<div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" data-reorder-owner="data-reorder-owner" data-reorder-items="${e(JSON.stringify(keys))}" x-load="x-load" x-load-src="${e(asset('builder'))}" x-data="builderFormComponent(${e(js(config))})" class="${cls([this._extraClass, 'fi-fo-builder', { 'fi-collapsible': this._collapsible }])}"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span>${collapseAll}${list}${picker}${descriptions}</div>`;
304
387
  return this.wrap(html, { labelAsDiv: true });
305
388
  }
306
389
  }