@makigamestudio/ui-core 0.4.2 → 0.5.0

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.
@@ -1,342 +1,682 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, Injectable, input, output, viewChild, ChangeDetectionStrategy, Component, inject } from '@angular/core';
3
- import { IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle, IonCardContent, IonButton, IonIcon, PopoverController, IonList, IonItem, IonLabel, IonSpinner } from '@ionic/angular/standalone';
2
+ import { Injectable, signal, inject, input, output, computed, ChangeDetectionStrategy, Component } from '@angular/core';
3
+ import { PopoverController, IonList, IonItem, IonIcon, IonLabel, IonSpinner, IonButton } from '@ionic/angular/standalone';
4
4
  import { addIcons } from 'ionicons';
5
- import { trashOutline, createOutline, chevronDownOutline } from 'ionicons/icons';
6
-
7
- /**
8
- * TestClass - Example class implementing TestInterface.
9
- * Demonstrates the pattern for creating model classes with validation and factory methods.
10
- */
11
- class TestClass {
12
- id;
13
- title;
14
- description;
15
- createdAt;
16
- isActive;
17
- metadata;
18
- constructor(data) {
19
- this.id = data.id;
20
- this.title = data.title;
21
- this.description = data.description;
22
- this.createdAt = data.createdAt instanceof Date ? data.createdAt : new Date(data.createdAt);
23
- this.isActive = data.isActive;
24
- this.metadata = data.metadata;
25
- }
26
- /**
27
- * Factory method to create a new TestClass instance with a generated ID.
28
- */
29
- static create(title, description) {
30
- return new TestClass({
31
- id: crypto.randomUUID(),
32
- title,
33
- description,
34
- createdAt: new Date(),
35
- isActive: true
36
- });
37
- }
38
- /**
39
- * Creates a new instance with updated properties (immutable pattern).
40
- */
41
- update(changes) {
42
- return new TestClass({
43
- ...this,
44
- ...changes
45
- });
46
- }
47
- /**
48
- * Returns a deactivated copy of this instance.
49
- */
50
- deactivate() {
51
- return this.update({ isActive: false });
52
- }
53
- /**
54
- * Converts the instance to a plain JSON object.
55
- */
56
- toJSON() {
57
- return {
58
- id: this.id,
59
- title: this.title,
60
- description: this.description,
61
- createdAt: this.createdAt,
62
- isActive: this.isActive,
63
- metadata: this.metadata
64
- };
65
- }
66
- }
5
+ import { chevronDownOutline } from 'ionicons/icons';
67
6
 
68
7
  /**
69
8
  * @file Action button type enumeration
70
9
  * @description Defines the display types for action buttons
71
10
  */
72
11
  /**
73
- * Enumeration of action button display types.
12
+ * Enumeration of action button types.
13
+ *
14
+ * Both types support flexible display formats:
15
+ * - Icon + Label: Provide both `icon` and `label` properties
16
+ * - Icon only: Provide `icon` without `label`
17
+ * - Label only: Provide `label` without `icon`
74
18
  *
75
19
  * @example
76
20
  * ```typescript
77
- * const button: ActionButton = {
21
+ * // Default button with label and icon
22
+ * const saveButton: ActionButton = {
78
23
  * id: 'save-btn',
79
24
  * label: 'Save',
25
+ * icon: 'save-outline',
80
26
  * type: ActionButtonType.Default,
81
27
  * handler: () => console.log('Saved!')
82
28
  * };
29
+ *
30
+ * // Icon-only button (no label)
31
+ * const iconButton: ActionButton = {
32
+ * id: 'settings-btn',
33
+ * icon: 'settings-outline',
34
+ * type: ActionButtonType.Default,
35
+ * ariaLabel: 'Settings',
36
+ * handler: () => console.log('Settings')
37
+ * };
38
+ *
39
+ * // Label-only button (no icon)
40
+ * const textButton: ActionButton = {
41
+ * id: 'cancel-btn',
42
+ * label: 'Cancel',
43
+ * type: ActionButtonType.Default,
44
+ * handler: () => console.log('Cancelled')
45
+ * };
83
46
  * ```
84
47
  */
85
48
  var ActionButtonType;
86
49
  (function (ActionButtonType) {
87
50
  /**
88
- * Standard button with label and optional icon.
51
+ * Standard action button.
52
+ * Display format (icon-only, label-only, or icon+label) is determined by which properties are provided.
89
53
  */
90
54
  ActionButtonType["Default"] = "default";
91
- /**
92
- * Button displaying only an icon, no label.
93
- */
94
- ActionButtonType["IconOnly"] = "icon-only";
95
55
  /**
96
56
  * Button that opens a dropdown popover with child actions.
57
+ * Like Default, supports icon-only, label-only, or icon+label display.
97
58
  */
98
59
  ActionButtonType["Dropdown"] = "dropdown";
99
60
  })(ActionButtonType || (ActionButtonType = {}));
100
61
 
101
62
  /**
102
- * TestService - Example service using Angular Signals for reactive state management.
103
- * Provided in root, demonstrating singleton service pattern with signal-based state.
63
+ * @file Action Button List Service
64
+ * @description Provides logic for managing lists of action buttons in dropdowns/popovers.
65
+ *
66
+ * This service encapsulates all list-related logic for action buttons, making it
67
+ * reusable across different UI library implementations (Ionic popover, Material menu,
68
+ * PrimeNG overlay, etc.).
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * // Ionic implementation (ActionButtonListComponent)
73
+ * @Component({
74
+ * providers: [ActionButtonListService],
75
+ * template: `
76
+ * <ion-list>
77
+ * @for (button of buttonList(); track button.id) {
78
+ * <ion-item
79
+ * [disabled]="!canSelectButton(button)"
80
+ * (click)="onSelect(button)"
81
+ * >
82
+ * @if (isButtonLoading(button)) {
83
+ * <ion-spinner slot="start" />
84
+ * }
85
+ * <ion-label>{{ button.label }}</ion-label>
86
+ * </ion-item>
87
+ * }
88
+ * </ion-list>
89
+ * `
90
+ * })
91
+ * export class ActionButtonListComponent {
92
+ * private readonly listService = inject(ActionButtonListService);
93
+ *
94
+ * readonly buttonList = computed(() =>
95
+ * this.listService.resolveButtonList(this._propsButtons(), this.buttons())
96
+ * );
97
+ *
98
+ * protected isButtonLoading(button: ActionButton): boolean {
99
+ * return this.listService.isButtonLoading(button, this.loadingChildIds());
100
+ * }
101
+ * }
102
+ *
103
+ * // Material Menu implementation example
104
+ * @Component({
105
+ * selector: 'mat-action-menu',
106
+ * providers: [ActionButtonListService],
107
+ * template: `
108
+ * <mat-menu #menu="matMenu">
109
+ * @for (button of buttonList(); track button.id) {
110
+ * <button mat-menu-item
111
+ * [disabled]="!canSelectButton(button)"
112
+ * (click)="onSelect(button)"
113
+ * >
114
+ * @if (isButtonLoading(button)) {
115
+ * <mat-spinner diameter="16" />
116
+ * } @else if (button.icon) {
117
+ * <mat-icon>{{ button.icon }}</mat-icon>
118
+ * }
119
+ * <span>{{ button.label }}</span>
120
+ * </button>
121
+ * }
122
+ * </mat-menu>
123
+ * `
124
+ * })
125
+ * export class MatActionMenuComponent {
126
+ * private readonly listService = inject(ActionButtonListService);
127
+ * // ... similar implementation
128
+ * }
129
+ * ```
104
130
  */
105
- class TestService {
106
- // Private writable signal for internal state
107
- _items = signal([], ...(ngDevMode ? [{ debugName: "_items" }] : []));
108
- _loading = signal(false, ...(ngDevMode ? [{ debugName: "_loading" }] : []));
109
- _error = signal(null, ...(ngDevMode ? [{ debugName: "_error" }] : []));
110
- // Public readonly computed signals for consumers
111
- /** All items in the store */
112
- items = this._items.asReadonly();
113
- /** Loading state indicator */
114
- loading = this._loading.asReadonly();
115
- /** Current error message, if any */
116
- error = this._error.asReadonly();
117
- /** Count of all items */
118
- count = computed(() => this._items().length, ...(ngDevMode ? [{ debugName: "count" }] : []));
119
- /** Only active items */
120
- activeItems = computed(() => this._items().filter(item => item.isActive), ...(ngDevMode ? [{ debugName: "activeItems" }] : []));
121
- /** Count of active items */
122
- activeCount = computed(() => this.activeItems().length, ...(ngDevMode ? [{ debugName: "activeCount" }] : []));
131
+ /**
132
+ * Service that provides logic for managing lists of action buttons.
133
+ *
134
+ * This service is designed to be provided at the component level to maintain
135
+ * consistency with other button services.
136
+ *
137
+ * @usageNotes
138
+ * ### Button List Resolution
139
+ * The service handles two input sources for buttons:
140
+ * 1. Direct property assignment (via PopoverController.create componentProps)
141
+ * 2. Angular signal input binding
142
+ *
143
+ * Props take precedence over input when both are provided.
144
+ *
145
+ * ### Loading State
146
+ * Loading state can come from:
147
+ * 1. The button's own `loading` property
148
+ * 2. A set of loading child IDs passed from the parent button
149
+ *
150
+ * ### Selection Validation
151
+ * A button can be selected only if it's not loading and not disabled.
152
+ */
153
+ class ActionButtonListService {
123
154
  /**
124
- * Adds a new item to the store.
155
+ * Resolves the button list from either props or input.
156
+ * Props (from PopoverController) take precedence over input binding.
157
+ *
158
+ * @param propsButtons - Buttons passed via component props
159
+ * @param inputButtons - Buttons passed via signal input
160
+ * @returns The resolved button array
125
161
  */
126
- addItem(title, description) {
127
- const newItem = TestClass.create(title, description);
128
- this._items.update(items => [...items, newItem]);
129
- this._error.set(null);
130
- return newItem;
162
+ resolveButtonList(propsButtons, inputButtons) {
163
+ return propsButtons.length > 0 ? propsButtons : inputButtons;
131
164
  }
132
165
  /**
133
- * Removes an item by ID.
166
+ * Checks if a button is currently in a loading state.
167
+ *
168
+ * @param button - The button to check
169
+ * @param loadingChildIds - Set of child IDs currently loading (optional)
170
+ * @returns True if the button is loading
134
171
  */
135
- removeItem(id) {
136
- const currentItems = this._items();
137
- const filteredItems = currentItems.filter(item => item.id !== id);
138
- if (filteredItems.length === currentItems.length) {
139
- return false;
140
- }
141
- this._items.set(filteredItems);
142
- return true;
172
+ isButtonLoading(button, loadingChildIds) {
173
+ const ids = loadingChildIds ?? new Set();
174
+ return (button.loading ?? false) || ids.has(button.id);
143
175
  }
144
176
  /**
145
- * Updates an existing item by ID.
177
+ * Checks if a button is currently in a loading state using a signal.
178
+ * Convenience method when loadingChildIds is provided as a signal.
179
+ *
180
+ * @param button - The button to check
181
+ * @param loadingChildIdsSignal - Signal containing loading child IDs (optional)
182
+ * @returns True if the button is loading
146
183
  */
147
- updateItem(id, changes) {
148
- let updatedItem = null;
149
- this._items.update(items => items.map(item => {
150
- if (item.id === id) {
151
- updatedItem = item.update(changes);
152
- return updatedItem;
153
- }
154
- return item;
155
- }));
156
- return updatedItem;
184
+ isButtonLoadingFromSignal(button, loadingChildIdsSignal) {
185
+ const loadingChildIds = loadingChildIdsSignal ? loadingChildIdsSignal() : new Set();
186
+ return this.isButtonLoading(button, loadingChildIds);
157
187
  }
158
188
  /**
159
- * Deactivates an item by ID.
189
+ * Determines if a button can be selected (clicked).
190
+ *
191
+ * @param button - The button to check
192
+ * @param loadingChildIds - Set of child IDs currently loading (optional)
193
+ * @returns True if the button can be selected
160
194
  */
161
- deactivateItem(id) {
162
- return this.updateItem(id, { isActive: false });
195
+ canSelectButton(button, loadingChildIds) {
196
+ const isLoading = this.isButtonLoading(button, loadingChildIds);
197
+ const isDisabled = button.config?.disabled ?? false;
198
+ return !isLoading && !isDisabled;
163
199
  }
164
200
  /**
165
- * Gets an item by ID using computed lookup.
201
+ * Determines whether to show a loading spinner for a button.
202
+ *
203
+ * @param button - The button to check
204
+ * @param loadingChildIds - Set of child IDs currently loading (optional)
205
+ * @returns True if spinner should be shown
166
206
  */
167
- getItemById(id) {
168
- return this._items().find(item => item.id === id);
207
+ shouldShowSpinner(button, loadingChildIds) {
208
+ const isLoading = this.isButtonLoading(button, loadingChildIds);
209
+ const showSpinner = button.showLoadingSpinner ?? true;
210
+ return isLoading && showSpinner;
169
211
  }
212
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ActionButtonListService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
213
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ActionButtonListService });
214
+ }
215
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ActionButtonListService, decorators: [{
216
+ type: Injectable
217
+ }] });
218
+
219
+ /**
220
+ * @file Button Display Service
221
+ * @description Provides display logic for action buttons (icon slots, label visibility, etc.).
222
+ *
223
+ * This service encapsulates all display-related logic for buttons, making it
224
+ * reusable across different UI library implementations.
225
+ *
226
+ * @example
227
+ * ```typescript
228
+ * // Ionic implementation
229
+ * @Component({
230
+ * providers: [ButtonDisplayService],
231
+ * template: `
232
+ * <ion-button
233
+ [fill]="button().config?.fill"
234
+ [size]="button().config?.size"
235
+ [color]="button().config?.color"
236
+ [shape]="button().config?.shape"
237
+ [expand]="button().config?.expand"
238
+ [strong]="button().config?.strong"
239
+ [disabled]="isDisabled()"
240
+ [attr.aria-label]="button().ariaLabel"
241
+ [title]="button().tooltip ?? ''"
242
+ (click)="onClick($event)"
243
+ >
244
+ @if (showLoadingSpinner()) {
245
+ <ion-spinner [slot]="iconSlot()" name="crescent" />
246
+ } @else if (button().icon) {
247
+ <ion-icon [name]="button().icon" [slot]="iconSlot()" class="button-icon" />
248
+ }
249
+ @if (showLabel()) {
250
+ {{ button().label }}
251
+ }
252
+ @if (showDropdownIcon()) {
253
+ <ion-icon name="chevron-down-outline" slot="end" class="dropdown-icon" />
254
+ }
255
+ </ion-button>
256
+ * `
257
+ * })
258
+ * export class ButtonComponent {
259
+ * private readonly displayService = inject(ButtonDisplayService);
260
+ * readonly button = input.required<ActionButton>();
261
+ *
262
+ * readonly showLabel = computed(() =>
263
+ * this.displayService.shouldShowLabel(this.button())
264
+ * );
265
+ * }
266
+ *
267
+ * // PrimeNG implementation example
268
+ * @Component({
269
+ * selector: 'p-action-button',
270
+ * providers: [ButtonDisplayService],
271
+ * template: `
272
+ * <p-button
273
+ * [icon]="button().icon"
274
+ * [iconPos]="showLabel() ? 'left' : undefined"
275
+ * [label]="showLabel() ? button().label : null"
276
+ * />
277
+ * `
278
+ * })
279
+ * export class PrimeActionButtonComponent {
280
+ * private readonly displayService = inject(ButtonDisplayService);
281
+ * readonly button = input.required<ActionButton>();
282
+ *
283
+ * readonly showLabel = computed(() =>
284
+ * this.displayService.shouldShowLabel(this.button())
285
+ * );
286
+ * }
287
+ * ```
288
+ */
289
+ /**
290
+ * Service that provides display logic for action buttons.
291
+ *
292
+ * This service is designed to be provided at the component level to maintain
293
+ * consistency with other button services, though it contains only pure functions.
294
+ *
295
+ * @usageNotes
296
+ * ### Pure Functions
297
+ * All methods in this service are pure functions that take a button configuration
298
+ * and return display values. They can be safely used in Angular's `computed()`.
299
+ *
300
+ * ### Dropdown Icon
301
+ * Dropdown buttons show a chevron icon unless `config.hideDropdownIcon` is true.
302
+ */
303
+ class ButtonDisplayService {
170
304
  /**
171
- * Clears all items from the store.
305
+ * Determines whether to show the label text.
306
+ *
307
+ * @param button - The button configuration
308
+ * @returns True when a label is provided
172
309
  */
173
- clearAll() {
174
- this._items.set([]);
175
- this._error.set(null);
310
+ shouldShowLabel(button) {
311
+ return !!button.label;
176
312
  }
177
313
  /**
178
- * Sets the loading state.
314
+ * Determines whether to show the dropdown chevron icon.
315
+ *
316
+ * @param button - The button configuration
317
+ * @returns True for dropdown buttons without hideDropdownIcon
179
318
  */
180
- setLoading(loading) {
181
- this._loading.set(loading);
319
+ shouldShowDropdownIcon(button) {
320
+ return button.type === ActionButtonType.Dropdown && !button.config?.hideDropdownIcon;
321
+ }
322
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonDisplayService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
323
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonDisplayService });
324
+ }
325
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonDisplayService, decorators: [{
326
+ type: Injectable
327
+ }] });
328
+
329
+ /**
330
+ * @file Button Handler Service
331
+ * @description Executes button handlers with automatic loading state management.
332
+ *
333
+ * This service encapsulates handler execution logic, providing automatic
334
+ * loading state management for async handlers across different UI implementations.
335
+ *
336
+ * @example
337
+ * ```typescript
338
+ * // Ionic implementation
339
+ * @Component({
340
+ * providers: [ButtonStateService, ButtonHandlerService]
341
+ * })
342
+ * export class ButtonComponent {
343
+ * private readonly stateService = inject(ButtonStateService);
344
+ * private readonly handlerService = inject(ButtonHandlerService);
345
+ *
346
+ * protected async onClick(): Promise<void> {
347
+ * await this.handlerService.executeHandler(
348
+ * this.button(),
349
+ * loading => this.stateService.setLoading(loading)
350
+ * );
351
+ * this.buttonClick.emit(this.button());
352
+ * }
353
+ * }
354
+ *
355
+ * // Generic web component example
356
+ * class ActionButtonElement extends HTMLElement {
357
+ * private handlerService = new ButtonHandlerService();
358
+ * private loading = false;
359
+ *
360
+ * async handleClick() {
361
+ * await this.handlerService.executeHandler(
362
+ * this.buttonConfig,
363
+ * loading => {
364
+ * this.loading = loading;
365
+ * this.render();
366
+ * }
367
+ * );
368
+ * }
369
+ * }
370
+ * ```
371
+ */
372
+ /**
373
+ * Service that executes button handlers with automatic loading state management.
374
+ *
375
+ * This service is designed to be provided at the component level to ensure
376
+ * proper isolation of handler execution context.
377
+ *
378
+ * @usageNotes
379
+ * ### Async Handler Support
380
+ * When a handler returns a Promise, the service automatically:
381
+ * 1. Calls `onLoadingChange(true)` before execution
382
+ * 2. Awaits the Promise
383
+ * 3. Calls `onLoadingChange(false)` after completion (success or failure)
384
+ *
385
+ * ### Sync Handler Support
386
+ * For synchronous handlers, no loading state changes are triggered.
387
+ *
388
+ * ### Error Handling
389
+ * The service uses try/finally to ensure loading state is always reset,
390
+ * even if the handler throws an error. The error is not caught, allowing
391
+ * it to propagate to the calling code.
392
+ */
393
+ class ButtonHandlerService {
394
+ /**
395
+ * Executes a button's handler with automatic loading state management.
396
+ *
397
+ * @param button - The button whose handler to execute
398
+ * @param onLoadingChange - Callback invoked when loading state changes
399
+ * @returns Promise that resolves when handler completes
400
+ *
401
+ * @example
402
+ * ```typescript
403
+ * await handlerService.executeHandler(
404
+ * myButton,
405
+ * loading => this.isLoading.set(loading)
406
+ * );
407
+ * ```
408
+ */
409
+ async executeHandler(button, onLoadingChange) {
410
+ const result = button.handler();
411
+ if (result instanceof Promise) {
412
+ onLoadingChange(true);
413
+ try {
414
+ await result;
415
+ }
416
+ finally {
417
+ onLoadingChange(false);
418
+ }
419
+ }
182
420
  }
183
421
  /**
184
- * Sets an error message.
422
+ * Executes a child button's handler with loading state tracking by ID.
423
+ *
424
+ * This method is designed for dropdown children where multiple buttons
425
+ * might be loading simultaneously and need individual tracking.
426
+ *
427
+ * @param child - The child button whose handler to execute
428
+ * @param onChildLoadingChange - Callback with child ID and loading state
429
+ * @returns Promise that resolves when handler completes
430
+ *
431
+ * @example
432
+ * ```typescript
433
+ * await handlerService.executeChildHandler(
434
+ * selectedChild,
435
+ * (childId, loading) => {
436
+ * if (loading) {
437
+ * this.stateService.addLoadingChild(childId);
438
+ * } else {
439
+ * this.stateService.removeLoadingChild(childId);
440
+ * }
441
+ * }
442
+ * );
443
+ * ```
185
444
  */
186
- setError(error) {
187
- this._error.set(error);
445
+ async executeChildHandler(child, onChildLoadingChange) {
446
+ onChildLoadingChange(child.id, true);
447
+ try {
448
+ const result = child.handler();
449
+ if (result instanceof Promise) {
450
+ await result;
451
+ }
452
+ }
453
+ finally {
454
+ onChildLoadingChange(child.id, false);
455
+ }
188
456
  }
189
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TestService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
190
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TestService, providedIn: 'root' });
457
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
458
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonHandlerService });
191
459
  }
192
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TestService, decorators: [{
193
- type: Injectable,
194
- args: [{
195
- providedIn: 'root'
196
- }]
460
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonHandlerService, decorators: [{
461
+ type: Injectable
197
462
  }] });
198
463
 
199
464
  /**
200
- * TestCardComponent - Standalone Ionic Card component demonstrating
201
- * Angular 21 best practices: OnPush, Signal inputs/outputs/queries.
465
+ * @file Button State Service
466
+ * @description Manages loading states and computed state values for action buttons.
467
+ *
468
+ * This service encapsulates all state management logic for buttons, making it
469
+ * reusable across different UI library implementations (Ionic, Material, PrimeNG, etc.).
202
470
  *
203
471
  * @example
204
- * ```html
205
- * <maki-test-card
206
- * [item]="myItem"
207
- * [showActions]="true"
208
- * (itemClick)="onItemClick($event)"
209
- * (deleteClick)="onDelete($event)"
210
- * />
472
+ * ```typescript
473
+ * // Ionic implementation (ButtonComponent)
474
+ * @Component({
475
+ * providers: [ButtonStateService]
476
+ * })
477
+ * export class ButtonComponent {
478
+ * private readonly stateService = inject(ButtonStateService);
479
+ * readonly button = input.required<ActionButton>();
480
+ *
481
+ * readonly isLoading = computed(() =>
482
+ * this.stateService.isLoading(this.button())
483
+ * );
484
+ * }
485
+ *
486
+ * // Material implementation example
487
+ * @Component({
488
+ * selector: 'mat-action-button',
489
+ * providers: [ButtonStateService],
490
+ * template: `
491
+ * <button mat-button [disabled]="isDisabled()">
492
+ * @if (showLoadingSpinner()) {
493
+ * <mat-spinner diameter="16" />
494
+ * }
495
+ * {{ button().label }}
496
+ * </button>
497
+ * `
498
+ * })
499
+ * export class MatActionButtonComponent {
500
+ * private readonly stateService = inject(ButtonStateService);
501
+ * readonly button = input.required<ActionButton>();
502
+ *
503
+ * readonly isDisabled = computed(() =>
504
+ * this.stateService.isDisabled(this.button())
505
+ * );
506
+ * readonly showLoadingSpinner = computed(() =>
507
+ * this.stateService.showLoadingSpinner(this.button())
508
+ * );
509
+ * }
211
510
  * ```
212
511
  */
213
- class TestCardComponent {
214
- // Signal inputs
215
- item = input.required(...(ngDevMode ? [{ debugName: "item" }] : []));
216
- showActions = input(false, ...(ngDevMode ? [{ debugName: "showActions" }] : []));
217
- // Signal outputs
218
- itemClick = output();
219
- editClick = output();
220
- deleteClick = output();
221
- // Signal queries
222
- cardElement = viewChild('cardElement', ...(ngDevMode ? [{ debugName: "cardElement" }] : []));
223
- // Computed signals
224
- formattedDate = computed(() => {
225
- const date = this.item().createdAt;
226
- if (!date)
227
- return '';
228
- const dateObj = date instanceof Date ? date : new Date(date);
229
- return dateObj.toLocaleDateString(undefined, {
230
- year: 'numeric',
231
- month: 'short',
232
- day: 'numeric'
233
- });
234
- }, ...(ngDevMode ? [{ debugName: "formattedDate" }] : []));
235
- constructor() {
236
- addIcons({ createOutline, trashOutline });
512
+ /**
513
+ * Service that manages button loading states and computes derived state values.
514
+ *
515
+ * This service is designed to be provided at the component level (not root)
516
+ * to ensure each button instance has isolated state management.
517
+ *
518
+ * @usageNotes
519
+ * ### Providing the Service
520
+ * Always provide this service at the component level to isolate state:
521
+ * ```typescript
522
+ * @Component({
523
+ * providers: [ButtonStateService]
524
+ * })
525
+ * ```
526
+ *
527
+ * ### State Management
528
+ * The service manages two types of loading state:
529
+ * 1. **Internal loading** - Set when executing async handlers via `setLoading()`
530
+ * 2. **Child loading** - Tracks which dropdown children are loading via `setChildLoading()`
531
+ *
532
+ * ### Computed States
533
+ * All computed methods are pure functions that can be used in Angular's `computed()`:
534
+ * - `isLoading(button)` - Combined loading state
535
+ * - `isDisabled(button)` - Whether button should be disabled
536
+ * - `showLoadingSpinner(button)` - Whether to show spinner
537
+ * - `hasLoadingChild(button)` - Whether any child is loading
538
+ */
539
+ class ButtonStateService {
540
+ /**
541
+ * Internal loading state for async handler execution.
542
+ */
543
+ _isLoading = signal(false, ...(ngDevMode ? [{ debugName: "_isLoading" }] : []));
544
+ /**
545
+ * Set of child button IDs that are currently loading (for dropdowns).
546
+ */
547
+ _loadingChildIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "_loadingChildIds" }] : []));
548
+ /**
549
+ * Read-only access to internal loading state.
550
+ */
551
+ internalLoading = this._isLoading.asReadonly();
552
+ /**
553
+ * Read-only access to loading child IDs.
554
+ */
555
+ loadingChildIds = this._loadingChildIds.asReadonly();
556
+ /**
557
+ * Sets the internal loading state.
558
+ *
559
+ * @param loading - Whether the button is loading
560
+ */
561
+ setLoading(loading) {
562
+ this._isLoading.set(loading);
237
563
  }
238
- onCardClick() {
239
- this.itemClick.emit(this.item());
564
+ /**
565
+ * Adds a child ID to the loading set.
566
+ *
567
+ * @param childId - The ID of the child button that started loading
568
+ */
569
+ addLoadingChild(childId) {
570
+ this._loadingChildIds.update(ids => new Set([...ids, childId]));
240
571
  }
241
- onEditClick(event) {
242
- event.stopPropagation();
243
- this.editClick.emit(this.item());
572
+ /**
573
+ * Removes a child ID from the loading set.
574
+ *
575
+ * @param childId - The ID of the child button that finished loading
576
+ */
577
+ removeLoadingChild(childId) {
578
+ this._loadingChildIds.update(ids => {
579
+ const newIds = new Set(ids);
580
+ newIds.delete(childId);
581
+ return newIds;
582
+ });
244
583
  }
245
- onDeleteClick(event) {
246
- event.stopPropagation();
247
- this.deleteClick.emit(this.item());
584
+ /**
585
+ * Checks if any child button is in a loading state with spinner enabled.
586
+ *
587
+ * @param button - The parent button configuration
588
+ * @returns True if any child is loading and should show spinner
589
+ */
590
+ hasLoadingChild(button) {
591
+ if (button.type !== ActionButtonType.Dropdown || !button.children) {
592
+ return false;
593
+ }
594
+ const loadingChildIds = this._loadingChildIds();
595
+ return button.children.some(child => {
596
+ const showSpinner = child.showLoadingSpinner ?? true;
597
+ const isLoadingFromProp = child.loading && showSpinner;
598
+ const isLoadingFromExecution = loadingChildIds.has(child.id) && showSpinner;
599
+ return isLoadingFromProp || isLoadingFromExecution;
600
+ });
248
601
  }
249
602
  /**
250
- * Public method to focus the card element programmatically.
603
+ * Computes the combined loading state for a button.
604
+ *
605
+ * @param button - The button configuration
606
+ * @returns True if the button is in any loading state
251
607
  */
252
- focus() {
253
- this.cardElement()?.nativeElement?.focus();
608
+ isLoading(button) {
609
+ const parentLoading = this._isLoading() || (button.loading ?? false);
610
+ const childLoading = this.hasLoadingChild(button) && (button.showLoadingSpinner ?? true);
611
+ return parentLoading || childLoading;
254
612
  }
255
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TestCardComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
256
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TestCardComponent, isStandalone: true, selector: "maki-test-card", inputs: { item: { classPropertyName: "item", publicName: "item", isSignal: true, isRequired: true, transformFunction: null }, showActions: { classPropertyName: "showActions", publicName: "showActions", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick", editClick: "editClick", deleteClick: "deleteClick" }, viewQueries: [{ propertyName: "cardElement", first: true, predicate: ["cardElement"], descendants: true, isSignal: true }], ngImport: i0, template: `
257
- <ion-card #cardElement [class.inactive]="!item().isActive" (click)="onCardClick()">
258
- <ion-card-header>
259
- <ion-card-title>{{ item().title }}</ion-card-title>
260
- @if (formattedDate()) {
261
- <ion-card-subtitle>
262
- {{ formattedDate() }}
263
- @if (!item().isActive) {
264
- <span class="status-badge inactive">Inactive</span>
265
- }
266
- </ion-card-subtitle>
613
+ /**
614
+ * Determines whether to display the loading spinner.
615
+ *
616
+ * @param button - The button configuration
617
+ * @returns True if spinner should be shown
618
+ */
619
+ showLoadingSpinner(button) {
620
+ return this.isLoading(button) && (button.showLoadingSpinner ?? true);
621
+ }
622
+ /**
623
+ * Computes whether the button should be disabled.
624
+ *
625
+ * For dropdown buttons: Only disabled if config says so or if parent itself is loading
626
+ * (not disabled by inherited child loading - user can still open dropdown).
627
+ *
628
+ * For non-dropdown buttons: Disabled when loading or explicitly disabled in config.
629
+ *
630
+ * @param button - The button configuration
631
+ * @returns True if the button should be disabled
632
+ */
633
+ isDisabled(button) {
634
+ const configDisabled = button.config?.disabled ?? false;
635
+ if (button.type === ActionButtonType.Dropdown) {
636
+ const parentSelfLoading = this._isLoading() || (button.loading ?? false);
637
+ return configDisabled || parentSelfLoading;
267
638
  }
268
- </ion-card-header>
269
-
270
- @if (item().description) {
271
- <ion-card-content>
272
- <p>{{ item().description }}</p>
273
- </ion-card-content>
274
- }
275
-
276
- @if (showActions()) {
277
- <ion-card-content class="card-actions">
278
- <ion-button fill="outline" size="small" (click)="onEditClick($event)">
279
- <ion-icon slot="start" name="create-outline"></ion-icon>
280
- Edit
281
- </ion-button>
282
- <ion-button fill="outline" size="small" color="danger" (click)="onDeleteClick($event)">
283
- <ion-icon slot="start" name="trash-outline"></ion-icon>
284
- Delete
285
- </ion-button>
286
- </ion-card-content>
287
- }
288
- </ion-card>
289
- `, isInline: true, styles: [":host{display:block}ion-card{--background: var(--maki-card-background, var(--ion-card-background, #fff));--color: var(--maki-card-color, var(--ion-text-color));margin:var(--maki-card-margin, 16px);border-radius:var(--maki-card-border-radius, 12px);transition:opacity .2s ease,transform .2s ease;cursor:pointer}ion-card:hover{transform:translateY(-2px)}ion-card.inactive{opacity:.6}ion-card-title{color:var(--maki-primary, var(--ion-color-primary));font-weight:600}ion-card-subtitle{display:flex;align-items:center;gap:8px}.status-badge{font-size:.75rem;padding:2px 8px;border-radius:4px;font-weight:500}.status-badge.inactive{background-color:var(--ion-color-medium-tint);color:var(--ion-color-medium-contrast)}.card-actions{display:flex;gap:8px;padding-top:0}.card-actions ion-button{--border-color: var(--maki-primary, var(--ion-color-primary));--color: var(--maki-primary, var(--ion-color-primary))}\n"], dependencies: [{ kind: "component", type: IonCard, selector: "ion-card", inputs: ["button", "color", "disabled", "download", "href", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonCardHeader, selector: "ion-card-header", inputs: ["color", "mode", "translucent"] }, { kind: "component", type: IonCardTitle, selector: "ion-card-title", inputs: ["color", "mode"] }, { kind: "component", type: IonCardSubtitle, selector: "ion-card-subtitle", inputs: ["color", "mode"] }, { kind: "component", type: IonCardContent, selector: "ion-card-content", inputs: ["mode"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
639
+ return this.isLoading(button) || configDisabled;
640
+ }
641
+ /**
642
+ * Checks if a specific button is currently loading.
643
+ * Used primarily for checking child button loading state.
644
+ *
645
+ * @param button - The button to check
646
+ * @returns True if the button is loading
647
+ */
648
+ isButtonLoading(button) {
649
+ const loadingChildIds = this._loadingChildIds();
650
+ return (button.loading ?? false) || loadingChildIds.has(button.id);
651
+ }
652
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
653
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonStateService });
290
654
  }
291
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TestCardComponent, decorators: [{
292
- type: Component,
293
- args: [{ selector: 'maki-test-card', standalone: true, imports: [
294
- IonCard,
295
- IonCardHeader,
296
- IonCardTitle,
297
- IonCardSubtitle,
298
- IonCardContent,
299
- IonButton,
300
- IonIcon
301
- ], changeDetection: ChangeDetectionStrategy.OnPush, template: `
302
- <ion-card #cardElement [class.inactive]="!item().isActive" (click)="onCardClick()">
303
- <ion-card-header>
304
- <ion-card-title>{{ item().title }}</ion-card-title>
305
- @if (formattedDate()) {
306
- <ion-card-subtitle>
307
- {{ formattedDate() }}
308
- @if (!item().isActive) {
309
- <span class="status-badge inactive">Inactive</span>
310
- }
311
- </ion-card-subtitle>
312
- }
313
- </ion-card-header>
655
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonStateService, decorators: [{
656
+ type: Injectable
657
+ }] });
314
658
 
315
- @if (item().description) {
316
- <ion-card-content>
317
- <p>{{ item().description }}</p>
318
- </ion-card-content>
319
- }
659
+ /**
660
+ * @file Button Services Barrel Export
661
+ * @description Exports all button-related services for use across different UI implementations.
662
+ */
320
663
 
321
- @if (showActions()) {
322
- <ion-card-content class="card-actions">
323
- <ion-button fill="outline" size="small" (click)="onEditClick($event)">
324
- <ion-icon slot="start" name="create-outline"></ion-icon>
325
- Edit
326
- </ion-button>
327
- <ion-button fill="outline" size="small" color="danger" (click)="onDeleteClick($event)">
328
- <ion-icon slot="start" name="trash-outline"></ion-icon>
329
- Delete
330
- </ion-button>
331
- </ion-card-content>
332
- }
333
- </ion-card>
334
- `, styles: [":host{display:block}ion-card{--background: var(--maki-card-background, var(--ion-card-background, #fff));--color: var(--maki-card-color, var(--ion-text-color));margin:var(--maki-card-margin, 16px);border-radius:var(--maki-card-border-radius, 12px);transition:opacity .2s ease,transform .2s ease;cursor:pointer}ion-card:hover{transform:translateY(-2px)}ion-card.inactive{opacity:.6}ion-card-title{color:var(--maki-primary, var(--ion-color-primary));font-weight:600}ion-card-subtitle{display:flex;align-items:center;gap:8px}.status-badge{font-size:.75rem;padding:2px 8px;border-radius:4px;font-weight:500}.status-badge.inactive{background-color:var(--ion-color-medium-tint);color:var(--ion-color-medium-contrast)}.card-actions{display:flex;gap:8px;padding-top:0}.card-actions ion-button{--border-color: var(--maki-primary, var(--ion-color-primary));--color: var(--maki-primary, var(--ion-color-primary))}\n"] }]
335
- }], ctorParameters: () => [], propDecorators: { item: [{ type: i0.Input, args: [{ isSignal: true, alias: "item", required: true }] }], showActions: [{ type: i0.Input, args: [{ isSignal: true, alias: "showActions", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }], editClick: [{ type: i0.Output, args: ["editClick"] }], deleteClick: [{ type: i0.Output, args: ["deleteClick"] }], cardElement: [{ type: i0.ViewChild, args: ['cardElement', { isSignal: true }] }] } });
664
+ /**
665
+ * @file Services Barrel Export
666
+ * @description Exports all services from the ui-core library.
667
+ */
336
668
 
337
669
  /**
338
670
  * @file Action Button List Component
339
- * @description Component for rendering a list of action buttons inside a popover
671
+ * @description Ionic implementation levantando lista de action buttons en un popover.
672
+ *
673
+ * This component is the Ionic-specific implementation that uses the shared
674
+ * ActionButtonListService for all business logic. The component only handles:
675
+ * - Ionic template rendering (ion-list, ion-item, ion-icon, ion-spinner)
676
+ * - Ionic-specific popover dismissal via PopoverController
677
+ *
678
+ * All list logic, loading state checks, and button resolution is delegated
679
+ * to the injected service, making the logic reusable for other UI libraries.
340
680
  */
341
681
  /**
342
682
  * Component that renders a list of action buttons for use in popovers/dropdowns.
@@ -370,37 +710,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
370
710
  * - `buttonSelect`: Emits the selected `ActionButton` when clicked
371
711
  */
372
712
  class ActionButtonListComponent {
373
- /**
374
- * Reference to ActionButtonType.Dropdown for template comparison.
375
- * @internal
376
- */
377
- dropdownType = ActionButtonType.Dropdown;
378
- /**
379
- * Popover controller for dismissing the popover when an item is selected.
380
- */
713
+ /** Reference to ActionButtonType.Dropdown for template comparison. */
714
+ ActionButtonType = ActionButtonType;
715
+ /** Ionic PopoverController for dismissing the popover when an item is selected. */
381
716
  popoverCtrl = inject(PopoverController, { optional: true });
382
- /**
383
- * Internal signal to store buttons passed via componentProps.
384
- * PopoverController.create() sets properties directly, bypassing signal inputs.
385
- */
717
+ /** Service for list logic. */
718
+ listService = inject(ActionButtonListService);
719
+ /** Internal signal to store buttons passed via componentProps. */
386
720
  _buttonsFromProps = signal([], ...(ngDevMode ? [{ debugName: "_buttonsFromProps" }] : []));
387
- /**
388
- * Internal signal to store the Set of currently loading child button IDs.
389
- */
721
+ /** Internal signal to store the Set of currently loading child button IDs. */
390
722
  _loadingChildIdsFromProps = signal(null, ...(ngDevMode ? [{ debugName: "_loadingChildIdsFromProps" }] : []));
391
- /**
392
- * The list of action buttons to display (when used via template binding).
393
- */
723
+ /** The list of action buttons to display (when used via template binding). */
394
724
  buttons = input([], ...(ngDevMode ? [{ debugName: "buttons" }] : []));
395
- /**
396
- * Computed button list that works with both signal input and componentProps.
397
- * PopoverController passes buttons as a direct property, not through Angular's input system.
398
- */
399
- buttonList = () => {
400
- const fromProps = this._buttonsFromProps();
401
- const fromInput = this.buttons();
402
- return fromProps.length > 0 ? fromProps : fromInput;
403
- };
725
+ /** Emits when a button is selected from the list. */
726
+ buttonSelect = output();
727
+ /** Computed button list that works with both signal input and componentProps. */
728
+ buttonList = computed(() => this.listService.resolveButtonList(this._buttonsFromProps(), this.buttons()), ...(ngDevMode ? [{ debugName: "buttonList" }] : []));
404
729
  /**
405
730
  * Setter to capture buttons passed via PopoverController.create({ componentProps }).
406
731
  * This is called when Ionic sets the property directly on the component instance.
@@ -414,21 +739,15 @@ class ActionButtonListComponent {
414
739
  set loadingChildIds(value) {
415
740
  this._loadingChildIdsFromProps.set(value);
416
741
  }
417
- /**
418
- * Checks if a specific button is currently loading.
419
- */
420
- isButtonLoading(button) {
421
- const loadingChildIdsSignal = this._loadingChildIdsFromProps();
422
- const loadingChildIds = loadingChildIdsSignal ? loadingChildIdsSignal() : new Set();
423
- return button.loading || loadingChildIds.has(button.id);
742
+ /** Checks if a button can be selected. */
743
+ canSelectButton(button) {
744
+ const loadingChildIds = this._loadingChildIdsFromProps()?.() ?? new Set();
745
+ return this.listService.canSelectButton(button, loadingChildIds);
424
746
  }
425
- /**
426
- * Emits when a button is selected from the list.
427
- */
428
- buttonSelect = output();
429
- constructor() {
430
- // Register any icons that might be used
431
- addIcons({});
747
+ /** Checks if a button should show its loading spinner. */
748
+ shouldShowSpinner(button) {
749
+ const loadingChildIds = this._loadingChildIdsFromProps()?.() ?? new Set();
750
+ return this.listService.shouldShowSpinner(button, loadingChildIds);
432
751
  }
433
752
  /**
434
753
  * Handles click on a button item.
@@ -437,23 +756,24 @@ class ActionButtonListComponent {
437
756
  * @param button - The clicked action button
438
757
  */
439
758
  onButtonClick(button) {
440
- if (this.isButtonLoading(button) || button.config?.disabled) {
759
+ const loadingChildIds = this._loadingChildIdsFromProps()?.() ?? new Set();
760
+ if (!this.listService.canSelectButton(button, loadingChildIds)) {
441
761
  return;
442
762
  }
443
763
  this.buttonSelect.emit(button);
444
764
  this.popoverCtrl?.dismiss(button, 'select');
445
765
  }
446
766
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ActionButtonListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
447
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ActionButtonListComponent, isStandalone: true, selector: "maki-action-button-list", inputs: { buttons: { classPropertyName: "buttons", publicName: "buttons", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { buttonSelect: "buttonSelect" }, ngImport: i0, template: `
767
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ActionButtonListComponent, isStandalone: true, selector: "maki-action-button-list", inputs: { buttons: { classPropertyName: "buttons", publicName: "buttons", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { buttonSelect: "buttonSelect" }, providers: [ActionButtonListService], ngImport: i0, template: `
448
768
  <ion-list lines="none">
449
769
  @for (button of buttonList(); track button.id) {
450
770
  <ion-item
451
771
  [button]="true"
452
- [disabled]="isButtonLoading(button) || button.config?.disabled"
453
- [detail]="button.type === dropdownType"
772
+ [disabled]="!canSelectButton(button)"
773
+ [detail]="button.type === ActionButtonType.Dropdown"
454
774
  (click)="onButtonClick(button)"
455
775
  >
456
- @if (isButtonLoading(button) && (button.showLoadingSpinner ?? true)) {
776
+ @if (shouldShowSpinner(button)) {
457
777
  <ion-spinner slot="start" name="crescent" />
458
778
  } @else if (button.icon) {
459
779
  <ion-icon slot="start" [color]="button.config?.color" [name]="button.icon" />
@@ -472,16 +792,16 @@ class ActionButtonListComponent {
472
792
  }
473
793
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ActionButtonListComponent, decorators: [{
474
794
  type: Component,
475
- args: [{ selector: 'maki-action-button-list', standalone: true, imports: [IonList, IonItem, IonIcon, IonLabel, IonSpinner], changeDetection: ChangeDetectionStrategy.OnPush, template: `
795
+ args: [{ selector: 'maki-action-button-list', standalone: true, imports: [IonList, IonItem, IonIcon, IonLabel, IonSpinner], providers: [ActionButtonListService], changeDetection: ChangeDetectionStrategy.OnPush, template: `
476
796
  <ion-list lines="none">
477
797
  @for (button of buttonList(); track button.id) {
478
798
  <ion-item
479
799
  [button]="true"
480
- [disabled]="isButtonLoading(button) || button.config?.disabled"
481
- [detail]="button.type === dropdownType"
800
+ [disabled]="!canSelectButton(button)"
801
+ [detail]="button.type === ActionButtonType.Dropdown"
482
802
  (click)="onButtonClick(button)"
483
803
  >
484
- @if (isButtonLoading(button) && (button.showLoadingSpinner ?? true)) {
804
+ @if (shouldShowSpinner(button)) {
485
805
  <ion-spinner slot="start" name="crescent" />
486
806
  } @else if (button.icon) {
487
807
  <ion-icon slot="start" [color]="button.config?.color" [name]="button.icon" />
@@ -497,18 +817,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
497
817
  }
498
818
  </ion-list>
499
819
  `, styles: [":host{display:block}::ng-deep ion-popover::part(content){width:fit-content}ion-list{padding:0}ion-item{--padding-start: var(--maki-action-button-list-padding, 16px);--padding-end: var(--maki-action-button-list-padding, 16px);--min-height: var(--maki-action-button-list-item-height, 44px);cursor:pointer;width:100%}ion-item[disabled]{cursor:not-allowed}ion-label{white-space:nowrap}ion-icon{font-size:var(--maki-action-button-list-icon-size, 20px);margin-inline-end:var(--maki-action-button-list-icon-gap, 12px)}ion-spinner{width:var(--maki-action-button-list-icon-size, 20px);height:var(--maki-action-button-list-icon-size, 20px);margin-inline-end:var(--maki-action-button-list-icon-gap, 12px)}\n"] }]
500
- }], ctorParameters: () => [], propDecorators: { buttons: [{ type: i0.Input, args: [{ isSignal: true, alias: "buttons", required: false }] }], buttonSelect: [{ type: i0.Output, args: ["buttonSelect"] }] } });
820
+ }], propDecorators: { buttons: [{ type: i0.Input, args: [{ isSignal: true, alias: "buttons", required: false }] }], buttonSelect: [{ type: i0.Output, args: ["buttonSelect"] }] } });
501
821
 
502
822
  /**
503
823
  * @file Button Component
504
- * @description Configurable button component that renders ion-button based on ActionButton configuration
824
+ * @description Ionic implementation of a configurable button component.
825
+ *
826
+ * This component is the Ionic-specific implementation that uses the shared
827
+ * button services for all business logic. The component only handles:
828
+ * - Ionic template rendering (ion-button, ion-icon, ion-spinner)
829
+ * - Ionic-specific popover creation via PopoverController
830
+ *
831
+ * All state management, display logic, and handler execution is delegated
832
+ * to the injected services, making the logic reusable for other UI libraries.
505
833
  */
506
834
  /**
507
835
  * A configurable button component that renders an `ion-button` based on
508
836
  * an `ActionButton` configuration object.
509
837
  *
510
838
  * Features:
511
- * - Three display types: Default (label + icon), IconOnly, and Dropdown
839
+ * - Two button types: Default and Dropdown
840
+ * - Flexible display: icon-only, label-only, or icon+label (determined by properties)
512
841
  * - Automatic loading state management for async handlers
513
842
  * - Dropdown support via PopoverController with child actions
514
843
  * - Full Ionic button styling configuration (fill, size, color, shape, expand)
@@ -516,12 +845,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
516
845
  *
517
846
  * @example
518
847
  * ```html
519
- * <!-- Simple button -->
848
+ * <!-- Button with label and icon -->
520
849
  * <maki-button [button]="saveButton" />
521
850
  *
522
- * <!-- Icon-only button -->
851
+ * <!-- Icon-only button (no label) -->
523
852
  * <maki-button [button]="iconButton" />
524
853
  *
854
+ * <!-- Label-only button (no icon) -->
855
+ * <maki-button [button]="textButton" />
856
+ *
525
857
  * <!-- Dropdown button -->
526
858
  * <maki-button [button]="menuButton" />
527
859
  * ```
@@ -532,7 +864,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
532
864
  * saveButton: ActionButton = {
533
865
  * id: 'save',
534
866
  * label: 'Save',
535
- * icon: 'saveOutline',
867
+ * icon: 'save-outline',
536
868
  * type: ActionButtonType.Default,
537
869
  * config: { fill: 'solid', color: 'primary' },
538
870
  * handler: async () => {
@@ -546,8 +878,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
546
878
  * type: ActionButtonType.Dropdown,
547
879
  * handler: () => {},
548
880
  * children: [
549
- * { id: 'edit', label: 'Edit', icon: 'createOutline', type: ActionButtonType.Default, handler: () => this.edit() },
550
- * { id: 'delete', label: 'Delete', icon: 'trashOutline', type: ActionButtonType.Default, handler: () => this.delete() }
881
+ * { id: 'edit', label: 'Edit', icon: 'create-outline', type: ActionButtonType.Default, handler: () => this.edit() },
882
+ * { id: 'delete', label: 'Delete', icon: 'trash-outline', type: ActionButtonType.Default, handler: () => this.delete() }
551
883
  * ]
552
884
  * };
553
885
  * ```
@@ -571,110 +903,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
571
903
  * When a child is selected, its handler is executed and `childSelect` is emitted.
572
904
  */
573
905
  class ButtonComponent {
574
- /**
575
- * Popover controller for creating dropdown popovers.
576
- */
906
+ /** Ionic PopoverController for dropdown popovers. */
577
907
  popoverCtrl = inject(PopoverController);
578
- /**
579
- * Internal loading state for async handler execution.
580
- */
581
- _isLoading = signal(false, ...(ngDevMode ? [{ debugName: "_isLoading" }] : []));
582
- /**
583
- * Set of child button IDs that are currently loading (for dropdowns).
584
- * Tracks multiple concurrent loading operations.
585
- */
586
- _loadingChildIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "_loadingChildIds" }] : []));
587
- /**
588
- * The action button configuration.
589
- */
908
+ /** Service for button state management. */
909
+ stateService = inject(ButtonStateService);
910
+ /** Service for button display logic. */
911
+ displayService = inject(ButtonDisplayService);
912
+ /** Service for handler execution. */
913
+ handlerService = inject(ButtonHandlerService);
914
+ /** The action button configuration. */
590
915
  button = input.required(...(ngDevMode ? [{ debugName: "button" }] : []));
591
- /**
592
- * Emits when the button is clicked (for non-dropdown buttons).
593
- * Emits the button configuration that was clicked.
594
- */
916
+ /** Emits when the button is clicked (for non-dropdown buttons). */
595
917
  buttonClick = output();
596
- /**
597
- * Emits when a child button is selected from a dropdown.
598
- * Only emits for `ActionButtonType.Dropdown` buttons.
599
- */
918
+ /** Emits when a child button is selected from a dropdown. */
600
919
  childSelect = output();
601
- /**
602
- * Checks if any child button is in a loading state and has showLoadingSpinner enabled.
603
- */
604
- hasLoadingChild = computed(() => {
605
- const btn = this.button();
606
- if (btn.type !== ActionButtonType.Dropdown || !btn.children) {
607
- return false;
608
- }
609
- const loadingChildIds = this._loadingChildIds();
610
- return btn.children.some(child => {
611
- const isLoadingFromProp = child.loading && (child.showLoadingSpinner ?? true);
612
- const isLoadingFromExecution = loadingChildIds.has(child.id) && (child.showLoadingSpinner ?? true);
613
- return isLoadingFromProp || isLoadingFromExecution;
614
- });
615
- }, ...(ngDevMode ? [{ debugName: "hasLoadingChild" }] : []));
616
- /**
617
- * Combined loading state from external prop, internal async state, and child loading.
618
- * Returns true if:
619
- * - Internal loading state is active, OR
620
- * - External loading prop is true, OR
621
- * - Any child is loading AND parent has showLoadingSpinner !== false
622
- */
623
- isLoading = computed(() => {
624
- const btn = this.button();
625
- const parentLoading = this._isLoading() || (btn.loading ?? false);
626
- const childLoading = this.hasLoadingChild() && (btn.showLoadingSpinner ?? true);
627
- return parentLoading || childLoading;
628
- }, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
629
- /**
630
- * Whether to display the loading spinner.
631
- * Shows spinner only if button is loading AND showLoadingSpinner is not false.
632
- */
633
- showLoadingSpinner = computed(() => {
634
- return this.isLoading() && (this.button().showLoadingSpinner ?? true);
635
- }, ...(ngDevMode ? [{ debugName: "showLoadingSpinner" }] : []));
636
- /**
637
- * Whether the button is disabled.
638
- * - Non-dropdown buttons: Disabled when loading or explicitly disabled in config
639
- * - Dropdown buttons: Only disabled when explicitly disabled in config or when parent itself is loading
640
- * (not disabled by inherited child loading - user can still open dropdown to see loading children)
641
- */
642
- isDisabled = computed(() => {
643
- const btn = this.button();
644
- const configDisabled = btn.config?.disabled ?? false;
645
- // For dropdown buttons, only disable if config says so or if parent itself is loading
646
- // Don't disable for inherited child loading (user should be able to open dropdown)
647
- if (btn.type === ActionButtonType.Dropdown) {
648
- const parentSelfLoading = this._isLoading() || (btn.loading ?? false);
649
- return configDisabled || parentSelfLoading;
650
- }
651
- // For non-dropdown buttons, disable when loading
652
- return this.isLoading() || configDisabled;
653
- }, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
654
- /**
655
- * Determines the slot for the icon based on button type and label presence.
656
- */
657
- iconSlot = computed(() => {
658
- const btn = this.button();
659
- if (btn.type === ActionButtonType.IconOnly) {
660
- return 'icon-only';
661
- }
662
- return btn.label ? 'start' : 'icon-only';
663
- }, ...(ngDevMode ? [{ debugName: "iconSlot" }] : []));
664
- /**
665
- * Whether to show the label text.
666
- */
667
- showLabel = computed(() => {
668
- const btn = this.button();
669
- return btn.type !== ActionButtonType.IconOnly && btn.label;
670
- }, ...(ngDevMode ? [{ debugName: "showLabel" }] : []));
671
- /**
672
- * Whether to show the dropdown chevron icon.
673
- */
674
- showDropdownIcon = computed(() => {
675
- const btn = this.button();
676
- return btn.type === ActionButtonType.Dropdown && !btn.config?.hideDropdownIcon;
677
- }, ...(ngDevMode ? [{ debugName: "showDropdownIcon" }] : []));
920
+ /** Whether the button is in a loading state. */
921
+ isLoading = computed(() => this.stateService.isLoading(this.button()), ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
922
+ /** Whether to display the loading spinner. */
923
+ showLoadingSpinner = computed(() => this.stateService.showLoadingSpinner(this.button()), ...(ngDevMode ? [{ debugName: "showLoadingSpinner" }] : []));
924
+ /** Whether the button is disabled. */
925
+ isDisabled = computed(() => this.stateService.isDisabled(this.button()), ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
926
+ /** The slot for the icon based on label presence (Ionic-specific). */
927
+ iconSlot = computed(() => (this.button().label ? 'start' : 'icon-only'), ...(ngDevMode ? [{ debugName: "iconSlot" }] : []));
928
+ /** Whether to show the label text. */
929
+ showLabel = computed(() => this.displayService.shouldShowLabel(this.button()), ...(ngDevMode ? [{ debugName: "showLabel" }] : []));
930
+ /** Whether to show the dropdown chevron icon. */
931
+ showDropdownIcon = computed(() => this.displayService.shouldShowDropdownIcon(this.button()), ...(ngDevMode ? [{ debugName: "showDropdownIcon" }] : []));
678
932
  constructor() {
679
933
  addIcons({ chevronDownOutline });
680
934
  }
@@ -695,7 +949,7 @@ class ButtonComponent {
695
949
  await this.openDropdown(event);
696
950
  }
697
951
  else {
698
- await this.executeHandler(btn);
952
+ await this.handlerService.executeHandler(btn, loading => this.stateService.setLoading(loading));
699
953
  this.buttonClick.emit(btn);
700
954
  }
701
955
  }
@@ -715,7 +969,7 @@ class ButtonComponent {
715
969
  component: ActionButtonListComponent,
716
970
  componentProps: {
717
971
  buttonsFromPopover: children,
718
- loadingChildIds: this._loadingChildIds
972
+ loadingChildIds: this.stateService.loadingChildIds
719
973
  },
720
974
  event,
721
975
  translucent: true,
@@ -727,44 +981,19 @@ class ButtonComponent {
727
981
  await popover.present();
728
982
  const { data, role } = await popover.onDidDismiss();
729
983
  if (role === 'select' && data) {
730
- // Add child to loading set and execute its handler
731
- this._loadingChildIds.update(ids => new Set([...ids, data.id]));
732
- try {
733
- const result = data.handler();
734
- if (result instanceof Promise) {
735
- await result;
984
+ await this.handlerService.executeChildHandler(data, (childId, loading) => {
985
+ if (loading) {
986
+ this.stateService.addLoadingChild(childId);
736
987
  }
737
- }
738
- finally {
739
- // Remove child from loading set
740
- this._loadingChildIds.update(ids => {
741
- const newIds = new Set(ids);
742
- newIds.delete(data.id);
743
- return newIds;
744
- });
745
- }
988
+ else {
989
+ this.stateService.removeLoadingChild(childId);
990
+ }
991
+ });
746
992
  this.childSelect.emit(data);
747
993
  }
748
994
  }
749
- /**
750
- * Executes a button's handler with automatic loading state management.
751
- *
752
- * @param btn - The button whose handler to execute
753
- */
754
- async executeHandler(btn) {
755
- const result = btn.handler();
756
- if (result instanceof Promise) {
757
- this._isLoading.set(true);
758
- try {
759
- await result;
760
- }
761
- finally {
762
- this._isLoading.set(false);
763
- }
764
- }
765
- }
766
995
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
767
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ButtonComponent, isStandalone: true, selector: "maki-button", inputs: { button: { classPropertyName: "button", publicName: "button", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { buttonClick: "buttonClick", childSelect: "childSelect" }, ngImport: i0, template: `
996
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ButtonComponent, isStandalone: true, selector: "maki-button", inputs: { button: { classPropertyName: "button", publicName: "button", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { buttonClick: "buttonClick", childSelect: "childSelect" }, providers: [ButtonStateService, ButtonDisplayService, ButtonHandlerService], ngImport: i0, template: `
768
997
  <ion-button
769
998
  [fill]="button().config?.fill"
770
999
  [size]="button().config?.size"
@@ -789,11 +1018,11 @@ class ButtonComponent {
789
1018
  <ion-icon name="chevron-down-outline" slot="end" class="dropdown-icon" />
790
1019
  }
791
1020
  </ion-button>
792
- `, isInline: true, styles: [":host{display:inline-block}ion-button{--padding-start: var(--maki-button-padding-start);--padding-end: var(--maki-button-padding-end);text-transform:none}ion-button.button-has-icon-only::part(native){padding:0}ion-spinner{width:var(--maki-button-spinner-size, 16px);height:var(--maki-button-spinner-size, 16px);margin:0 4px 0 0}.button-icon{font-size:var(--maki-button-icon-size, 16px);margin:0 4px 0 0}.button-icon[slot=icon-only]{margin:0}.dropdown-icon{font-size:var(--maki-button-dropdown-icon-size, 16px);margin-inline-start:var(--maki-button-dropdown-icon-gap, 4px)}\n"], dependencies: [{ kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1021
+ `, isInline: true, styles: ["ion-button{--padding-start: var(--maki-button-padding-start);--padding-end: var(--maki-button-padding-end);text-transform:none}ion-button.button-has-icon-only::part(native){padding:0}ion-spinner{width:var(--maki-button-spinner-size, 16px);height:var(--maki-button-spinner-size, 16px);margin:0 4px 0 0}.button-icon{font-size:var(--maki-button-icon-size, 16px);margin:0 4px 0 0}.button-icon[slot=icon-only]{margin:0}.dropdown-icon{font-size:var(--maki-button-dropdown-icon-size, 16px);margin-inline-start:var(--maki-button-dropdown-icon-gap, 4px)}\n"], dependencies: [{ kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
793
1022
  }
794
1023
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ButtonComponent, decorators: [{
795
1024
  type: Component,
796
- args: [{ selector: 'maki-button', standalone: true, imports: [IonButton, IonIcon, IonSpinner], changeDetection: ChangeDetectionStrategy.OnPush, template: `
1025
+ args: [{ selector: 'maki-button', standalone: true, imports: [IonButton, IonIcon, IonSpinner], providers: [ButtonStateService, ButtonDisplayService, ButtonHandlerService], changeDetection: ChangeDetectionStrategy.OnPush, template: `
797
1026
  <ion-button
798
1027
  [fill]="button().config?.fill"
799
1028
  [size]="button().config?.size"
@@ -818,17 +1047,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
818
1047
  <ion-icon name="chevron-down-outline" slot="end" class="dropdown-icon" />
819
1048
  }
820
1049
  </ion-button>
821
- `, styles: [":host{display:inline-block}ion-button{--padding-start: var(--maki-button-padding-start);--padding-end: var(--maki-button-padding-end);text-transform:none}ion-button.button-has-icon-only::part(native){padding:0}ion-spinner{width:var(--maki-button-spinner-size, 16px);height:var(--maki-button-spinner-size, 16px);margin:0 4px 0 0}.button-icon{font-size:var(--maki-button-icon-size, 16px);margin:0 4px 0 0}.button-icon[slot=icon-only]{margin:0}.dropdown-icon{font-size:var(--maki-button-dropdown-icon-size, 16px);margin-inline-start:var(--maki-button-dropdown-icon-gap, 4px)}\n"] }]
1050
+ `, styles: ["ion-button{--padding-start: var(--maki-button-padding-start);--padding-end: var(--maki-button-padding-end);text-transform:none}ion-button.button-has-icon-only::part(native){padding:0}ion-spinner{width:var(--maki-button-spinner-size, 16px);height:var(--maki-button-spinner-size, 16px);margin:0 4px 0 0}.button-icon{font-size:var(--maki-button-icon-size, 16px);margin:0 4px 0 0}.button-icon[slot=icon-only]{margin:0}.dropdown-icon{font-size:var(--maki-button-dropdown-icon-size, 16px);margin-inline-start:var(--maki-button-dropdown-icon-gap, 4px)}\n"] }]
822
1051
  }], ctorParameters: () => [], propDecorators: { button: [{ type: i0.Input, args: [{ isSignal: true, alias: "button", required: true }] }], buttonClick: [{ type: i0.Output, args: ["buttonClick"] }], childSelect: [{ type: i0.Output, args: ["childSelect"] }] } });
823
1052
 
824
1053
  /*
825
1054
  * Public API Surface of @makigamestudio/ui-core
826
1055
  */
827
- // Models
828
1056
 
829
1057
  /**
830
1058
  * Generated bundle index. Do not edit.
831
1059
  */
832
1060
 
833
- export { ActionButtonListComponent, ActionButtonType, ButtonComponent, TestCardComponent, TestClass, TestService };
1061
+ export { ActionButtonListComponent, ActionButtonListService, ActionButtonType, ButtonComponent, ButtonDisplayService, ButtonHandlerService, ButtonStateService };
834
1062
  //# sourceMappingURL=makigamestudio-ui-core.mjs.map