@navette-js/actions 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,8 +15,24 @@ export type ActionCallContext = {
15
15
  livewire: any;
16
16
  record: any;
17
17
  arguments: Record<string, unknown>;
18
- data: Record<string, unknown>;
18
+ data: Record<string, unknown>; /** The action being run. */
19
+ action?: Action; /** Form data of the parent action, for an action of its modal footer (changes are kept). */
20
+ parentData?: Record<string, unknown>; /** For an action of a form component (affix action...): the component's state. */
21
+ state?: unknown; /** For an action of a form component: changes a field of the same form (`set('slug', value)`). */
22
+ set?: (name: string, value: unknown) => void;
23
+ get?: (name: string) => unknown; /** The modal's form, once validated (to save its relationships). */
24
+ schema?: any;
19
25
  };
26
+ /** Message of a bulk notification, from the number of records concerned and the number of selected records. */
27
+ export type BulkMessage = string | ((count: number, total: number) => string);
28
+ /**
29
+ * Thrown by `action.halt()` (the modal stays open) and `action.cancel()` (the modal closes) to stop an action from one of
30
+ * its hooks. The host component catches it.
31
+ */
32
+ export declare class ActionHalt extends Error {
33
+ readonly closesModal: boolean;
34
+ constructor(closesModal: boolean);
35
+ }
20
36
  export declare class Action implements Renderable {
21
37
  /** Default setup for every instance created afterwards by `make()`, e.g. `Action.configureUsing((action) => action.size('sm'))`. */
22
38
  static configureUsing<T>(this: abstract new (...args: any[]) => T, configure: (instance: T) => void): void;
@@ -66,8 +82,6 @@ export declare class Action implements Renderable {
66
82
  /** Icon used when the action is a table row link. */
67
83
  tableIcon(name: string | null): this;
68
84
  private _iconButton;
69
- /** Icon-only row action; the label becomes the tooltip and accessible name. */
70
- iconButton(condition?: boolean): this;
71
85
  /** Icon used when the action is a menu item (ActionGroup). */
72
86
  groupedIcon(name: string | null): this;
73
87
  requiresConfirmation(condition?: boolean): this;
@@ -77,8 +91,7 @@ export declare class Action implements Renderable {
77
91
  modalIcon(name: string | null): this;
78
92
  private _badge;
79
93
  private _badgeColor;
80
- /** Badge (counter) shown on the button. */
81
- badge(badge: string | number | null | (() => string | number | null | Promise<string | number | null>)): this;
94
+ /** Color of the counter set with `badge(value)`. */
82
95
  badgeColor(color: string): this;
83
96
  private _slideOver;
84
97
  private _hasSubmit;
@@ -122,8 +135,48 @@ export declare class Action implements Renderable {
122
135
  schema(components: any[] | ((schema: any) => any), columns?: Record<string, number> | number | null): this;
123
136
  /** Applies the action's form to the host's schema; the function returns either components or the configured schema. */
124
137
  applySchema(schema: any): any | null;
125
- /** Extra buttons in the modal footer, after the submit button. */
138
+ /**
139
+ * Extra buttons in the modal footer, after the submit button. An action with an `action()` function runs on top of this
140
+ * one (with its own modal, if it has one), and gets this action's form data as `parentData`.
141
+ */
126
142
  extraModalFooterActions(actions: Action[]): this;
143
+ getExtraModalFooterActions(): Action[];
144
+ private _closeByClickingAway;
145
+ private _closeByEscaping;
146
+ private _closeButton;
147
+ private _stickyHeader;
148
+ private _stickyFooter;
149
+ private _modalAlignment;
150
+ private _footerAlignment;
151
+ private _modalHidden;
152
+ private _mountUsing;
153
+ /** `false`: clicking outside the modal does not close it. */
154
+ closeModalByClickingAway(condition?: boolean): this;
155
+ /** `false`: the Escape key does not close the modal. */
156
+ closeModalByEscaping(condition?: boolean): this;
157
+ /** `false`: no close button in the modal header. */
158
+ modalCloseButton(condition?: boolean): this;
159
+ /** Keeps the modal header visible while its content scrolls. */
160
+ stickyModalHeader(condition?: boolean): this;
161
+ /** Keeps the modal footer visible while its content scrolls. */
162
+ stickyModalFooter(condition?: boolean): this;
163
+ modalAlignment(alignment: 'start' | 'center' | null): this;
164
+ modalFooterActionsAlignment(alignment: 'start' | 'center' | 'end' | null): this;
165
+ private _steps;
166
+ /** Builds the wizard of `steps()`: registered by `@navette-js/forms`, which owns the `Wizard` component. */
167
+ static stepsRenderer: ((steps: any[], submit: Action, cancel: Action) => any) | null;
168
+ /**
169
+ * Turns the modal form into a wizard: each step is validated before the next one, and the last step submits the action.
170
+ * `steps` are `Step` components of `@navette-js/forms`.
171
+ */
172
+ steps(steps: any[] | null): this;
173
+ isWizard(): boolean;
174
+ /** Runs the action without its modal (e.g. no confirmation for some records). */
175
+ modalHidden(condition?: Lazy<boolean, ActionCallContext>): this;
176
+ isModalHidden(ctx: ActionCallContext): Promise<boolean>;
177
+ /** Runs when the action is mounted, after the form is filled: `ctx.data` is the form state, which you may change. */
178
+ mountUsing(callback: ((ctx: ActionCallContext) => unknown) | null): this;
179
+ runMountUsing(ctx: ActionCallContext): Promise<void>;
127
180
  /** Initial form state (e.g. from the record). */
128
181
  fillForm(resolver: (ctx: ActionCallContext) => Record<string, unknown> | Promise<Record<string, unknown>>): this;
129
182
  /** Modal width (`xs` … `7xl`). Defaults to `md` for a confirmation and `4xl` for a form. */
@@ -140,6 +193,56 @@ export declare class Action implements Renderable {
140
193
  before(fn: (ctx: ActionCallContext) => unknown | Promise<unknown>): this;
141
194
  /** Runs after the handler succeeds (logging, extra notification…). */
142
195
  after(fn: (ctx: ActionCallContext) => unknown | Promise<unknown>): this;
196
+ private _individualAuthorization;
197
+ private _chunkSize;
198
+ private _deselectAfterCompletion;
199
+ private _successNotificationTitle;
200
+ private _failureNotificationTitle;
201
+ private _missingAuthorizationMessage;
202
+ private _missingProcessingMessage;
203
+ /**
204
+ * Bulk action: each selected record must pass this policy ability (or function), otherwise it is skipped and counted in the
205
+ * failure notification. Used by `processSelectedRecords()` of the table's page.
206
+ */
207
+ authorizeIndividualRecords(ability: string | ((record: any, ctx: ActionCallContext) => boolean | Promise<boolean>) | null): this;
208
+ getIndividualRecordsAuthorization(): string | ((record: any, ctx: ActionCallContext) => boolean | Promise<boolean>) | null;
209
+ /** Number of selected records loaded at a time by `processSelectedRecords()` (100 by default). */
210
+ chunkSelectedRecords(size: number): this;
211
+ getSelectedRecordsChunkSize(): number;
212
+ /** Clears the table selection once the bulk action has run. */
213
+ deselectRecordsAfterCompletion(condition?: boolean): this;
214
+ shouldDeselectRecordsAfterCompletion(): boolean;
215
+ /** Notification sent when every selected record was processed. */
216
+ successNotificationTitle(title: string | null): this;
217
+ getSuccessNotificationTitle(): string | null;
218
+ /** Notification title when some records failed: receives the number of processed records and the number selected. */
219
+ failureNotificationTitle(title: BulkMessage | null): this;
220
+ getFailureNotificationTitle(success: number, total: number): string | null;
221
+ /** Body line for records skipped by `authorizeIndividualRecords()`: receives their number and the number selected. */
222
+ missingBulkAuthorizationFailureNotificationMessage(message: BulkMessage | null): this;
223
+ getMissingBulkAuthorizationFailureNotificationMessage(count: number, total: number): string | null;
224
+ /** Body line for records whose processing failed: receives their number and the number selected. */
225
+ missingBulkProcessingFailureNotificationMessage(message: BulkMessage | null): this;
226
+ getMissingBulkProcessingFailureNotificationMessage(count: number, total: number): string | null;
227
+ private _successNotification;
228
+ private _successRedirectUrl;
229
+ private _preserveData;
230
+ /**
231
+ * Customizes the success notification of a prebuilt action: the function receives the notification and returns it (changed)
232
+ * or `null` to send nothing.
233
+ */
234
+ successNotification(customize: ((notification: any, ctx: ActionCallContext) => any) | null): this;
235
+ getSuccessNotificationCustomizer(): ((notification: any, ctx: ActionCallContext) => any) | null;
236
+ /** Where a prebuilt action redirects after success: a URL, or a function of the context (record, replica...). */
237
+ successRedirectUrl(url: string | ((ctx: ActionCallContext & Record<string, unknown>) => string | null | Promise<string | null>) | null): this;
238
+ getSuccessRedirectUrl(ctx: ActionCallContext & Record<string, unknown>): Promise<string | null>;
239
+ /** "Create & create another": fields kept in the new form (names, or a function returning the data to keep). */
240
+ preserveFormDataWhenCreatingAnother(fields: string[] | ((data: Record<string, unknown>) => Record<string, unknown>) | null): this;
241
+ getDataPreservedForAnother(data: Record<string, unknown>): Record<string, unknown>;
242
+ /** Stops the action from one of its hooks; the modal stays open. */
243
+ halt(): never;
244
+ /** Stops the action from one of its hooks and closes the modal. */
245
+ cancel(): never;
143
246
  call(ctx: ActionCallContext): Promise<unknown>;
144
247
  /** Menu item of a table bulk action. The click goes through the table's Alpine `mountAction()`, which sends the selection. */
145
248
  toGroupedHtml(ctx: ComponentContext, context: Record<string, unknown>): Promise<string>;
@@ -151,7 +254,7 @@ export declare class Action implements Renderable {
151
254
  /** Small link (or icon button) for a table row action. */
152
255
  toLinkHtml(ctx: ComponentContext, context: Record<string, unknown>, wireKey: string): Promise<string>;
153
256
  /** Modal of a mounted action; `content` is the rendered form, if any. */
154
- toModalHtml(ctx: ActionCallContext, index: number, content?: string | null): Promise<string>;
257
+ toModalHtml(ctx: ActionCallContext, index: number, content?: string | null, mountContext?: Record<string, unknown>): Promise<string>;
155
258
  alpineClickHandler(handler: string | null): this;
156
259
  private _livewireTarget;
157
260
  private _iconAfter;
@@ -169,15 +272,80 @@ export declare class Action implements Renderable {
169
272
  /** Shown but unusable; the server also refuses to mount it. */
170
273
  disabled(condition?: Lazy<boolean, ComponentContext>): this;
171
274
  isDisabled(ctx: ComponentContext): Promise<boolean>;
275
+ private _view;
276
+ private _labeledFrom;
277
+ private _outlined;
278
+ /** Trigger style, instead of the one chosen by the location of the action. */
279
+ button(): this;
280
+ link(): this;
281
+ /** Icon only: the label becomes the tooltip and accessible name. */
282
+ iconButton(condition?: boolean): this;
283
+ badge(badge?: string | number | null | (() => string | number | null | Promise<string | number | null>)): this;
284
+ /** Icon button on small screens, labeled button from this breakpoint up (`sm`, `md`, `lg`, `xl`, `2xl`). */
285
+ labeledFrom(breakpoint: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | null): this;
286
+ /** Outlined button. */
287
+ outlined(condition?: boolean): this;
288
+ getView(): 'button' | 'link' | 'icon-button' | 'badge' | null;
289
+ /** Markup of a link, icon button or badge trigger; `attributes` carries the click (or href) behavior. */
290
+ private styledTriggerHtml;
172
291
  /** Renders the action as a button (or as a link when it has a URL). */
173
292
  toHtml(ctx: ComponentContext, explicitMountContext?: Record<string, unknown> | null): Promise<string>;
174
293
  }
175
294
  /** Group of actions collapsed into a dropdown behind a "⋮" icon button. */
295
+ /**
296
+ * Actions in a dropdown menu ("⋮" icon button by default). A nested group with `dropdown(false)` is shown as a section of
297
+ * the menu, separated by a divider. `buttonGroup()` shows the actions side by side instead.
298
+ */
176
299
  export declare class ActionGroup {
177
300
  private actions;
178
- constructor(actions: Action[]);
179
- static make(actions: Action[]): ActionGroup;
301
+ private _label;
302
+ private _icon;
303
+ private _color;
304
+ private _size;
305
+ private _tooltip;
306
+ private _view;
307
+ private _dropdown;
308
+ private _buttonGroup;
309
+ private _placement;
310
+ private _width;
311
+ private _offset;
312
+ private _maxHeight;
313
+ private _hidden;
314
+ constructor(actions: (Action | ActionGroup)[]);
315
+ static make(actions: (Action | ActionGroup)[]): ActionGroup;
316
+ /** Every action of the group, nested groups included. */
180
317
  getActions(): Action[];
181
- /** `keyOf` returns each action's `wire:key` (it depends on the record). */
182
- toHtml(ctx: ComponentContext, context: Record<string, unknown>, keyOf: (action: Action) => string): Promise<string>;
318
+ getName(): string;
319
+ /** A group is never mounted: its actions are. */
320
+ isMountable(): boolean;
321
+ label(label: string | null): this;
322
+ icon(icon: string): this;
323
+ color(color: string): this;
324
+ size(size: string): this;
325
+ tooltip(tooltip: string | null): this;
326
+ /** Labeled button trigger. */
327
+ button(): this;
328
+ link(): this;
329
+ iconButton(): this;
330
+ /** `false`: inside another group, the actions form a section of its menu (with a divider). */
331
+ dropdown(condition?: boolean): this;
332
+ hasDropdown(): boolean;
333
+ /** The actions side by side, as a group of buttons. */
334
+ buttonGroup(condition?: boolean): this;
335
+ /** Menu placement: `bottom-start`, `bottom-end`, `top-start`... (`bottom-end` in tables). */
336
+ dropdownPlacement(placement: string | null): this;
337
+ /** Menu width: `xs` to `7xl`. */
338
+ dropdownWidth(width: string | null): this;
339
+ /** Distance between the trigger and the menu, in pixels (8 by default). */
340
+ dropdownOffset(offset: number | null): this;
341
+ /** Maximum height of the menu, as a CSS length; it scrolls beyond. */
342
+ maxHeight(height: string | null): this;
343
+ hidden(condition?: Lazy<boolean, ComponentContext>): this;
344
+ visible(condition?: Lazy<boolean, ComponentContext>): this;
345
+ isVisible(ctx: ComponentContext): Promise<boolean>;
346
+ private triggerHtml;
347
+ /** Menu items of the visible actions, one list per section (nested groups without dropdown). */
348
+ private itemLists;
349
+ /** `keyOf` returns each action's `wire:key` (it depends on the record in a table). */
350
+ toHtml(ctx: ComponentContext, explicitContext?: Record<string, unknown> | null, keyOf?: (action: Action) => string): Promise<string>;
183
351
  }
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // @navette-js/actions — actions: buttons, links, menu items and modal actions.
2
- import { Attributes, e, evaluate, icon, js, loadingIndicator, renderHook, views, configured, configureUsing } from '@navette-js/support';
2
+ import { Attributes, cls, e, evaluate, icon, js, loadingIndicator, renderHook, views, configured, configureUsing } from '@navette-js/support';
3
3
  import { t } from '@navette-js/support/i18n';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  views.namespace('filament-actions', fileURLToPath(new URL('../views', import.meta.url)));
@@ -14,6 +14,14 @@ export const ActionsRenderHook = {
14
14
  };
15
15
  let colorManager = null;
16
16
  export const useColorManager = (manager) => { colorManager = manager; };
17
+ /**
18
+ * Thrown by `action.halt()` (the modal stays open) and `action.cancel()` (the modal closes) to stop an action from one of
19
+ * its hooks. The host component catches it.
20
+ */
21
+ export class ActionHalt extends Error {
22
+ closesModal;
23
+ constructor(closesModal) { super(closesModal ? 'Action cancelled.' : 'Action halted.'); this.closesModal = closesModal; }
24
+ }
17
25
  export class Action {
18
26
  /** Default setup for every instance created afterwards by `make()`, e.g. `Action.configureUsing((action) => action.size('sm'))`. */
19
27
  static configureUsing(configure) { configureUsing(this, configure); }
@@ -88,8 +96,6 @@ export class Action {
88
96
  /** Icon used when the action is a table row link. */
89
97
  tableIcon(name) { this._tableIcon = name; return this; }
90
98
  _iconButton = false;
91
- /** Icon-only row action; the label becomes the tooltip and accessible name. */
92
- iconButton(condition = true) { this._iconButton = condition; return this; }
93
99
  /** Icon used when the action is a menu item (ActionGroup). */
94
100
  groupedIcon(name) { this._groupedIcon = name; return this; }
95
101
  requiresConfirmation(condition = true) { this._requiresConfirmation = condition; return this; }
@@ -99,8 +105,7 @@ export class Action {
99
105
  modalIcon(name) { this._modalIcon = name; return this; }
100
106
  _badge = null;
101
107
  _badgeColor = 'primary';
102
- /** Badge (counter) shown on the button. */
103
- badge(badge) { this._badge = badge; return this; }
108
+ /** Color of the counter set with `badge(value)`. */
104
109
  badgeColor(color) { this._badgeColor = color; return this; }
105
110
  _slideOver = false;
106
111
  _hasSubmit = true;
@@ -164,8 +169,60 @@ export class Action {
164
169
  const result = this._schema(schema);
165
170
  return Array.isArray(result) ? schema.columns(this._schemaColumns).components(result) : result;
166
171
  }
167
- /** Extra buttons in the modal footer, after the submit button. */
172
+ /**
173
+ * Extra buttons in the modal footer, after the submit button. An action with an `action()` function runs on top of this
174
+ * one (with its own modal, if it has one), and gets this action's form data as `parentData`.
175
+ */
168
176
  extraModalFooterActions(actions) { this._extraModalFooterActions = actions; return this; }
177
+ getExtraModalFooterActions() { return this._extraModalFooterActions; }
178
+ // --- modal behavior
179
+ _closeByClickingAway = true;
180
+ _closeByEscaping = true;
181
+ _closeButton = true;
182
+ _stickyHeader = false;
183
+ _stickyFooter = false;
184
+ _modalAlignment = null;
185
+ _footerAlignment = null;
186
+ _modalHidden = false;
187
+ _mountUsing = null;
188
+ /** `false`: clicking outside the modal does not close it. */
189
+ closeModalByClickingAway(condition = true) { this._closeByClickingAway = condition; return this; }
190
+ /** `false`: the Escape key does not close the modal. */
191
+ closeModalByEscaping(condition = true) { this._closeByEscaping = condition; return this; }
192
+ /** `false`: no close button in the modal header. */
193
+ modalCloseButton(condition = true) { this._closeButton = condition; return this; }
194
+ /** Keeps the modal header visible while its content scrolls. */
195
+ stickyModalHeader(condition = true) { this._stickyHeader = condition; return this; }
196
+ /** Keeps the modal footer visible while its content scrolls. */
197
+ stickyModalFooter(condition = true) { this._stickyFooter = condition; return this; }
198
+ modalAlignment(alignment) { this._modalAlignment = alignment; return this; }
199
+ modalFooterActionsAlignment(alignment) { this._footerAlignment = alignment; return this; }
200
+ _steps = null;
201
+ /** Builds the wizard of `steps()`: registered by `@navette-js/forms`, which owns the `Wizard` component. */
202
+ static stepsRenderer = null;
203
+ /**
204
+ * Turns the modal form into a wizard: each step is validated before the next one, and the last step submits the action.
205
+ * `steps` are `Step` components of `@navette-js/forms`.
206
+ */
207
+ steps(steps) {
208
+ this._steps = steps;
209
+ this._schema = steps === null ? null : (schema) => {
210
+ if (!Action.stepsRenderer)
211
+ throw new Error('Action.steps() needs @navette-js/forms to be loaded.');
212
+ const submitLabel = this._modalSubmitActionLabel ?? t('filament-actions::modal.actions.submit.label');
213
+ const submit = Action.make('submit').label(submitLabel).color(this._color === 'gray' ? 'primary' : this._color).submit('callMountedAction');
214
+ const cancel = Action.make('cancel').label(this._cancelLabel ?? t('filament-actions::modal.actions.cancel.label')).color('gray').alpineClickHandler('close()');
215
+ return schema.components([Action.stepsRenderer(steps, submit, cancel)]);
216
+ };
217
+ return this;
218
+ }
219
+ isWizard() { return this._steps !== null; }
220
+ /** Runs the action without its modal (e.g. no confirmation for some records). */
221
+ modalHidden(condition = true) { this._modalHidden = condition; return this; }
222
+ async isModalHidden(ctx) { return Boolean(await evaluate(this._modalHidden, ctx)); }
223
+ /** Runs when the action is mounted, after the form is filled: `ctx.data` is the form state, which you may change. */
224
+ mountUsing(callback) { this._mountUsing = callback; return this; }
225
+ async runMountUsing(ctx) { await this._mountUsing?.(ctx); }
169
226
  /** Initial form state (e.g. from the record). */
170
227
  fillForm(resolver) { this._fillForm = resolver; return this; }
171
228
  /** Modal width (`xs` … `7xl`). Defaults to `md` for a confirmation and `4xl` for a form. */
@@ -182,9 +239,68 @@ export class Action {
182
239
  before(fn) { this._before = fn; return this; }
183
240
  /** Runs after the handler succeeds (logging, extra notification…). */
184
241
  after(fn) { this._after = fn; return this; }
242
+ // --- bulk actions
243
+ _individualAuthorization = null;
244
+ _chunkSize = 100;
245
+ _deselectAfterCompletion = false;
246
+ _successNotificationTitle = null;
247
+ _failureNotificationTitle = null;
248
+ _missingAuthorizationMessage = null;
249
+ _missingProcessingMessage = null;
250
+ /**
251
+ * Bulk action: each selected record must pass this policy ability (or function), otherwise it is skipped and counted in the
252
+ * failure notification. Used by `processSelectedRecords()` of the table's page.
253
+ */
254
+ authorizeIndividualRecords(ability) { this._individualAuthorization = ability; return this; }
255
+ getIndividualRecordsAuthorization() { return this._individualAuthorization; }
256
+ /** Number of selected records loaded at a time by `processSelectedRecords()` (100 by default). */
257
+ chunkSelectedRecords(size) { this._chunkSize = Math.max(1, Math.floor(size)); return this; }
258
+ getSelectedRecordsChunkSize() { return this._chunkSize; }
259
+ /** Clears the table selection once the bulk action has run. */
260
+ deselectRecordsAfterCompletion(condition = true) { this._deselectAfterCompletion = condition; return this; }
261
+ shouldDeselectRecordsAfterCompletion() { return this._deselectAfterCompletion; }
262
+ /** Notification sent when every selected record was processed. */
263
+ successNotificationTitle(title) { this._successNotificationTitle = title; return this; }
264
+ getSuccessNotificationTitle() { return this._successNotificationTitle; }
265
+ /** Notification title when some records failed: receives the number of processed records and the number selected. */
266
+ failureNotificationTitle(title) { this._failureNotificationTitle = title; return this; }
267
+ getFailureNotificationTitle(success, total) { const title = this._failureNotificationTitle; return typeof title === 'function' ? title(success, total) : title; }
268
+ /** Body line for records skipped by `authorizeIndividualRecords()`: receives their number and the number selected. */
269
+ missingBulkAuthorizationFailureNotificationMessage(message) { this._missingAuthorizationMessage = message; return this; }
270
+ getMissingBulkAuthorizationFailureNotificationMessage(count, total) { const message = this._missingAuthorizationMessage; return typeof message === 'function' ? message(count, total) : message; }
271
+ /** Body line for records whose processing failed: receives their number and the number selected. */
272
+ missingBulkProcessingFailureNotificationMessage(message) { this._missingProcessingMessage = message; return this; }
273
+ getMissingBulkProcessingFailureNotificationMessage(count, total) { const message = this._missingProcessingMessage; return typeof message === 'function' ? message(count, total) : message; }
274
+ // --- outcome of single actions
275
+ _successNotification = null;
276
+ _successRedirectUrl = null;
277
+ _preserveData = null;
278
+ /**
279
+ * Customizes the success notification of a prebuilt action: the function receives the notification and returns it (changed)
280
+ * or `null` to send nothing.
281
+ */
282
+ successNotification(customize) { this._successNotification = customize; return this; }
283
+ getSuccessNotificationCustomizer() { return this._successNotification; }
284
+ /** Where a prebuilt action redirects after success: a URL, or a function of the context (record, replica...). */
285
+ successRedirectUrl(url) { this._successRedirectUrl = url; return this; }
286
+ async getSuccessRedirectUrl(ctx) { return typeof this._successRedirectUrl === 'function' ? this._successRedirectUrl(ctx) : this._successRedirectUrl; }
287
+ /** "Create & create another": fields kept in the new form (names, or a function returning the data to keep). */
288
+ preserveFormDataWhenCreatingAnother(fields) { this._preserveData = fields; return this; }
289
+ getDataPreservedForAnother(data) {
290
+ if (!this._preserveData)
291
+ return {};
292
+ if (typeof this._preserveData === 'function')
293
+ return this._preserveData(data) ?? {};
294
+ return Object.fromEntries(this._preserveData.filter((field) => Object.hasOwn(data, field)).map((field) => [field, data[field]]));
295
+ }
296
+ /** Stops the action from one of its hooks; the modal stays open. */
297
+ halt() { throw new ActionHalt(false); }
298
+ /** Stops the action from one of its hooks and closes the modal. */
299
+ cancel() { throw new ActionHalt(true); }
185
300
  async call(ctx) {
186
301
  if (!this._handler)
187
302
  return null;
303
+ ctx = { ...ctx, action: this };
188
304
  if (this._mutateData)
189
305
  ctx = { ...ctx, data: await this._mutateData(ctx.data ?? {}, ctx) };
190
306
  await this._before?.(ctx);
@@ -241,6 +357,9 @@ export class Action {
241
357
  async toLinkHtml(ctx, context, wireKey) {
242
358
  if (!colorManager)
243
359
  throw new Error('No color manager: call useColorManager() first.');
360
+ // `button()`, `badge()`, `outlined()` or `labeledFrom()` in a table row: rendered like a header action.
361
+ if (this._view === 'button' || this._view === 'badge' || this._outlined || this._labeledFrom)
362
+ return this.toHtml(ctx, context);
244
363
  const color = this._lazyColor ? await this._lazyColor(ctx) : this._color;
245
364
  const iconName = this._lazyIcon ? await this._lazyIcon(ctx) : (this._tableIcon ?? this._icon);
246
365
  const raw = `mountAction(${js(this.name)}, {}, ${js(context)})`;
@@ -262,7 +381,7 @@ export class Action {
262
381
  return `<button ${keys}${tooltip}type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${classes}" wire:click="${e(raw)}" wire:key="${e(wireKey)}">${iconHtml}${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': raw }), 'sm')}<span class="fi-link-label">${e(label)}</span></button>`;
263
382
  }
264
383
  /** Modal of a mounted action; `content` is the rendered form, if any. */
265
- async toModalHtml(ctx, index, content = null) {
384
+ async toModalHtml(ctx, index, content = null, mountContext = {}) {
266
385
  if (!colorManager)
267
386
  throw new Error('No color manager: call useColorManager() first.');
268
387
  const schemaCtx = { livewire: ctx.livewire, component: null, operation: null, record: ctx.record, get: () => undefined, state: undefined };
@@ -280,15 +399,21 @@ export class Action {
280
399
  + hook(ActionsRenderHook.MODAL_CUSTOM_CONTENT_FOOTER_BEFORE) + hook(ActionsRenderHook.MODAL_CUSTOM_CONTENT_FOOTER_AFTER);
281
400
  if (content !== null || hooked !== '')
282
401
  content = hooked;
402
+ // Footer actions with a handler are mounted on top of this one, with its context.
403
+ const footerActions = await Promise.all(this._extraModalFooterActions.map((a) => (a.isMountable() ? a.toHtml(schemaCtx, { ...mountContext, parentAction: index }) : a.toHtml(schemaCtx))));
283
404
  return views.render('filament-actions::modal', {
284
- slideOver: this._slideOver, content, confirmation, width, alignment: confirmation || ['xs', 'sm'].includes(width) ? 'center' : 'start',
405
+ // A wizard has its own buttons (back, next, submit): the modal has no footer.
406
+ hasFooter: !this.isWizard(),
407
+ closeByClickingAway: this._closeByClickingAway, closeByEscaping: this._closeByEscaping, closeButton: this._closeButton, stickyHeader: this._stickyHeader, stickyFooter: this._stickyFooter,
408
+ footerAlignment: this._footerAlignment ?? (confirmation ? 'center' : 'start'),
409
+ slideOver: this._slideOver, content, confirmation, width, alignment: this._modalAlignment ?? (confirmation || ['xs', 'sm'].includes(width) ? 'center' : 'start'),
285
410
  livewireId: ctx.livewire.id, id: `fi-${ctx.livewire.id}-action-${index}`, index, name: this.name,
286
411
  heading: (await evaluate(this._modalHeading, ctx)) ?? label,
287
412
  description: (await evaluate(this._modalDescription, ctx)) ?? (this._requiresConfirmation ? t('filament-actions::modal.confirmation') : null),
288
413
  iconName: this._modalIcon ?? (this._requiresConfirmation ? 'o-exclamation-triangle' : null), color,
289
414
  cancel: await Action.make('cancel').label(this._cancelLabel ?? t('filament-actions::modal.actions.cancel.label')).color('gray').alpineClickHandler('close()').toHtml(schemaCtx),
290
415
  // The submit button of a gray action uses the primary color.
291
- submit: !this._hasSubmit ? '' : (await Action.make('submit').label(submitLabel).color(color === 'gray' ? 'primary' : color).submit('callMountedAction').toHtml(schemaCtx)) + (await Promise.all(this._extraModalFooterActions.map((a) => a.toHtml(schemaCtx)))).join(''),
416
+ submit: !this._hasSubmit ? '' : (await Action.make('submit').label(submitLabel).color(color === 'gray' ? 'primary' : color).submit('callMountedAction').toHtml(schemaCtx)) + footerActions.join(''),
292
417
  });
293
418
  }
294
419
  alpineClickHandler(handler) { this._alpineClick = handler; return this; }
@@ -308,11 +433,72 @@ export class Action {
308
433
  /** Shown but unusable; the server also refuses to mount it. */
309
434
  disabled(condition = true) { this._disabled = condition; return this; }
310
435
  async isDisabled(ctx) { return evaluate(this._disabled, ctx); }
436
+ _view = null;
437
+ _labeledFrom = null;
438
+ _outlined = false;
439
+ /** Trigger style, instead of the one chosen by the location of the action. */
440
+ button() { this._view = 'button'; return this; }
441
+ link() { this._view = 'link'; return this; }
442
+ /** Icon only: the label becomes the tooltip and accessible name. */
443
+ iconButton(condition = true) { this._iconButton = condition; this._view = condition ? 'icon-button' : null; return this; }
444
+ badge(badge) {
445
+ // `badge()` without argument: badge trigger style; with a value: a counter on the button.
446
+ if (arguments.length === 0) {
447
+ this._view = 'badge';
448
+ return this;
449
+ }
450
+ this._badge = badge ?? null;
451
+ return this;
452
+ }
453
+ /** Icon button on small screens, labeled button from this breakpoint up (`sm`, `md`, `lg`, `xl`, `2xl`). */
454
+ labeledFrom(breakpoint) { this._labeledFrom = breakpoint !== null && ['sm', 'md', 'lg', 'xl', '2xl'].includes(breakpoint) ? breakpoint : null; return this; }
455
+ /** Outlined button. */
456
+ outlined(condition = true) { this._outlined = condition; return this; }
457
+ getView() { return this._view; }
458
+ /** Markup of a link, icon button or badge trigger; `attributes` carries the click (or href) behavior. */
459
+ async styledTriggerHtml(ctx, view, attributes, tag, wireTarget) {
460
+ const label = (await evaluate(this._label, ctx)) ?? this.getDefaultLabel();
461
+ const iconName = this._lazyIcon ? await this._lazyIcon(ctx) : this._icon;
462
+ const color = this._lazyColor ? await this._lazyColor(ctx) : this._color;
463
+ const colors = (component) => (color !== 'gray' ? colorManager.getComponentClasses(component, color) : []);
464
+ const loader = tag === 'button' && wireTarget ? loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': wireTarget })) : '';
465
+ const glyph = iconName ? icon(iconName, { attributes: tag === 'button' && wireTarget ? new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': wireTarget }) : undefined }) : '';
466
+ const tooltip = await this.tooltipAttribute(ctx);
467
+ if (view === 'icon-button')
468
+ return `<${tag} title="${e(label)}" aria-label="${e(label)}" ${tooltip}${attributes} class="${cls(['fi-ac-icon-btn-action', 'fi-icon-btn', `fi-size-${this._size}`, ...colors('icon-button')])}">${glyph}${loader}</${tag}>`;
469
+ if (view === 'badge')
470
+ return `<${tag} ${tooltip}${attributes} class="${cls(['fi-ac-badge-action', 'fi-badge', `fi-size-${this._size}`, ...colors('badge')])}">${glyph}${loader}<span class="fi-badge-label-ctn"><span class="fi-badge-label">${e(label)}</span></span></${tag}>`;
471
+ return `<${tag} ${tooltip}${attributes} class="${cls(['fi-ac-link-action', 'fi-link', `fi-size-${this._size}`, ...colors('link')])}">${glyph}${loader}<span class="fi-link-label">${e(label)}</span></${tag}>`;
472
+ }
311
473
  /** Renders the action as a button (or as a link when it has a URL). */
312
474
  async toHtml(ctx, explicitMountContext = null) {
313
475
  if (!colorManager)
314
476
  throw new Error('No color manager: call useColorManager() first.');
315
477
  const linkUrl = this._lazyUrl ? this._lazyUrl(ctx) : this._url;
478
+ // Explicit trigger styles (link, icon button, badge), and the icon button / labeled button pair of `labeledFrom()`.
479
+ if (this._submit === null && (this._view === 'link' || this._view === 'icon-button' || this._view === 'badge' || this._labeledFrom)) {
480
+ const safeUrl = linkUrl !== null && /^(\/(?!\/)|https?:\/\/)/.test(linkUrl) ? linkUrl : null;
481
+ const mountContext = explicitMountContext ?? ctx.livewire?.getActionMountContext?.() ?? {};
482
+ const mountExpression = this._handler ? (Object.keys(mountContext).length ? `mountAction(${js(this.name)}, {}, ${js(mountContext)})` : `mountAction(${js(this.name)})`) : null;
483
+ const click = safeUrl ? `href="${e(safeUrl)}" ${this._newTab ? 'target="_blank" ' : 'wire:navigate '}` : `type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" ${this._alpineClick ? `x-on:click="${e(this._alpineClick)}" ` : ''}${(mountExpression ?? this._action) ? `wire:click="${e(mountExpression ?? this._action)}" ` : ''}`;
484
+ const tag = safeUrl ? 'a' : 'button';
485
+ const target = this._livewireTarget ?? mountExpression ?? this._action;
486
+ if (this._labeledFrom) {
487
+ const iconButton = (await this.styledTriggerHtml(ctx, 'icon-button', click.trim(), tag, target)).replace('class="fi-ac-icon-btn-action', `class="${this._labeledFrom}:fi-hidden fi-ac-icon-btn-action`);
488
+ const previousView = this._view, previousLabeled = this._labeledFrom;
489
+ this._view = null;
490
+ this._labeledFrom = null;
491
+ try {
492
+ const labeled = (await this.toHtml(ctx, explicitMountContext)).replace(/class="([^"]*\bfi-btn\b[^"]*)"/, (_match, classes) => `class="${classes} fi-hidden ${previousLabeled}:fi-inline-grid"`);
493
+ return iconButton + labeled;
494
+ }
495
+ finally {
496
+ this._view = previousView;
497
+ this._labeledFrom = previousLabeled;
498
+ }
499
+ }
500
+ return this.styledTriggerHtml(ctx, this._view, click.trim(), tag, target);
501
+ }
316
502
  if (linkUrl !== null && /^(\/(?!\/)|https?:\/\/)/.test(linkUrl)) {
317
503
  const link = new Attributes().class(['fi-ac-btn-action', 'fi-btn', `fi-size-${this._size}`, ...(this._color !== 'gray' ? colorManager.getComponentClasses('button', this._color) : [])]);
318
504
  const linkIcon = this._icon ? icon(this._icon) : '';
@@ -333,7 +519,7 @@ export class Action {
333
519
  'wire:target': this._livewireTarget ?? this._submit,
334
520
  'x-bind:disabled': isSubmit ? 'isProcessing' : null,
335
521
  })
336
- .class(['fi-ac-btn-action', 'fi-btn', `fi-size-${this._size}`, ...colorManager.getComponentClasses('button', this._color)])
522
+ .class(['fi-ac-btn-action', 'fi-btn', `fi-size-${this._size}`, ...(this._outlined ? ['fi-outlined', ...colorManager.getComponentClasses('button-outlined', this._color)] : colorManager.getComponentClasses('button', this._color))])
337
523
  .when(this._action, (a) => a.set('wire:click', this._action))
338
524
  .when(mountExpression, (a) => a.set('wire:click', mountExpression))
339
525
  .when(this._alpineClick, (a) => a.set('x-on:click', this._alpineClick));
@@ -349,22 +535,118 @@ export class Action {
349
535
  }
350
536
  }
351
537
  /** Group of actions collapsed into a dropdown behind a "⋮" icon button. */
538
+ /**
539
+ * Actions in a dropdown menu ("⋮" icon button by default). A nested group with `dropdown(false)` is shown as a section of
540
+ * the menu, separated by a divider. `buttonGroup()` shows the actions side by side instead.
541
+ */
352
542
  export class ActionGroup {
353
543
  actions;
544
+ _label = null;
545
+ _icon = 'm-ellipsis-vertical';
546
+ _color = 'primary';
547
+ _size = 'md';
548
+ _tooltip = null;
549
+ _view = 'icon-button';
550
+ _dropdown = true;
551
+ _buttonGroup = false;
552
+ _placement = null;
553
+ _width = null;
554
+ _offset = null;
555
+ _maxHeight = null;
556
+ _hidden = false;
354
557
  constructor(actions) { this.actions = actions; }
355
558
  static make(actions) { return configured(new ActionGroup(actions)); }
356
- getActions() { return this.actions; }
357
- /** `keyOf` returns each action's `wire:key` (it depends on the record). */
358
- async toHtml(ctx, context, keyOf) {
359
- if (!colorManager)
360
- throw new Error('No color manager: call useColorManager() first.');
361
- const items = [];
559
+ /** Every action of the group, nested groups included. */
560
+ getActions() { return this.actions.flatMap((action) => (action instanceof ActionGroup ? action.getActions() : [action])); }
561
+ getName() { return 'group'; }
562
+ /** A group is never mounted: its actions are. */
563
+ isMountable() { return false; }
564
+ label(label) { this._label = label; return this; }
565
+ icon(icon) { this._icon = icon; return this; }
566
+ color(color) { this._color = color; return this; }
567
+ size(size) { this._size = size; return this; }
568
+ tooltip(tooltip) { this._tooltip = tooltip; return this; }
569
+ /** Labeled button trigger. */
570
+ button() { this._view = 'button'; return this; }
571
+ link() { this._view = 'link'; return this; }
572
+ iconButton() { this._view = 'icon-button'; return this; }
573
+ /** `false`: inside another group, the actions form a section of its menu (with a divider). */
574
+ dropdown(condition = true) { this._dropdown = condition; return this; }
575
+ hasDropdown() { return this._dropdown; }
576
+ /** The actions side by side, as a group of buttons. */
577
+ buttonGroup(condition = true) { this._buttonGroup = condition; return this; }
578
+ /** Menu placement: `bottom-start`, `bottom-end`, `top-start`... (`bottom-end` in tables). */
579
+ dropdownPlacement(placement) { this._placement = placement !== null && /^(top|bottom|left|right)(-(start|end))?$/.test(placement) ? placement : null; return this; }
580
+ /** Menu width: `xs` to `7xl`. */
581
+ dropdownWidth(width) { this._width = width !== null && /^(xs|sm|md|lg|xl|[2-7]xl)$/.test(width) ? width : null; return this; }
582
+ /** Distance between the trigger and the menu, in pixels (8 by default). */
583
+ dropdownOffset(offset) { this._offset = offset !== null && Number.isFinite(offset) ? Math.max(0, Math.round(offset)) : null; return this; }
584
+ /** Maximum height of the menu, as a CSS length; it scrolls beyond. */
585
+ maxHeight(height) { this._maxHeight = height !== null && /^[0-9.]+(px|rem|em|vh|%)$/.test(height) ? height : null; return this; }
586
+ hidden(condition = true) { this._hidden = condition; return this; }
587
+ visible(condition = true) { return this.hidden(typeof condition === 'function' ? async (ctx) => !(await condition(ctx)) : !condition); }
588
+ async isVisible(ctx) {
589
+ if (await evaluate(this._hidden, ctx))
590
+ return false;
362
591
  for (const action of this.actions)
363
592
  if (await action.isVisible(ctx))
364
- items.push(await action.toRecordGroupedHtml(ctx, context, keyOf(action)));
365
- if (!items.length)
593
+ return true;
594
+ return false;
595
+ }
596
+ triggerHtml() {
597
+ if (!colorManager)
598
+ throw new Error('No color manager: call useColorManager() first.');
599
+ const label = this._label ?? t('filament-actions::group.trigger.label');
600
+ const tooltip = this._tooltip ? ` x-tooltip="${e(`{ content: ${js(this._tooltip)}, theme: $store.theme, allowHTML: false }`)}"` : '';
601
+ const colors = (component) => (this._color !== 'gray' || component === 'icon-button' ? colorManager.getComponentClasses(component, this._color) : []);
602
+ if (this._view === 'button')
603
+ return `<button${tooltip} type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${cls(['fi-ac-btn-group', 'fi-btn', `fi-size-${this._size}`, ...colors('button')])}">${icon(this._icon)}${e(label)}</button>`;
604
+ if (this._view === 'link')
605
+ return `<button${tooltip} type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${cls(['fi-ac-link-group', 'fi-link', `fi-size-${this._size}`, ...colors('link')])}">${icon(this._icon)}<span class="fi-link-label">${e(label)}</span></button>`;
606
+ return `<button title="${e(label)}" aria-label="${e(label)}"${tooltip} type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${['fi-ac-icon-btn-group', 'fi-icon-btn', `fi-size-${this._size}`, ...colors('icon-button')].join(' ')}">${icon(this._icon)}</button>`;
607
+ }
608
+ /** Menu items of the visible actions, one list per section (nested groups without dropdown). */
609
+ async itemLists(ctx, context, keyOf) {
610
+ const lists = [];
611
+ let current = [];
612
+ for (const action of this.actions) {
613
+ if (action instanceof ActionGroup && !action.hasDropdown()) {
614
+ if (!(await action.isVisible(ctx)))
615
+ continue;
616
+ if (current.length) {
617
+ lists.push(current);
618
+ current = [];
619
+ }
620
+ lists.push(...(await action.itemLists(ctx, context, keyOf)));
621
+ continue;
622
+ }
623
+ if (!(await action.isVisible(ctx)))
624
+ continue;
625
+ current.push(action instanceof ActionGroup ? await action.toHtml(ctx, context, keyOf) : await action.toRecordGroupedHtml(ctx, context, keyOf(action)));
626
+ }
627
+ if (current.length)
628
+ lists.push(current);
629
+ return lists.filter((list) => list.length);
630
+ }
631
+ /** `keyOf` returns each action's `wire:key` (it depends on the record in a table). */
632
+ async toHtml(ctx, explicitContext = null, keyOf = (action) => `actions.${action.getName()}`) {
633
+ if (!colorManager)
634
+ throw new Error('No color manager: call useColorManager() first.');
635
+ if (await evaluate(this._hidden, ctx))
636
+ return '';
637
+ const context = explicitContext ?? ctx.livewire?.getActionMountContext?.() ?? {};
638
+ if (this._buttonGroup) {
639
+ const buttons = [];
640
+ for (const action of this.getActions())
641
+ if (await action.isVisible(ctx))
642
+ buttons.push(await action.toHtml(ctx, explicitContext));
643
+ return buttons.length ? `<div class="fi-btn-group">${buttons.join('')}</div>` : '';
644
+ }
645
+ const lists = await this.itemLists(ctx, context, keyOf);
646
+ if (!lists.length)
366
647
  return '';
367
- const label = e(t('filament-actions::group.trigger.label'));
368
- return `<div x-data="filamentDropdown" class="fi-dropdown"><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger"><button title="${label}" aria-label="${label}" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${['fi-ac-icon-btn-group', 'fi-icon-btn', 'fi-size-md', ...colorManager.getComponentClasses('icon-button', 'primary')].join(' ')}">${icon('m-ellipsis-vertical')}</button></div><div x-cloak x-float.placement.bottom-end.flip.teleport.offset="{ offset: 8 }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" role="menu" class="fi-dropdown-panel"><div class="fi-dropdown-list" role="group">${items.join(' ')}</div></div></div>`;
648
+ const panelClasses = cls(['fi-dropdown-panel', this._width ? `fi-width-${this._width}` : null, { 'fi-scrollable': this._maxHeight !== null }]);
649
+ const style = this._maxHeight ? ` style="max-height: ${this._maxHeight}"` : '';
650
+ return `<div x-data="filamentDropdown" class="fi-dropdown"><div x-on:mousedown="if ($event.button === 0) toggle($event)" class="fi-dropdown-trigger">${this.triggerHtml()}</div><div x-cloak x-float.placement.${this._placement ?? 'bottom-end'}.flip.teleport.offset="{ offset: ${this._offset ?? 8} }" x-ref="panel" x-transition:enter-start="fi-opacity-0" x-transition:leave-end="fi-opacity-0" role="menu" class="${panelClasses}"${style}>${lists.map((items) => `<div class="fi-dropdown-list" role="group">${items.join(' ')}</div>`).join('')}</div></div>`;
369
651
  }
370
652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navette-js/actions",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.4",
4
4
  "description": "Actions and modals for Navette admin panels.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,8 +38,8 @@
38
38
  "node"
39
39
  ],
40
40
  "dependencies": {
41
- "@navette-js/support": "1.0.0-beta.3",
42
- "@navette-js/schemas": "1.0.0-beta.3"
41
+ "@navette-js/support": "1.0.0-beta.4",
42
+ "@navette-js/schemas": "1.0.0-beta.4"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public",
package/views/modal.ejs CHANGED
@@ -1,20 +1,21 @@
1
1
  <%# Modal of a mounted action (confirmation or form). Closing it unmounts the action. %>
2
+ <% const trapOnWindow = locals.slideOver || stickyHeader || stickyFooter; const close = '$dispatch(' + js('close-modal') + ', { id: ' + js(id) + ' })'; %>
2
3
  <div wire:partial="action-modals.<%- index %>"<% if (description) { %> aria-describedby="<%= id %>.description"<% } %> aria-labelledby="<%= id %>.heading" aria-modal="true" id="<%= id %>" role="<%- confirmation ? 'alertdialog' : 'dialog' %>" tabindex="-1" x-data="filamentModal({
3
4
  id: <%- js(id) %>,
4
5
  isScrollLocked: true,
5
6
  shouldRestoreFocus: false,
6
7
  })" data-fi-modal-id="<%= id %>" x-on:close-modal.window="if (($event.detail.id === <%- js(id) %>) && isOpen) close()" x-on:close-modal-quietly.window="if (($event.detail.id === <%- js(id) %>) && isOpen) closeQuietly()" x-on:open-modal.window="if (($event.detail.id === <%- js(id) %>) && (! isOpen)) open()" x-bind:class="{
7
8
  'fi-modal-open': isOpen,
8
- }" x-cloak x-show="isOpen" class="<%- locals.slideOver ? 'fi-modal fi-modal-slide-over fi-absolute-positioning-context' : 'fi-modal fi-absolute-positioning-context' %>" wire:key="<%= livewireId %>.actions.<%= name %>.modal" x-on:modal-closed="if ($event.detail.id === <%= js(id) %>) $wire.unmountAction(false)">
9
+ }" x-cloak x-show="isOpen" class="<%- cls(['fi-modal', locals.slideOver ? 'fi-modal-slide-over' : null, 'fi-absolute-positioning-context', { 'fi-modal-has-sticky-header': stickyHeader, 'fi-modal-has-sticky-footer': stickyFooter }]) %>" wire:key="<%= livewireId %>.actions.<%= name %>.modal" x-on:modal-closed="if ($event.detail.id === <%= js(id) %>) $wire.unmountAction(false)">
9
10
  <div aria-hidden="true" x-show="isOpen" x-transition.duration.300ms.opacity class="fi-modal-close-overlay"></div>
10
11
 
11
- <div tabindex="-1" x-on:click.self="$dispatch(<%= js('close-modal') %>, { id: <%= js(id) %> })" x-trap.noreturn="isTrapActive" class="fi-modal-window-ctn fi-clickable">
12
- <form x-on:keydown.window.escape="if (isTopmost()) $dispatch(<%= js('close-modal') %>, { id: <%= js(id) %> })" x-show="isWindowVisible" x-transition:enter="fi-transition-enter" x-transition:leave="fi-transition-leave" x-transition:enter-start="fi-transition-enter-start" x-transition:enter-end="fi-transition-enter-end" x-transition:leave-start="fi-transition-leave-start" x-transition:leave-end="fi-transition-leave-end" wire:submit.prevent="callMountedAction" wire:key="<%= livewireId %>.modal.<%= id %>.window" class="<%- cls(['fi-modal-window', 'fi-modal-window-has-close-btn', { 'fi-modal-window-has-content': content !== null }, 'fi-modal-window-has-footer', { 'fi-modal-window-has-icon': iconName }, 'fi-align-' + alignment, 'fi-width-' + width]) %>">
12
+ <div<% if (!trapOnWindow) { %> tabindex="-1"<% } %><% if (closeByClickingAway) { %> x-on:click.self="<%= close %>"<% } %><% if (!trapOnWindow) { %> x-trap.noreturn="isTrapActive"<% } %> class="<%- cls(['fi-modal-window-ctn', { 'fi-clickable': closeByClickingAway }]) %>">
13
+ <form<% if (closeByEscaping) { %> x-on:keydown.window.escape="if (isTopmost()) <%= close %>"<% } %><% if (trapOnWindow) { %> x-trap.noreturn="isTrapActive" tabindex="0"<% } %> x-show="isWindowVisible" x-transition:enter="fi-transition-enter" x-transition:leave="fi-transition-leave" x-transition:enter-start="fi-transition-enter-start" x-transition:enter-end="fi-transition-enter-end" x-transition:leave-start="fi-transition-leave-start" x-transition:leave-end="fi-transition-leave-end" wire:submit.prevent="callMountedAction" wire:key="<%= livewireId %>.modal.<%= id %>.window" class="<%- cls(['fi-modal-window', { 'fi-modal-window-has-close-btn': closeButton }, { 'fi-modal-window-has-content': content !== null }, { 'fi-modal-window-has-footer': locals.hasFooter !== false }, { 'fi-modal-window-has-icon': iconName }, 'fi-align-' + alignment, 'fi-width-' + width]) %>">
13
14
  <div wire:key="<%= livewireId %>.modal.<%= id %>.header" class="fi-modal-header">
14
- <%- view('filament::components.icon-button', {
15
+ <% if (closeButton) { %><%- view('filament::components.icon-button', {
15
16
  iconName: 'o-x-mark', iconSize: 'lg', label: t('filament::components/modal.actions.close.label'), className: 'fi-modal-close-btn',
16
- extra: attrs({ tabindex: '-1' }), after: attrs({ 'x-on:click': "$dispatch('close-modal', { id: " + js(id) + ' })' }),
17
- }) %>
17
+ extra: attrs({ tabindex: closeByEscaping ? '-1' : null }), after: attrs({ 'x-on:click': "$dispatch('close-modal', { id: " + js(id) + ' })' }),
18
+ }) %><% } %>
18
19
 
19
20
  <% if (iconName) { %>
20
21
  <div class="fi-modal-icon-ctn">
@@ -44,12 +45,14 @@
44
45
  </div>
45
46
  <% } %>
46
47
 
47
- <div wire:key="<%= livewireId %>.modal.<%= id %>.footer" class="fi-modal-footer fi-align-<%- confirmation ? 'center' : 'start' %>">
48
+ <% if (locals.hasFooter !== false) { %>
49
+ <div wire:key="<%= livewireId %>.modal.<%= id %>.footer" class="fi-modal-footer fi-align-<%- footerAlignment %>">
48
50
  <div class="fi-modal-footer-actions">
49
51
  <%# Confirmation: cancel then confirm; form: submit then cancel. %>
50
52
  <%- confirmation ? cancel + submit : submit + cancel %>
51
53
  </div>
52
54
  </div>
55
+ <% } %>
53
56
  </form>
54
57
  </div>
55
58
  </div>