@navette-js/actions 1.0.0-beta.3 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ColorManager, type Lazy } from '@navette-js/support';
1
+ import { type HtmlString, type ColorManager, type Lazy } from '@navette-js/support';
2
2
  import type { ComponentContext, Renderable } from '@navette-js/schemas';
3
3
  /** Render hook slots of an action modal. Hooks receive the action as `data.action`. */
4
4
  export declare const ActionsRenderHook: {
@@ -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;
@@ -35,6 +51,7 @@ export declare class Action implements Renderable {
35
51
  private _modalHeading;
36
52
  private _modalDescription;
37
53
  private _modalSubmitActionLabel;
54
+ private _lazySubmitLabel;
38
55
  private _modalIcon;
39
56
  private _icon;
40
57
  private _alpineClick;
@@ -61,24 +78,25 @@ export declare class Action implements Renderable {
61
78
  /** Navigation action, rendered as `<a href wire:navigate>`. */
62
79
  url(url: string | null | ((ctx: ComponentContext) => string | null)): this;
63
80
  /** A string: host component method called on click (`wire:click`). A function: the action handler, mounted through `mountAction()` (with its modal, if any). */
81
+ /** On click, dispatches a Livewire event (to a listener of the page or of another component), with parameters. */
82
+ dispatch(event: string, params?: unknown[] | Record<string, unknown>): this;
64
83
  action(action: string | ((ctx: ActionCallContext) => unknown | Promise<unknown>) | null): this;
65
84
  icon(name: string | null | ((ctx: ComponentContext) => string | null | Promise<string | null>)): this;
66
85
  /** Icon used when the action is a table row link. */
67
86
  tableIcon(name: string | null): this;
68
87
  private _iconButton;
69
- /** Icon-only row action; the label becomes the tooltip and accessible name. */
70
- iconButton(condition?: boolean): this;
71
88
  /** Icon used when the action is a menu item (ActionGroup). */
72
89
  groupedIcon(name: string | null): this;
73
90
  requiresConfirmation(condition?: boolean): this;
74
91
  modalHeading(heading: Lazy<string | null, ActionCallContext>): this;
75
- modalDescription(description: Lazy<string | null, ActionCallContext>): this;
76
- modalSubmitActionLabel(label: string | null): this;
92
+ /** Text under the heading; an `HtmlString` is inserted as is (build it from escaped values). */
93
+ modalDescription(description: Lazy<string | HtmlString | null, ActionCallContext>): this;
94
+ /** Label of the submit button; a function receives the action context (its arguments…). */
95
+ modalSubmitActionLabel(label: string | null | ((ctx: ActionCallContext) => string | null)): this;
77
96
  modalIcon(name: string | null): this;
78
97
  private _badge;
79
98
  private _badgeColor;
80
- /** Badge (counter) shown on the button. */
81
- badge(badge: string | number | null | (() => string | number | null | Promise<string | number | null>)): this;
99
+ /** Color of the counter set with `badge(value)`. */
82
100
  badgeColor(color: string): this;
83
101
  private _slideOver;
84
102
  private _hasSubmit;
@@ -122,8 +140,51 @@ export declare class Action implements Renderable {
122
140
  schema(components: any[] | ((schema: any) => any), columns?: Record<string, number> | number | null): this;
123
141
  /** Applies the action's form to the host's schema; the function returns either components or the configured schema. */
124
142
  applySchema(schema: any): any | null;
125
- /** Extra buttons in the modal footer, after the submit button. */
143
+ /**
144
+ * Extra buttons in the modal footer, after the submit button. An action with an `action()` function runs on top of this
145
+ * one (with its own modal, if it has one), and gets this action's form data as `parentData`.
146
+ */
126
147
  extraModalFooterActions(actions: Action[]): this;
148
+ getExtraModalFooterActions(): Action[];
149
+ private _closeByClickingAway;
150
+ private _closeByEscaping;
151
+ private _closeButton;
152
+ private _stickyHeader;
153
+ private _stickyFooter;
154
+ private _modalAlignment;
155
+ private _footerAlignment;
156
+ private _modalHidden;
157
+ private _mountUsing;
158
+ /** `false`: clicking outside the modal does not close it. */
159
+ closeModalByClickingAway(condition?: boolean): this;
160
+ /** `false`: the Escape key does not close the modal. */
161
+ closeModalByEscaping(condition?: boolean): this;
162
+ /** `false`: no close button in the modal header. */
163
+ modalCloseButton(condition?: boolean): this;
164
+ /** Keeps the modal header visible while its content scrolls. */
165
+ stickyModalHeader(condition?: boolean): this;
166
+ /** Keeps the modal footer visible while its content scrolls. */
167
+ stickyModalFooter(condition?: boolean): this;
168
+ modalAlignment(alignment: 'start' | 'center' | null): this;
169
+ modalFooterActionsAlignment(alignment: 'start' | 'center' | 'end' | null): this;
170
+ private _steps;
171
+ /** Builds the wizard of `steps()`: registered by `@navette-js/forms`, which owns the `Wizard` component. */
172
+ static stepsRenderer: ((steps: any[], submit: Action, cancel: Action) => any) | null;
173
+ /**
174
+ * Turns the modal form into a wizard: each step is validated before the next one, and the last step submits the action.
175
+ * `steps` are `Step` components of `@navette-js/forms`.
176
+ */
177
+ steps(steps: any[] | null): this;
178
+ private _modifyWizard;
179
+ /** Changes the wizard built by `steps()` (`(wizard) => wizard.hiddenHeader()`). */
180
+ modifyWizardUsing(callback: ((wizard: any) => any) | null): this;
181
+ isWizard(): boolean;
182
+ /** Runs the action without its modal (e.g. no confirmation for some records). */
183
+ modalHidden(condition?: Lazy<boolean, ActionCallContext>): this;
184
+ isModalHidden(ctx: ActionCallContext): Promise<boolean>;
185
+ /** Runs when the action is mounted, after the form is filled: `ctx.data` is the form state, which you may change. */
186
+ mountUsing(callback: ((ctx: ActionCallContext) => unknown) | null): this;
187
+ runMountUsing(ctx: ActionCallContext): Promise<void>;
127
188
  /** Initial form state (e.g. from the record). */
128
189
  fillForm(resolver: (ctx: ActionCallContext) => Record<string, unknown> | Promise<Record<string, unknown>>): this;
129
190
  /** Modal width (`xs` … `7xl`). Defaults to `md` for a confirmation and `4xl` for a form. */
@@ -140,6 +201,56 @@ export declare class Action implements Renderable {
140
201
  before(fn: (ctx: ActionCallContext) => unknown | Promise<unknown>): this;
141
202
  /** Runs after the handler succeeds (logging, extra notification…). */
142
203
  after(fn: (ctx: ActionCallContext) => unknown | Promise<unknown>): this;
204
+ private _individualAuthorization;
205
+ private _chunkSize;
206
+ private _deselectAfterCompletion;
207
+ private _successNotificationTitle;
208
+ private _failureNotificationTitle;
209
+ private _missingAuthorizationMessage;
210
+ private _missingProcessingMessage;
211
+ /**
212
+ * Bulk action: each selected record must pass this policy ability (or function), otherwise it is skipped and counted in the
213
+ * failure notification. Used by `processSelectedRecords()` of the table's page.
214
+ */
215
+ authorizeIndividualRecords(ability: string | ((record: any, ctx: ActionCallContext) => boolean | Promise<boolean>) | null): this;
216
+ getIndividualRecordsAuthorization(): string | ((record: any, ctx: ActionCallContext) => boolean | Promise<boolean>) | null;
217
+ /** Number of selected records loaded at a time by `processSelectedRecords()` (100 by default). */
218
+ chunkSelectedRecords(size: number): this;
219
+ getSelectedRecordsChunkSize(): number;
220
+ /** Clears the table selection once the bulk action has run. */
221
+ deselectRecordsAfterCompletion(condition?: boolean): this;
222
+ shouldDeselectRecordsAfterCompletion(): boolean;
223
+ /** Notification sent when every selected record was processed. */
224
+ successNotificationTitle(title: string | null): this;
225
+ getSuccessNotificationTitle(): string | null;
226
+ /** Notification title when some records failed: receives the number of processed records and the number selected. */
227
+ failureNotificationTitle(title: BulkMessage | null): this;
228
+ getFailureNotificationTitle(success: number, total: number): string | null;
229
+ /** Body line for records skipped by `authorizeIndividualRecords()`: receives their number and the number selected. */
230
+ missingBulkAuthorizationFailureNotificationMessage(message: BulkMessage | null): this;
231
+ getMissingBulkAuthorizationFailureNotificationMessage(count: number, total: number): string | null;
232
+ /** Body line for records whose processing failed: receives their number and the number selected. */
233
+ missingBulkProcessingFailureNotificationMessage(message: BulkMessage | null): this;
234
+ getMissingBulkProcessingFailureNotificationMessage(count: number, total: number): string | null;
235
+ private _successNotification;
236
+ private _successRedirectUrl;
237
+ private _preserveData;
238
+ /**
239
+ * Customizes the success notification of a prebuilt action: the function receives the notification and returns it (changed)
240
+ * or `null` to send nothing.
241
+ */
242
+ successNotification(customize: ((notification: any, ctx: ActionCallContext) => any) | null): this;
243
+ getSuccessNotificationCustomizer(): ((notification: any, ctx: ActionCallContext) => any) | null;
244
+ /** Where a prebuilt action redirects after success: a URL, or a function of the context (record, replica...). */
245
+ successRedirectUrl(url: string | ((ctx: ActionCallContext & Record<string, unknown>) => string | null | Promise<string | null>) | null): this;
246
+ getSuccessRedirectUrl(ctx: ActionCallContext & Record<string, unknown>): Promise<string | null>;
247
+ /** "Create & create another": fields kept in the new form (names, or a function returning the data to keep). */
248
+ preserveFormDataWhenCreatingAnother(fields: string[] | ((data: Record<string, unknown>) => Record<string, unknown>) | null): this;
249
+ getDataPreservedForAnother(data: Record<string, unknown>): Record<string, unknown>;
250
+ /** Stops the action from one of its hooks; the modal stays open. */
251
+ halt(): never;
252
+ /** Stops the action from one of its hooks and closes the modal. */
253
+ cancel(): never;
143
254
  call(ctx: ActionCallContext): Promise<unknown>;
144
255
  /** Menu item of a table bulk action. The click goes through the table's Alpine `mountAction()`, which sends the selection. */
145
256
  toGroupedHtml(ctx: ComponentContext, context: Record<string, unknown>): Promise<string>;
@@ -151,7 +262,7 @@ export declare class Action implements Renderable {
151
262
  /** Small link (or icon button) for a table row action. */
152
263
  toLinkHtml(ctx: ComponentContext, context: Record<string, unknown>, wireKey: string): Promise<string>;
153
264
  /** Modal of a mounted action; `content` is the rendered form, if any. */
154
- toModalHtml(ctx: ActionCallContext, index: number, content?: string | null): Promise<string>;
265
+ toModalHtml(ctx: ActionCallContext, index: number, content?: string | null, mountContext?: Record<string, unknown>): Promise<string>;
155
266
  alpineClickHandler(handler: string | null): this;
156
267
  private _livewireTarget;
157
268
  private _iconAfter;
@@ -169,15 +280,80 @@ export declare class Action implements Renderable {
169
280
  /** Shown but unusable; the server also refuses to mount it. */
170
281
  disabled(condition?: Lazy<boolean, ComponentContext>): this;
171
282
  isDisabled(ctx: ComponentContext): Promise<boolean>;
283
+ private _view;
284
+ private _labeledFrom;
285
+ private _outlined;
286
+ /** Trigger style, instead of the one chosen by the location of the action. */
287
+ button(): this;
288
+ link(): this;
289
+ /** Icon only: the label becomes the tooltip and accessible name. */
290
+ iconButton(condition?: boolean): this;
291
+ badge(badge?: string | number | null | (() => string | number | null | Promise<string | number | null>)): this;
292
+ /** Icon button on small screens, labeled button from this breakpoint up (`sm`, `md`, `lg`, `xl`, `2xl`). */
293
+ labeledFrom(breakpoint: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | null): this;
294
+ /** Outlined button. */
295
+ outlined(condition?: boolean): this;
296
+ getView(): 'button' | 'link' | 'icon-button' | 'badge' | null;
297
+ /** Markup of a link, icon button or badge trigger; `attributes` carries the click (or href) behavior. */
298
+ private styledTriggerHtml;
172
299
  /** Renders the action as a button (or as a link when it has a URL). */
173
300
  toHtml(ctx: ComponentContext, explicitMountContext?: Record<string, unknown> | null): Promise<string>;
174
301
  }
175
302
  /** Group of actions collapsed into a dropdown behind a "⋮" icon button. */
303
+ /**
304
+ * Actions in a dropdown menu ("⋮" icon button by default). A nested group with `dropdown(false)` is shown as a section of
305
+ * the menu, separated by a divider. `buttonGroup()` shows the actions side by side instead.
306
+ */
176
307
  export declare class ActionGroup {
177
308
  private actions;
178
- constructor(actions: Action[]);
179
- static make(actions: Action[]): ActionGroup;
309
+ private _label;
310
+ private _icon;
311
+ private _color;
312
+ private _size;
313
+ private _tooltip;
314
+ private _view;
315
+ private _dropdown;
316
+ private _buttonGroup;
317
+ private _placement;
318
+ private _width;
319
+ private _offset;
320
+ private _maxHeight;
321
+ private _hidden;
322
+ constructor(actions: (Action | ActionGroup)[]);
323
+ static make(actions: (Action | ActionGroup)[]): ActionGroup;
324
+ /** Every action of the group, nested groups included. */
180
325
  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>;
326
+ getName(): string;
327
+ /** A group is never mounted: its actions are. */
328
+ isMountable(): boolean;
329
+ label(label: string | null): this;
330
+ icon(icon: string): this;
331
+ color(color: string): this;
332
+ size(size: string): this;
333
+ tooltip(tooltip: string | null): this;
334
+ /** Labeled button trigger. */
335
+ button(): this;
336
+ link(): this;
337
+ iconButton(): this;
338
+ /** `false`: inside another group, the actions form a section of its menu (with a divider). */
339
+ dropdown(condition?: boolean): this;
340
+ hasDropdown(): boolean;
341
+ /** The actions side by side, as a group of buttons. */
342
+ buttonGroup(condition?: boolean): this;
343
+ /** Menu placement: `bottom-start`, `bottom-end`, `top-start`... (`bottom-end` in tables). */
344
+ dropdownPlacement(placement: string | null): this;
345
+ /** Menu width: `xs` to `7xl`. */
346
+ dropdownWidth(width: string | null): this;
347
+ /** Distance between the trigger and the menu, in pixels (8 by default). */
348
+ dropdownOffset(offset: number | null): this;
349
+ /** Maximum height of the menu, as a CSS length; it scrolls beyond. */
350
+ maxHeight(height: string | null): this;
351
+ hidden(condition?: Lazy<boolean, ComponentContext>): this;
352
+ visible(condition?: Lazy<boolean, ComponentContext>): this;
353
+ isVisible(ctx: ComponentContext): Promise<boolean>;
354
+ private triggerHtml;
355
+ /** Menu items of the visible actions, one list per section (nested groups without dropdown). */
356
+ private itemLists;
357
+ /** `keyOf` returns each action's `wire:key` (it depends on the record in a table). */
358
+ toHtml(ctx: ComponentContext, explicitContext?: Record<string, unknown> | null, keyOf?: (action: Action) => string): Promise<string>;
183
359
  }
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); }
@@ -32,6 +40,7 @@ export class Action {
32
40
  _modalHeading = null;
33
41
  _modalDescription = null;
34
42
  _modalSubmitActionLabel = null;
43
+ _lazySubmitLabel = null;
35
44
  _modalIcon = null;
36
45
  _icon = null;
37
46
  _alpineClick = null;
@@ -68,6 +77,8 @@ export class Action {
68
77
  this._lazyUrl = null;
69
78
  } return this; }
70
79
  /** A string: host component method called on click (`wire:click`). A function: the action handler, mounted through `mountAction()` (with its modal, if any). */
80
+ /** On click, dispatches a Livewire event (to a listener of the page or of another component), with parameters. */
81
+ dispatch(event, params = []) { return this.action(`$dispatch(${js(event)}${Array.isArray(params) ? params.map((param) => `, ${js(param)}`).join('') : `, ${js(params)}`})`); }
71
82
  action(action) {
72
83
  if (typeof action === 'function') {
73
84
  this._handler = action;
@@ -88,19 +99,25 @@ export class Action {
88
99
  /** Icon used when the action is a table row link. */
89
100
  tableIcon(name) { this._tableIcon = name; return this; }
90
101
  _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
102
  /** Icon used when the action is a menu item (ActionGroup). */
94
103
  groupedIcon(name) { this._groupedIcon = name; return this; }
95
104
  requiresConfirmation(condition = true) { this._requiresConfirmation = condition; return this; }
96
105
  modalHeading(heading) { this._modalHeading = heading; return this; }
106
+ /** Text under the heading; an `HtmlString` is inserted as is (build it from escaped values). */
97
107
  modalDescription(description) { this._modalDescription = description; return this; }
98
- modalSubmitActionLabel(label) { this._modalSubmitActionLabel = label; return this; }
108
+ /** Label of the submit button; a function receives the action context (its arguments…). */
109
+ modalSubmitActionLabel(label) { if (typeof label === 'function') {
110
+ this._lazySubmitLabel = label;
111
+ this._modalSubmitActionLabel = null;
112
+ }
113
+ else {
114
+ this._modalSubmitActionLabel = label;
115
+ this._lazySubmitLabel = null;
116
+ } return this; }
99
117
  modalIcon(name) { this._modalIcon = name; return this; }
100
118
  _badge = null;
101
119
  _badgeColor = 'primary';
102
- /** Badge (counter) shown on the button. */
103
- badge(badge) { this._badge = badge; return this; }
120
+ /** Color of the counter set with `badge(value)`. */
104
121
  badgeColor(color) { this._badgeColor = color; return this; }
105
122
  _slideOver = false;
106
123
  _hasSubmit = true;
@@ -164,8 +181,64 @@ export class Action {
164
181
  const result = this._schema(schema);
165
182
  return Array.isArray(result) ? schema.columns(this._schemaColumns).components(result) : result;
166
183
  }
167
- /** Extra buttons in the modal footer, after the submit button. */
184
+ /**
185
+ * Extra buttons in the modal footer, after the submit button. An action with an `action()` function runs on top of this
186
+ * one (with its own modal, if it has one), and gets this action's form data as `parentData`.
187
+ */
168
188
  extraModalFooterActions(actions) { this._extraModalFooterActions = actions; return this; }
189
+ getExtraModalFooterActions() { return this._extraModalFooterActions; }
190
+ // --- modal behavior
191
+ _closeByClickingAway = true;
192
+ _closeByEscaping = true;
193
+ _closeButton = true;
194
+ _stickyHeader = false;
195
+ _stickyFooter = false;
196
+ _modalAlignment = null;
197
+ _footerAlignment = null;
198
+ _modalHidden = false;
199
+ _mountUsing = null;
200
+ /** `false`: clicking outside the modal does not close it. */
201
+ closeModalByClickingAway(condition = true) { this._closeByClickingAway = condition; return this; }
202
+ /** `false`: the Escape key does not close the modal. */
203
+ closeModalByEscaping(condition = true) { this._closeByEscaping = condition; return this; }
204
+ /** `false`: no close button in the modal header. */
205
+ modalCloseButton(condition = true) { this._closeButton = condition; return this; }
206
+ /** Keeps the modal header visible while its content scrolls. */
207
+ stickyModalHeader(condition = true) { this._stickyHeader = condition; return this; }
208
+ /** Keeps the modal footer visible while its content scrolls. */
209
+ stickyModalFooter(condition = true) { this._stickyFooter = condition; return this; }
210
+ modalAlignment(alignment) { this._modalAlignment = alignment; return this; }
211
+ modalFooterActionsAlignment(alignment) { this._footerAlignment = alignment; return this; }
212
+ _steps = null;
213
+ /** Builds the wizard of `steps()`: registered by `@navette-js/forms`, which owns the `Wizard` component. */
214
+ static stepsRenderer = null;
215
+ /**
216
+ * Turns the modal form into a wizard: each step is validated before the next one, and the last step submits the action.
217
+ * `steps` are `Step` components of `@navette-js/forms`.
218
+ */
219
+ steps(steps) {
220
+ this._steps = steps;
221
+ this._schema = steps === null ? null : (schema) => {
222
+ if (!Action.stepsRenderer)
223
+ throw new Error('Action.steps() needs @navette-js/forms to be loaded.');
224
+ const submitLabel = this._modalSubmitActionLabel ?? t('filament-actions::modal.actions.submit.label');
225
+ const submit = Action.make('submit').label(submitLabel).color(this._color === 'gray' ? 'primary' : this._color).submit('callMountedAction');
226
+ const cancel = Action.make('cancel').label(this._cancelLabel ?? t('filament-actions::modal.actions.cancel.label')).color('gray').alpineClickHandler('close()');
227
+ const wizard = Action.stepsRenderer(steps, submit, cancel);
228
+ return schema.components([this._modifyWizard?.(wizard) ?? wizard]);
229
+ };
230
+ return this;
231
+ }
232
+ _modifyWizard = null;
233
+ /** Changes the wizard built by `steps()` (`(wizard) => wizard.hiddenHeader()`). */
234
+ modifyWizardUsing(callback) { this._modifyWizard = callback; return this; }
235
+ isWizard() { return this._steps !== null; }
236
+ /** Runs the action without its modal (e.g. no confirmation for some records). */
237
+ modalHidden(condition = true) { this._modalHidden = condition; return this; }
238
+ async isModalHidden(ctx) { return Boolean(await evaluate(this._modalHidden, ctx)); }
239
+ /** Runs when the action is mounted, after the form is filled: `ctx.data` is the form state, which you may change. */
240
+ mountUsing(callback) { this._mountUsing = callback; return this; }
241
+ async runMountUsing(ctx) { await this._mountUsing?.(ctx); }
169
242
  /** Initial form state (e.g. from the record). */
170
243
  fillForm(resolver) { this._fillForm = resolver; return this; }
171
244
  /** Modal width (`xs` … `7xl`). Defaults to `md` for a confirmation and `4xl` for a form. */
@@ -182,9 +255,68 @@ export class Action {
182
255
  before(fn) { this._before = fn; return this; }
183
256
  /** Runs after the handler succeeds (logging, extra notification…). */
184
257
  after(fn) { this._after = fn; return this; }
258
+ // --- bulk actions
259
+ _individualAuthorization = null;
260
+ _chunkSize = 100;
261
+ _deselectAfterCompletion = false;
262
+ _successNotificationTitle = null;
263
+ _failureNotificationTitle = null;
264
+ _missingAuthorizationMessage = null;
265
+ _missingProcessingMessage = null;
266
+ /**
267
+ * Bulk action: each selected record must pass this policy ability (or function), otherwise it is skipped and counted in the
268
+ * failure notification. Used by `processSelectedRecords()` of the table's page.
269
+ */
270
+ authorizeIndividualRecords(ability) { this._individualAuthorization = ability; return this; }
271
+ getIndividualRecordsAuthorization() { return this._individualAuthorization; }
272
+ /** Number of selected records loaded at a time by `processSelectedRecords()` (100 by default). */
273
+ chunkSelectedRecords(size) { this._chunkSize = Math.max(1, Math.floor(size)); return this; }
274
+ getSelectedRecordsChunkSize() { return this._chunkSize; }
275
+ /** Clears the table selection once the bulk action has run. */
276
+ deselectRecordsAfterCompletion(condition = true) { this._deselectAfterCompletion = condition; return this; }
277
+ shouldDeselectRecordsAfterCompletion() { return this._deselectAfterCompletion; }
278
+ /** Notification sent when every selected record was processed. */
279
+ successNotificationTitle(title) { this._successNotificationTitle = title; return this; }
280
+ getSuccessNotificationTitle() { return this._successNotificationTitle; }
281
+ /** Notification title when some records failed: receives the number of processed records and the number selected. */
282
+ failureNotificationTitle(title) { this._failureNotificationTitle = title; return this; }
283
+ getFailureNotificationTitle(success, total) { const title = this._failureNotificationTitle; return typeof title === 'function' ? title(success, total) : title; }
284
+ /** Body line for records skipped by `authorizeIndividualRecords()`: receives their number and the number selected. */
285
+ missingBulkAuthorizationFailureNotificationMessage(message) { this._missingAuthorizationMessage = message; return this; }
286
+ getMissingBulkAuthorizationFailureNotificationMessage(count, total) { const message = this._missingAuthorizationMessage; return typeof message === 'function' ? message(count, total) : message; }
287
+ /** Body line for records whose processing failed: receives their number and the number selected. */
288
+ missingBulkProcessingFailureNotificationMessage(message) { this._missingProcessingMessage = message; return this; }
289
+ getMissingBulkProcessingFailureNotificationMessage(count, total) { const message = this._missingProcessingMessage; return typeof message === 'function' ? message(count, total) : message; }
290
+ // --- outcome of single actions
291
+ _successNotification = null;
292
+ _successRedirectUrl = null;
293
+ _preserveData = null;
294
+ /**
295
+ * Customizes the success notification of a prebuilt action: the function receives the notification and returns it (changed)
296
+ * or `null` to send nothing.
297
+ */
298
+ successNotification(customize) { this._successNotification = customize; return this; }
299
+ getSuccessNotificationCustomizer() { return this._successNotification; }
300
+ /** Where a prebuilt action redirects after success: a URL, or a function of the context (record, replica...). */
301
+ successRedirectUrl(url) { this._successRedirectUrl = url; return this; }
302
+ async getSuccessRedirectUrl(ctx) { return typeof this._successRedirectUrl === 'function' ? this._successRedirectUrl(ctx) : this._successRedirectUrl; }
303
+ /** "Create & create another": fields kept in the new form (names, or a function returning the data to keep). */
304
+ preserveFormDataWhenCreatingAnother(fields) { this._preserveData = fields; return this; }
305
+ getDataPreservedForAnother(data) {
306
+ if (!this._preserveData)
307
+ return {};
308
+ if (typeof this._preserveData === 'function')
309
+ return this._preserveData(data) ?? {};
310
+ return Object.fromEntries(this._preserveData.filter((field) => Object.hasOwn(data, field)).map((field) => [field, data[field]]));
311
+ }
312
+ /** Stops the action from one of its hooks; the modal stays open. */
313
+ halt() { throw new ActionHalt(false); }
314
+ /** Stops the action from one of its hooks and closes the modal. */
315
+ cancel() { throw new ActionHalt(true); }
185
316
  async call(ctx) {
186
317
  if (!this._handler)
187
318
  return null;
319
+ ctx = { ...ctx, action: this };
188
320
  if (this._mutateData)
189
321
  ctx = { ...ctx, data: await this._mutateData(ctx.data ?? {}, ctx) };
190
322
  await this._before?.(ctx);
@@ -241,6 +373,9 @@ export class Action {
241
373
  async toLinkHtml(ctx, context, wireKey) {
242
374
  if (!colorManager)
243
375
  throw new Error('No color manager: call useColorManager() first.');
376
+ // `button()`, `badge()`, `outlined()` or `labeledFrom()` in a table row: rendered like a header action.
377
+ if (this._view === 'button' || this._view === 'badge' || this._outlined || this._labeledFrom)
378
+ return this.toHtml(ctx, context);
244
379
  const color = this._lazyColor ? await this._lazyColor(ctx) : this._color;
245
380
  const iconName = this._lazyIcon ? await this._lazyIcon(ctx) : (this._tableIcon ?? this._icon);
246
381
  const raw = `mountAction(${js(this.name)}, {}, ${js(context)})`;
@@ -262,7 +397,7 @@ export class Action {
262
397
  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
398
  }
264
399
  /** Modal of a mounted action; `content` is the rendered form, if any. */
265
- async toModalHtml(ctx, index, content = null) {
400
+ async toModalHtml(ctx, index, content = null, mountContext = {}) {
266
401
  if (!colorManager)
267
402
  throw new Error('No color manager: call useColorManager() first.');
268
403
  const schemaCtx = { livewire: ctx.livewire, component: null, operation: null, record: ctx.record, get: () => undefined, state: undefined };
@@ -271,7 +406,7 @@ export class Action {
271
406
  const confirmation = this._requiresConfirmation;
272
407
  // A confirmation is centered (md); a form is start-aligned (4xl), except at small widths.
273
408
  const width = this._modalWidth ?? (confirmation ? 'md' : '4xl');
274
- const submitLabel = this._modalSubmitActionLabel ?? t(confirmation ? 'filament-actions::modal.actions.confirm.label' : 'filament-actions::modal.actions.submit.label');
409
+ const submitLabel = this._lazySubmitLabel?.(ctx) ?? this._modalSubmitActionLabel ?? t(confirmation ? 'filament-actions::modal.actions.confirm.label' : 'filament-actions::modal.actions.submit.label');
275
410
  // Hook order: custom content, schema, content footer, each between its before/after slots.
276
411
  const scopes = ctx.livewire.getRenderHookScopes?.() ?? [];
277
412
  const hook = (name) => renderHook(name, scopes, { action: this });
@@ -280,15 +415,21 @@ export class Action {
280
415
  + hook(ActionsRenderHook.MODAL_CUSTOM_CONTENT_FOOTER_BEFORE) + hook(ActionsRenderHook.MODAL_CUSTOM_CONTENT_FOOTER_AFTER);
281
416
  if (content !== null || hooked !== '')
282
417
  content = hooked;
418
+ // Footer actions with a handler are mounted on top of this one, with its context.
419
+ const footerActions = await Promise.all(this._extraModalFooterActions.map((a) => (a.isMountable() ? a.toHtml(schemaCtx, { ...mountContext, parentAction: index }) : a.toHtml(schemaCtx))));
283
420
  return views.render('filament-actions::modal', {
284
- slideOver: this._slideOver, content, confirmation, width, alignment: confirmation || ['xs', 'sm'].includes(width) ? 'center' : 'start',
421
+ // A wizard has its own buttons (back, next, submit): the modal has no footer.
422
+ hasFooter: !this.isWizard(),
423
+ closeByClickingAway: this._closeByClickingAway, closeByEscaping: this._closeByEscaping, closeButton: this._closeButton, stickyHeader: this._stickyHeader, stickyFooter: this._stickyFooter,
424
+ footerAlignment: this._footerAlignment ?? (confirmation ? 'center' : 'start'),
425
+ slideOver: this._slideOver, content, confirmation, width, alignment: this._modalAlignment ?? (confirmation || ['xs', 'sm'].includes(width) ? 'center' : 'start'),
285
426
  livewireId: ctx.livewire.id, id: `fi-${ctx.livewire.id}-action-${index}`, index, name: this.name,
286
427
  heading: (await evaluate(this._modalHeading, ctx)) ?? label,
287
428
  description: (await evaluate(this._modalDescription, ctx)) ?? (this._requiresConfirmation ? t('filament-actions::modal.confirmation') : null),
288
429
  iconName: this._modalIcon ?? (this._requiresConfirmation ? 'o-exclamation-triangle' : null), color,
289
430
  cancel: await Action.make('cancel').label(this._cancelLabel ?? t('filament-actions::modal.actions.cancel.label')).color('gray').alpineClickHandler('close()').toHtml(schemaCtx),
290
431
  // 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(''),
432
+ submit: !this._hasSubmit ? '' : (await Action.make('submit').label(submitLabel).color(color === 'gray' ? 'primary' : color).submit('callMountedAction').toHtml(schemaCtx)) + footerActions.join(''),
292
433
  });
293
434
  }
294
435
  alpineClickHandler(handler) { this._alpineClick = handler; return this; }
@@ -308,11 +449,72 @@ export class Action {
308
449
  /** Shown but unusable; the server also refuses to mount it. */
309
450
  disabled(condition = true) { this._disabled = condition; return this; }
310
451
  async isDisabled(ctx) { return evaluate(this._disabled, ctx); }
452
+ _view = null;
453
+ _labeledFrom = null;
454
+ _outlined = false;
455
+ /** Trigger style, instead of the one chosen by the location of the action. */
456
+ button() { this._view = 'button'; return this; }
457
+ link() { this._view = 'link'; return this; }
458
+ /** Icon only: the label becomes the tooltip and accessible name. */
459
+ iconButton(condition = true) { this._iconButton = condition; this._view = condition ? 'icon-button' : null; return this; }
460
+ badge(badge) {
461
+ // `badge()` without argument: badge trigger style; with a value: a counter on the button.
462
+ if (arguments.length === 0) {
463
+ this._view = 'badge';
464
+ return this;
465
+ }
466
+ this._badge = badge ?? null;
467
+ return this;
468
+ }
469
+ /** Icon button on small screens, labeled button from this breakpoint up (`sm`, `md`, `lg`, `xl`, `2xl`). */
470
+ labeledFrom(breakpoint) { this._labeledFrom = breakpoint !== null && ['sm', 'md', 'lg', 'xl', '2xl'].includes(breakpoint) ? breakpoint : null; return this; }
471
+ /** Outlined button. */
472
+ outlined(condition = true) { this._outlined = condition; return this; }
473
+ getView() { return this._view; }
474
+ /** Markup of a link, icon button or badge trigger; `attributes` carries the click (or href) behavior. */
475
+ async styledTriggerHtml(ctx, view, attributes, tag, wireTarget) {
476
+ const label = (await evaluate(this._label, ctx)) ?? this.getDefaultLabel();
477
+ const iconName = this._lazyIcon ? await this._lazyIcon(ctx) : this._icon;
478
+ const color = this._lazyColor ? await this._lazyColor(ctx) : this._color;
479
+ const colors = (component) => (color !== 'gray' ? colorManager.getComponentClasses(component, color) : []);
480
+ const loader = tag === 'button' && wireTarget ? loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': wireTarget })) : '';
481
+ const glyph = iconName ? icon(iconName, { attributes: tag === 'button' && wireTarget ? new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': wireTarget }) : undefined }) : '';
482
+ const tooltip = await this.tooltipAttribute(ctx);
483
+ if (view === 'icon-button')
484
+ 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}>`;
485
+ if (view === 'badge')
486
+ 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}>`;
487
+ 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}>`;
488
+ }
311
489
  /** Renders the action as a button (or as a link when it has a URL). */
312
490
  async toHtml(ctx, explicitMountContext = null) {
313
491
  if (!colorManager)
314
492
  throw new Error('No color manager: call useColorManager() first.');
315
493
  const linkUrl = this._lazyUrl ? this._lazyUrl(ctx) : this._url;
494
+ // Explicit trigger styles (link, icon button, badge), and the icon button / labeled button pair of `labeledFrom()`.
495
+ if (this._submit === null && (this._view === 'link' || this._view === 'icon-button' || this._view === 'badge' || this._labeledFrom)) {
496
+ const safeUrl = linkUrl !== null && /^(\/(?!\/)|https?:\/\/)/.test(linkUrl) ? linkUrl : null;
497
+ const mountContext = explicitMountContext ?? ctx.livewire?.getActionMountContext?.() ?? {};
498
+ const mountExpression = this._handler ? (Object.keys(mountContext).length ? `mountAction(${js(this.name)}, {}, ${js(mountContext)})` : `mountAction(${js(this.name)})`) : null;
499
+ 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)}" ` : ''}`;
500
+ const tag = safeUrl ? 'a' : 'button';
501
+ const target = this._livewireTarget ?? mountExpression ?? this._action;
502
+ if (this._labeledFrom) {
503
+ 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`);
504
+ const previousView = this._view, previousLabeled = this._labeledFrom;
505
+ this._view = null;
506
+ this._labeledFrom = null;
507
+ try {
508
+ const labeled = (await this.toHtml(ctx, explicitMountContext)).replace(/class="([^"]*\bfi-btn\b[^"]*)"/, (_match, classes) => `class="${classes} fi-hidden ${previousLabeled}:fi-inline-grid"`);
509
+ return iconButton + labeled;
510
+ }
511
+ finally {
512
+ this._view = previousView;
513
+ this._labeledFrom = previousLabeled;
514
+ }
515
+ }
516
+ return this.styledTriggerHtml(ctx, this._view, click.trim(), tag, target);
517
+ }
316
518
  if (linkUrl !== null && /^(\/(?!\/)|https?:\/\/)/.test(linkUrl)) {
317
519
  const link = new Attributes().class(['fi-ac-btn-action', 'fi-btn', `fi-size-${this._size}`, ...(this._color !== 'gray' ? colorManager.getComponentClasses('button', this._color) : [])]);
318
520
  const linkIcon = this._icon ? icon(this._icon) : '';
@@ -333,7 +535,7 @@ export class Action {
333
535
  'wire:target': this._livewireTarget ?? this._submit,
334
536
  'x-bind:disabled': isSubmit ? 'isProcessing' : null,
335
537
  })
336
- .class(['fi-ac-btn-action', 'fi-btn', `fi-size-${this._size}`, ...colorManager.getComponentClasses('button', this._color)])
538
+ .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
539
  .when(this._action, (a) => a.set('wire:click', this._action))
338
540
  .when(mountExpression, (a) => a.set('wire:click', mountExpression))
339
541
  .when(this._alpineClick, (a) => a.set('x-on:click', this._alpineClick));
@@ -349,22 +551,118 @@ export class Action {
349
551
  }
350
552
  }
351
553
  /** Group of actions collapsed into a dropdown behind a "⋮" icon button. */
554
+ /**
555
+ * Actions in a dropdown menu ("⋮" icon button by default). A nested group with `dropdown(false)` is shown as a section of
556
+ * the menu, separated by a divider. `buttonGroup()` shows the actions side by side instead.
557
+ */
352
558
  export class ActionGroup {
353
559
  actions;
560
+ _label = null;
561
+ _icon = 'm-ellipsis-vertical';
562
+ _color = 'primary';
563
+ _size = 'md';
564
+ _tooltip = null;
565
+ _view = 'icon-button';
566
+ _dropdown = true;
567
+ _buttonGroup = false;
568
+ _placement = null;
569
+ _width = null;
570
+ _offset = null;
571
+ _maxHeight = null;
572
+ _hidden = false;
354
573
  constructor(actions) { this.actions = actions; }
355
574
  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 = [];
575
+ /** Every action of the group, nested groups included. */
576
+ getActions() { return this.actions.flatMap((action) => (action instanceof ActionGroup ? action.getActions() : [action])); }
577
+ getName() { return 'group'; }
578
+ /** A group is never mounted: its actions are. */
579
+ isMountable() { return false; }
580
+ label(label) { this._label = label; return this; }
581
+ icon(icon) { this._icon = icon; return this; }
582
+ color(color) { this._color = color; return this; }
583
+ size(size) { this._size = size; return this; }
584
+ tooltip(tooltip) { this._tooltip = tooltip; return this; }
585
+ /** Labeled button trigger. */
586
+ button() { this._view = 'button'; return this; }
587
+ link() { this._view = 'link'; return this; }
588
+ iconButton() { this._view = 'icon-button'; return this; }
589
+ /** `false`: inside another group, the actions form a section of its menu (with a divider). */
590
+ dropdown(condition = true) { this._dropdown = condition; return this; }
591
+ hasDropdown() { return this._dropdown; }
592
+ /** The actions side by side, as a group of buttons. */
593
+ buttonGroup(condition = true) { this._buttonGroup = condition; return this; }
594
+ /** Menu placement: `bottom-start`, `bottom-end`, `top-start`... (`bottom-end` in tables). */
595
+ dropdownPlacement(placement) { this._placement = placement !== null && /^(top|bottom|left|right)(-(start|end))?$/.test(placement) ? placement : null; return this; }
596
+ /** Menu width: `xs` to `7xl`. */
597
+ dropdownWidth(width) { this._width = width !== null && /^(xs|sm|md|lg|xl|[2-7]xl)$/.test(width) ? width : null; return this; }
598
+ /** Distance between the trigger and the menu, in pixels (8 by default). */
599
+ dropdownOffset(offset) { this._offset = offset !== null && Number.isFinite(offset) ? Math.max(0, Math.round(offset)) : null; return this; }
600
+ /** Maximum height of the menu, as a CSS length; it scrolls beyond. */
601
+ maxHeight(height) { this._maxHeight = height !== null && /^[0-9.]+(px|rem|em|vh|%)$/.test(height) ? height : null; return this; }
602
+ hidden(condition = true) { this._hidden = condition; return this; }
603
+ visible(condition = true) { return this.hidden(typeof condition === 'function' ? async (ctx) => !(await condition(ctx)) : !condition); }
604
+ async isVisible(ctx) {
605
+ if (await evaluate(this._hidden, ctx))
606
+ return false;
362
607
  for (const action of this.actions)
363
608
  if (await action.isVisible(ctx))
364
- items.push(await action.toRecordGroupedHtml(ctx, context, keyOf(action)));
365
- if (!items.length)
609
+ return true;
610
+ return false;
611
+ }
612
+ triggerHtml() {
613
+ if (!colorManager)
614
+ throw new Error('No color manager: call useColorManager() first.');
615
+ const label = this._label ?? t('filament-actions::group.trigger.label');
616
+ const tooltip = this._tooltip ? ` x-tooltip="${e(`{ content: ${js(this._tooltip)}, theme: $store.theme, allowHTML: false }`)}"` : '';
617
+ const colors = (component) => (this._color !== 'gray' || component === 'icon-button' ? colorManager.getComponentClasses(component, this._color) : []);
618
+ if (this._view === 'button')
619
+ 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>`;
620
+ if (this._view === 'link')
621
+ 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>`;
622
+ 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>`;
623
+ }
624
+ /** Menu items of the visible actions, one list per section (nested groups without dropdown). */
625
+ async itemLists(ctx, context, keyOf) {
626
+ const lists = [];
627
+ let current = [];
628
+ for (const action of this.actions) {
629
+ if (action instanceof ActionGroup && !action.hasDropdown()) {
630
+ if (!(await action.isVisible(ctx)))
631
+ continue;
632
+ if (current.length) {
633
+ lists.push(current);
634
+ current = [];
635
+ }
636
+ lists.push(...(await action.itemLists(ctx, context, keyOf)));
637
+ continue;
638
+ }
639
+ if (!(await action.isVisible(ctx)))
640
+ continue;
641
+ current.push(action instanceof ActionGroup ? await action.toHtml(ctx, context, keyOf) : await action.toRecordGroupedHtml(ctx, context, keyOf(action)));
642
+ }
643
+ if (current.length)
644
+ lists.push(current);
645
+ return lists.filter((list) => list.length);
646
+ }
647
+ /** `keyOf` returns each action's `wire:key` (it depends on the record in a table). */
648
+ async toHtml(ctx, explicitContext = null, keyOf = (action) => `actions.${action.getName()}`) {
649
+ if (!colorManager)
650
+ throw new Error('No color manager: call useColorManager() first.');
651
+ if (await evaluate(this._hidden, ctx))
652
+ return '';
653
+ const context = explicitContext ?? ctx.livewire?.getActionMountContext?.() ?? {};
654
+ if (this._buttonGroup) {
655
+ const buttons = [];
656
+ for (const action of this.getActions())
657
+ if (await action.isVisible(ctx))
658
+ buttons.push(await action.toHtml(ctx, explicitContext));
659
+ return buttons.length ? `<div class="fi-btn-group">${buttons.join('')}</div>` : '';
660
+ }
661
+ const lists = await this.itemLists(ctx, context, keyOf);
662
+ if (!lists.length)
366
663
  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>`;
664
+ const panelClasses = cls(['fi-dropdown-panel', this._width ? `fi-width-${this._width}` : null, { 'fi-scrollable': this._maxHeight !== null }]);
665
+ const style = this._maxHeight ? ` style="max-height: ${this._maxHeight}"` : '';
666
+ 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
667
  }
370
668
  }
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.5",
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.5",
42
+ "@navette-js/schemas": "1.0.0-beta.5"
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">
@@ -31,7 +32,7 @@
31
32
 
32
33
  <% if (description) { %>
33
34
  <p id="<%= id %>.description" class="fi-modal-description">
34
- <%= description %>
35
+ <% if (description && typeof description === 'object' && typeof description.html === 'string') { %><%- description.html %><% } else { %><%= description %><% } %>
35
36
  </p>
36
37
  <% } %>
37
38
  </div>
@@ -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>