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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NavetteJS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @navette-js/forms
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Form fields and validation for Navette admin panels.
4
+
5
+ Part of [Navette](https://github.com/NavetteJS/navette), admin panels for Node.js. Start a project with `npm create navette@latest`, or install the framework with `npm install @navette-js/panels`.
6
+
7
+ **Beta:** the API may still change between beta releases.
8
+
9
+ License: MIT. Third-party notices: see `THIRD-PARTY-NOTICES.md` where present.
@@ -0,0 +1,128 @@
1
+ import { type ColorManager } from '@navette-js/support';
2
+ import { Action } from '@navette-js/actions';
3
+ import { Schema, type Component } from '@navette-js/schemas';
4
+ import { Field } from './index.ts';
5
+ /** Whether an item key is acceptable: keys come from the browser. */
6
+ export declare const isItemKey: (key: string) => boolean;
7
+ /** Random four-character alphanumeric key. */
8
+ export declare const shortKey: () => string;
9
+ /** Writes a value at a state path of the host component. */
10
+ export declare function writeState(target: any, segments: string[], value: unknown): void;
11
+ /** Icon button for a component action: the icon, then a loading indicator. */
12
+ export declare function iconActionButton(options: {
13
+ label: string;
14
+ icon: string;
15
+ expression: string;
16
+ colorClasses?: string[];
17
+ attributes?: Record<string, string | null>;
18
+ disabled?: boolean;
19
+ inHeader?: boolean;
20
+ }): string;
21
+ /** Color manager of the host's panel (pages and widgets expose it through `panel`). */
22
+ export declare const colorsOf: (livewire: unknown) => ColorManager | null;
23
+ type BlockContext = {
24
+ builder: Builder;
25
+ itemKey: string;
26
+ data: Record<string, unknown>;
27
+ };
28
+ /** Item type of a Builder. */
29
+ export declare class Block {
30
+ readonly name: string;
31
+ private _label;
32
+ private _icon;
33
+ private _schema;
34
+ private _defaults;
35
+ constructor(name: string);
36
+ static make(name: string): Block;
37
+ label(label: string | null): this;
38
+ icon(name: string | null): this;
39
+ /** Item components: a function called for each item (with its current data). */
40
+ schema(factory: (ctx: BlockContext) => Component[]): this;
41
+ /** Data of a new item when it cannot be derived from the fields' defaults (fields depending on another). */
42
+ defaultData(factory: (builder: Builder) => Record<string, unknown>): this;
43
+ getLabel(): string;
44
+ getIcon(): string | null;
45
+ getComponents(ctx: BlockContext): Component[];
46
+ getDefaults(builder: Builder): Record<string, unknown> | null;
47
+ }
48
+ type Item = {
49
+ type: string;
50
+ data: Record<string, unknown>;
51
+ };
52
+ export declare class Builder extends Field {
53
+ private _blocks;
54
+ private _resolvedBlocks;
55
+ private _pickerColumns;
56
+ private _pickerWidth;
57
+ private _labelBetweenItems;
58
+ private _blockHeaders;
59
+ private _cloneable;
60
+ private _blockNumbers;
61
+ private _blockIcons;
62
+ private _headingTag;
63
+ private _addBetween;
64
+ private _addBetweenLabel;
65
+ private _addLabel;
66
+ private _addIcon;
67
+ private _addAlignment;
68
+ private _maxItems;
69
+ private _extraClass;
70
+ private _uuid;
71
+ static make(name: string): Builder;
72
+ blocks(blocks: Block[] | (() => Block[])): this;
73
+ blockPickerColumns(columns?: number | null): this;
74
+ blockPickerWidth(width: string | null): this;
75
+ labelBetweenItems(label: string | null): this;
76
+ /** Without a header, an item only shows its content: its actions are then up to the block. */
77
+ blockHeaders(condition?: boolean): this;
78
+ cloneable(condition?: boolean): this;
79
+ /** Item number after the block label (on by default). */
80
+ blockNumbers(condition?: boolean): this;
81
+ /** Block icon in the item header (off by default). */
82
+ blockIcons(condition?: boolean): this;
83
+ /** Tag of the header label (`h2` by default). */
84
+ blockLabelHeadingTag(tag: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'): this;
85
+ /** "Insert between blocks" picker between two items (on by default). */
86
+ addBetweenAction(condition?: boolean): this;
87
+ addBetweenActionLabel(label: string | null): this;
88
+ addActionLabel(label: string | null): this;
89
+ addActionIcon(name: string | null): this;
90
+ addActionAlignment(alignment: 'start' | 'center' | 'end' | null): this;
91
+ maxItems(count: number): this;
92
+ /** Class added to the root (e.g. `fi-fo-builder-not-contained`). */
93
+ extraClass(name: string | null): this;
94
+ generateUuidUsing(generator: () => string): this;
95
+ getBlocks(): Block[];
96
+ getBlock(name: unknown): Block | undefined;
97
+ /** Blocks offered in the picker (a subclass may remove some). */
98
+ getBlockPickerBlocks(): Block[];
99
+ /** Well-formed items only: simple key, declared type, object data. Anything else the browser sends is ignored. */
100
+ getItems(): Record<string, Item>;
101
+ protected setItems(items: Record<string, Item>): void;
102
+ getDefaultState(): unknown;
103
+ /**
104
+ * Stored value (a `[{ type, data }]` list, possibly as JSON) to form state (a map by key).
105
+ * Each block's fields hydrate their own value.
106
+ */
107
+ hydrateState(value: unknown): unknown;
108
+ private _storedAsList;
109
+ /** `false`: the stored value stays the keyed map of the state (filters) instead of a `[{ type, data }]` list. */
110
+ storedAsList(condition?: boolean): this;
111
+ finalizeDehydratedState(state: unknown): unknown;
112
+ dehydrateState(): unknown;
113
+ /** Item schema: key `<builder>.<item>.data`, state at the same path. */
114
+ protected itemSchema(itemKey: string, item: Item): Schema;
115
+ getChildSchemas(): Schema[];
116
+ protected newItem(block: Block): Item;
117
+ /** A subclass may refuse adding (a rule cap, for instance). */
118
+ protected canAdd(_block: Block): boolean;
119
+ getActions(): Action[];
120
+ /** Copy of an item; a subclass renews the keys of nested items. */
121
+ protected cloneItem(item: Item): Item;
122
+ /** `mountAction(...)` expression for an action of this component. */
123
+ actionExpression(action: string, args: Record<string, unknown> | null): string;
124
+ private blockPickerHtml;
125
+ private _labelText;
126
+ toHtml(): Promise<string>;
127
+ }
128
+ export {};
package/lib/builder.js ADDED
@@ -0,0 +1,306 @@
1
+ // @navette-js/forms/builder — a list of items, each one a "block" picked from a menu.
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.
4
+ import { Attributes, cls, e, icon, js, loadingIndicator, views, configured } from '@navette-js/support';
5
+ import { t } from '@navette-js/support/i18n';
6
+ import { Action } from '@navette-js/actions';
7
+ import { Schema } from '@navette-js/schemas';
8
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
9
+ import { Field, fillSchema } from "./index.js";
10
+ const asset = (name) => views.render('filament-forms::asset', { path: name }).trim();
11
+ const ITEM_KEY = /^[A-Za-z0-9_-]{1,64}$/;
12
+ /** Whether an item key is acceptable: keys come from the browser. */
13
+ export const isItemKey = (key) => ITEM_KEY.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
14
+ /** Random four-character alphanumeric key. */
15
+ export const shortKey = () => Array.from(randomBytes(4), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join('');
16
+ /** Writes a value at a state path of the host component. */
17
+ export function writeState(target, segments, value) {
18
+ let node = target;
19
+ for (const key of segments.slice(0, -1)) {
20
+ if (node[key] === null || typeof node[key] !== 'object')
21
+ node[key] = {};
22
+ node = node[key];
23
+ }
24
+ node[segments[segments.length - 1]] = value;
25
+ }
26
+ /** Icon button for a component action: the icon, then a loading indicator. */
27
+ export function iconActionButton(options) {
28
+ // Item header buttons put `tabindex` before `type`, and the class before the extra attributes.
29
+ const { tabindex = null, ...extra } = options.attributes ?? {};
30
+ const attributes = new Attributes(options.inHeader ? {
31
+ title: options.label, 'aria-disabled': options.disabled ? 'true' : null, 'aria-label': options.label, disabled: options.disabled ? 'disabled' : null, tabindex, type: 'button',
32
+ 'wire:loading.attr': 'aria-disabled', 'wire:loading.class': 'fi-disabled', class: '', ...extra, 'wire:click': options.expression,
33
+ } : {
34
+ title: options.label, 'aria-disabled': options.disabled ? 'true' : null, 'aria-label': options.label, disabled: options.disabled ? 'disabled' : null, type: 'button',
35
+ 'wire:loading.attr': 'aria-disabled', 'wire:loading.class': 'fi-disabled', ...(options.attributes ?? {}), 'wire:click': options.expression,
36
+ }).class(['fi-ac-icon-btn-action', 'fi-icon-btn', { 'fi-disabled': options.disabled === true }, 'fi-size-sm', ...(options.colorClasses ?? [])]);
37
+ return `<button ${attributes.toHtml()}>${icon(options.icon, { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': options.expression }) })}${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': options.expression }))}</button>`;
38
+ }
39
+ /** Color manager of the host's panel (pages and widgets expose it through `panel`). */
40
+ export const colorsOf = (livewire) => livewire?.panel?.colors ?? null;
41
+ /** Item type of a Builder. */
42
+ export class Block {
43
+ name;
44
+ _label = null;
45
+ _icon = null;
46
+ _schema = () => [];
47
+ _defaults = null;
48
+ constructor(name) { this.name = name; }
49
+ static make(name) { return configured(new Block(name)); }
50
+ label(label) { this._label = label; return this; }
51
+ icon(name) { this._icon = name; return this; }
52
+ /** Item components: a function called for each item (with its current data). */
53
+ schema(factory) { this._schema = factory; return this; }
54
+ /** Data of a new item when it cannot be derived from the fields' defaults (fields depending on another). */
55
+ defaultData(factory) { this._defaults = factory; return this; }
56
+ getLabel() { return this._label ?? this.name.replace(/[_-]+/g, ' ').replace(/^./, (c) => c.toUpperCase()); }
57
+ getIcon() { return this._icon; }
58
+ getComponents(ctx) { return this._schema(ctx); }
59
+ getDefaults(builder) { return this._defaults ? this._defaults(builder) : null; }
60
+ }
61
+ /** Precomputed block picker key hashes for common `width|placement|has blocks|searchable` signatures; others hash the signature itself. */
62
+ const PICKER_HASHES = { 'fi-width-md|bottom-start|1|0': '8b2521a32ddb82abb3a5f4fd214c90b1', '||1|0': '9c288f69c73367423cc1eb48b3e9a82b' };
63
+ export class Builder extends Field {
64
+ _blocks = [];
65
+ _resolvedBlocks = null;
66
+ _pickerColumns = null;
67
+ _pickerWidth = null;
68
+ _labelBetweenItems = null;
69
+ _blockHeaders = true;
70
+ _cloneable = false;
71
+ _blockNumbers = true;
72
+ _blockIcons = false;
73
+ _headingTag = 'h2';
74
+ _addBetween = true;
75
+ _addBetweenLabel = null;
76
+ _addLabel = null;
77
+ _addIcon = null;
78
+ _addAlignment = null;
79
+ _maxItems = 100;
80
+ _extraClass = null;
81
+ _uuid = randomUUID;
82
+ static make(name) { return configured(new Builder(name)); }
83
+ blocks(blocks) { this._blocks = blocks; this._resolvedBlocks = null; return this; }
84
+ blockPickerColumns(columns = 2) { this._pickerColumns = columns; return this; }
85
+ blockPickerWidth(width) { this._pickerWidth = width; return this; }
86
+ labelBetweenItems(label) { this._labelBetweenItems = label; return this; }
87
+ /** Without a header, an item only shows its content: its actions are then up to the block. */
88
+ blockHeaders(condition = true) { this._blockHeaders = condition; return this; }
89
+ cloneable(condition = true) { this._cloneable = condition; return this; }
90
+ /** Item number after the block label (on by default). */
91
+ blockNumbers(condition = true) { this._blockNumbers = condition; return this; }
92
+ /** Block icon in the item header (off by default). */
93
+ blockIcons(condition = true) { this._blockIcons = condition; return this; }
94
+ /** Tag of the header label (`h2` by default). */
95
+ blockLabelHeadingTag(tag) { this._headingTag = tag; return this; }
96
+ /** "Insert between blocks" picker between two items (on by default). */
97
+ addBetweenAction(condition = true) { this._addBetween = condition; return this; }
98
+ addBetweenActionLabel(label) { this._addBetweenLabel = label; return this; }
99
+ addActionLabel(label) { this._addLabel = label; return this; }
100
+ addActionIcon(name) { this._addIcon = name; return this; }
101
+ addActionAlignment(alignment) { this._addAlignment = alignment; return this; }
102
+ maxItems(count) { this._maxItems = count; return this; }
103
+ /** Class added to the root (e.g. `fi-fo-builder-not-contained`). */
104
+ extraClass(name) { this._extraClass = name; return this; }
105
+ generateUuidUsing(generator) { this._uuid = generator; return this; }
106
+ getBlocks() { return (this._resolvedBlocks ??= typeof this._blocks === 'function' ? this._blocks() : this._blocks); }
107
+ 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(); }
110
+ /** Well-formed items only: simple key, declared type, object data. Anything else the browser sends is ignored. */
111
+ getItems() {
112
+ const state = this.context().state;
113
+ if (!state || typeof state !== 'object' || Array.isArray(state))
114
+ return {};
115
+ const items = {};
116
+ for (const [key, raw] of Object.entries(state).slice(0, this._maxItems)) {
117
+ if (!isItemKey(key) || !raw || typeof raw !== 'object' || !this.getBlock(raw.type))
118
+ continue;
119
+ items[key] = { type: raw.type, data: raw.data && typeof raw.data === 'object' && !Array.isArray(raw.data) ? raw.data : {} };
120
+ }
121
+ return items;
122
+ }
123
+ setItems(items) { writeState(this.livewire, this.getStatePath().split('.'), items); }
124
+ getDefaultState() { return {}; }
125
+ /**
126
+ * Stored value (a `[{ type, data }]` list, possibly as JSON) to form state (a map by key).
127
+ * Each block's fields hydrate their own value.
128
+ */
129
+ hydrateState(value) {
130
+ if (typeof value === 'string') {
131
+ try {
132
+ value = JSON.parse(value);
133
+ }
134
+ catch {
135
+ value = null;
136
+ }
137
+ }
138
+ if (!value || typeof value !== 'object')
139
+ return {};
140
+ const entries = Array.isArray(value) ? value.map((raw) => [this._uuid(), raw]) : Object.entries(value);
141
+ const items = {};
142
+ for (const [key, raw] of entries.slice(0, this._maxItems)) {
143
+ const block = raw && typeof raw === 'object' ? this.getBlock(raw.type) : undefined;
144
+ if (!block || !isItemKey(key))
145
+ continue;
146
+ const source = raw.data && typeof raw.data === 'object' && !Array.isArray(raw.data) ? raw.data : {};
147
+ const data = { ...source };
148
+ for (const component of block.getComponents({ builder: this, itemKey: key, data: source })) {
149
+ if (component instanceof Field)
150
+ data[component.getName()] = component.hydrateState(source[component.getName()] ?? null);
151
+ }
152
+ items[key] = { type: block.name, data };
153
+ }
154
+ return items;
155
+ }
156
+ _storedAsList = true;
157
+ /** `false`: the stored value stays the keyed map of the state (filters) instead of a `[{ type, data }]` list. */
158
+ storedAsList(condition = true) { this._storedAsList = condition; return this; }
159
+ finalizeDehydratedState(state) {
160
+ if (!this._storedAsList || !state || typeof state !== 'object' || Array.isArray(state))
161
+ return state;
162
+ return Object.values(state);
163
+ }
164
+ dehydrateState() { return Object.fromEntries(Object.entries(this.getItems()).map(([key, item]) => [key, { type: item.type, data: {} }])); }
165
+ /** Item schema: key `<builder>.<item>.data`, state at the same path. */
166
+ itemSchema(itemKey, item) {
167
+ 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)
169
+ .components(this.getBlock(item.type).getComponents({ builder: this, itemKey, data: item.data }));
170
+ }
171
+ getChildSchemas() { return Object.entries(this.getItems()).map(([key, item]) => this.itemSchema(key, item)); }
172
+ newItem(block) {
173
+ const defaults = block.getDefaults(this);
174
+ if (defaults)
175
+ return { type: block.name, data: defaults };
176
+ const data = {};
177
+ const item = { type: block.name, data };
178
+ fillSchema(this.itemSchema('new', item), data);
179
+ return item;
180
+ }
181
+ /** A subclass may refuse adding (a rule cap, for instance). */
182
+ canAdd(_block) { return Object.keys(this.getItems()).length < this._maxItems; }
183
+ getActions() {
184
+ const insert = (items, after, key, item) => {
185
+ if (after === null || !Object.hasOwn(items, after))
186
+ return { ...items, [key]: item };
187
+ return Object.fromEntries(Object.entries(items).flatMap(([k, v]) => (k === after ? [[k, v], [key, item]] : [[k, v]])));
188
+ };
189
+ const move = (offset) => ({ arguments: args }) => {
190
+ const items = this.getItems();
191
+ const keys = Object.keys(items);
192
+ const from = keys.indexOf(String(args.item));
193
+ const to = from + offset;
194
+ if (from < 0 || to < 0 || to >= keys.length)
195
+ return;
196
+ [keys[from], keys[to]] = [keys[to], keys[from]];
197
+ this.setItems(Object.fromEntries(keys.map((key) => [key, items[key]])));
198
+ };
199
+ return [
200
+ ...this.getHintActions(),
201
+ Action.make('add').action(({ arguments: args }) => {
202
+ const block = this.getBlockPickerBlocks().find((candidate) => candidate.name === args.block);
203
+ if (!block || !this.canAdd(block))
204
+ return;
205
+ this.setItems(insert(this.getItems(), typeof args.afterItem === 'string' ? args.afterItem : null, this._uuid(), this.newItem(block)));
206
+ }),
207
+ Action.make('addBetween').action(({ arguments: args }) => {
208
+ const block = this.getBlockPickerBlocks().find((candidate) => candidate.name === args.block);
209
+ const items = this.getItems();
210
+ if (!this._addBetween || !block || !this.canAdd(block) || typeof args.afterItem !== 'string' || !Object.hasOwn(items, args.afterItem))
211
+ return;
212
+ this.setItems(insert(items, args.afterItem, this._uuid(), this.newItem(block)));
213
+ }),
214
+ Action.make('delete').action(({ arguments: args }) => { const items = this.getItems(); const key = String(args.item); if (!Object.hasOwn(items, key))
215
+ return; delete items[key]; this.setItems(items); }),
216
+ Action.make('clone').action(({ arguments: args }) => {
217
+ const items = this.getItems();
218
+ const key = String(args.item);
219
+ if (!this._cloneable || !Object.hasOwn(items, key) || !this.canAdd(this.getBlock(items[key].type)))
220
+ return;
221
+ this.setItems(insert(items, key, this._uuid(), this.cloneItem(items[key])));
222
+ }),
223
+ Action.make('moveUp').action(move(-1)),
224
+ Action.make('moveDown').action(move(1)),
225
+ // Drag and drop: the new order is only accepted if it is a permutation of the existing keys.
226
+ Action.make('reorder').action(({ arguments: args }) => {
227
+ const items = this.getItems();
228
+ const keys = Object.keys(items);
229
+ const order = Array.isArray(args.items) ? args.items.map(String) : [];
230
+ if (order.length !== keys.length || new Set(order).size !== keys.length || !order.every((key) => Object.hasOwn(items, key)))
231
+ return;
232
+ this.setItems(Object.fromEntries(order.map((key) => [key, items[key]])));
233
+ }),
234
+ ];
235
+ }
236
+ /** Copy of an item; a subclass renews the keys of nested items. */
237
+ cloneItem(item) { return JSON.parse(JSON.stringify(item)); }
238
+ /** `mountAction(...)` expression for an action of this component. */
239
+ actionExpression(action, args) {
240
+ const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: this.getKey() };
241
+ return `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
242
+ }
243
+ blockPickerHtml(afterItem = null) {
244
+ const key = this.getKey();
245
+ const blocks = this.getBlockPickerBlocks();
246
+ const tr = (name) => t(`filament-forms::components.builder.${name}`);
247
+ const between = afterItem !== null;
248
+ const actionName = between ? 'addBetween' : 'add';
249
+ const alignment = between ? null : this._addAlignment;
250
+ const placement = alignment === 'start' ? 'bottom-start' : alignment === 'end' ? 'bottom-end' : null;
251
+ const widthClass = this._pickerWidth ? `fi-width-${this._pickerWidth}` : null;
252
+ const signature = `${widthClass ?? ''}|${placement ?? ''}|${blocks.length ? 1 : 0}|0`;
253
+ const wireKey = `${key}.${actionName}.${afterItem ?? ''}.block-picker.${PICKER_HASHES[signature] ?? createHash('md5').update(signature).digest('hex')}`;
254
+ const items = blocks.map((block) => {
255
+ const expression = `mountAction('${actionName}', ${js(between ? { block: block.name, afterItem } : { block: block.name })}, { schemaComponent: ${js(key)} })`;
256
+ 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>`;
258
+ });
259
+ 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>` : '';
261
+ const addIcon = !between && this._addIcon ? icon(this._addIcon, { size: 'sm' }) : '';
262
+ const triggerLabel = between
263
+ ? (this._addBetweenLabel ?? tr('actions.add_between.label'))
264
+ : (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>`;
266
+ }
267
+ _labelText = '';
268
+ async toHtml() {
269
+ const id = this.getId();
270
+ const key = this.getKey();
271
+ this._labelText = String((await this.getLabel()) ?? '');
272
+ const livewireKey = this.getLivewireKey() ?? '';
273
+ const items = this.getItems();
274
+ const keys = Object.keys(items);
275
+ const tr = (name, replace = {}) => t(`filament-forms::components.builder.${name}`, replace);
276
+ const descriptionId = (itemKey) => `${id}-item-${createHash('md5').update(itemKey).digest('hex')}`;
277
+ const danger = colorsOf(this.livewire)?.getComponentClasses('icon-button', 'danger') ?? [];
278
+ const rows = [];
279
+ for (const [index, itemKey] of keys.entries()) {
280
+ const item = items[itemKey];
281
+ const block = this.getBlock(item.type);
282
+ let header = '';
283
+ if (this._blockHeaders) {
284
+ const moveButton = (action, label, iconName, direction, disabled) => {
285
+ 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>`;
287
+ };
288
+ 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>`;
290
+ }
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))
293
+ 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
+ else if (index < keys.length - 1 && this._labelBetweenItems)
295
+ 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>`);
296
+ }
297
+ const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tr('reordering.moved'), hasCustomReorderAction: false };
298
+ const descriptions = keys.map((itemKey, index) => {
299
+ const label = this.getBlock(items[itemKey].type).getLabel().trim() || tr('reordering.item');
300
+ 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
+ }).join('');
302
+ 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>`;
304
+ return this.wrap(html, { labelAsDiv: true });
305
+ }
306
+ }