@mimi-ng/cli 0.1.1 → 0.3.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.
Files changed (104) hide show
  1. package/README.md +28 -1
  2. package/dist/init/css.js +17 -0
  3. package/dist/init/index.js +7 -5
  4. package/dist/init/items.js +9 -0
  5. package/dist/registry.json +482 -1
  6. package/dist/templates/components/avatar/avatar.ts +1 -1
  7. package/dist/templates/components/avatar/avatar.variants.ts +4 -4
  8. package/dist/templates/components/badge/badge.ts +41 -6
  9. package/dist/templates/components/badge/badge.variants.ts +105 -11
  10. package/dist/templates/components/button/button.ts +74 -7
  11. package/dist/templates/components/button/button.variants.ts +145 -21
  12. package/dist/templates/components/calendar/calendar-texts.ts +72 -0
  13. package/dist/templates/components/calendar/calendar.ts +588 -0
  14. package/dist/templates/components/calendar/dates.ts +181 -0
  15. package/dist/templates/components/calendar/index.ts +3 -0
  16. package/dist/templates/components/card/card.ts +8 -5
  17. package/dist/templates/components/checkbox/checkbox.ts +3 -3
  18. package/dist/templates/components/chip/chip-texts.ts +40 -0
  19. package/dist/templates/components/chip/chip.ts +116 -0
  20. package/dist/templates/components/chip/index.ts +2 -0
  21. package/dist/templates/components/color-picker/color-picker-texts.ts +57 -0
  22. package/dist/templates/components/color-picker/color-picker.ts +718 -0
  23. package/dist/templates/components/color-picker/color.ts +157 -0
  24. package/dist/templates/components/color-picker/index.ts +3 -0
  25. package/dist/templates/components/combobox/combobox-texts.ts +70 -0
  26. package/dist/templates/components/combobox/combobox.ts +798 -0
  27. package/dist/templates/components/combobox/index.ts +2 -0
  28. package/dist/templates/components/confirm/confirm-texts.ts +46 -0
  29. package/dist/templates/components/confirm/confirm.ts +190 -0
  30. package/dist/templates/components/confirm/index.ts +2 -0
  31. package/dist/templates/components/context-menu/context-menu.ts +378 -0
  32. package/dist/templates/components/context-menu/index.ts +14 -0
  33. package/dist/templates/components/date-picker/date-picker.ts +240 -0
  34. package/dist/templates/components/date-picker/index.ts +3 -0
  35. package/dist/templates/components/date-range/date-range.ts +308 -0
  36. package/dist/templates/components/date-range/index.ts +1 -0
  37. package/dist/templates/components/dialog/dialog-texts.ts +41 -0
  38. package/dist/templates/components/dialog/dialog.ts +701 -0
  39. package/dist/templates/components/dialog/index.ts +18 -0
  40. package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
  41. package/dist/templates/components/dropdown-menu/index.ts +14 -0
  42. package/dist/templates/components/file-upload/file-upload-texts.ts +77 -0
  43. package/dist/templates/components/file-upload/file-upload.ts +698 -0
  44. package/dist/templates/components/file-upload/index.ts +2 -0
  45. package/dist/templates/components/form-field/form-error.ts +1 -1
  46. package/dist/templates/components/form-field/form-field.ts +3 -2
  47. package/dist/templates/components/input/input.variants.ts +6 -4
  48. package/dist/templates/components/input-mask/index.ts +1 -0
  49. package/dist/templates/components/input-mask/input-mask.ts +319 -0
  50. package/dist/templates/components/input-otp/index.ts +1 -0
  51. package/dist/templates/components/input-otp/input-otp.ts +212 -0
  52. package/dist/templates/components/menu/index.ts +15 -0
  53. package/dist/templates/components/menu/menu.ts +656 -0
  54. package/dist/templates/components/number-input/index.ts +2 -0
  55. package/dist/templates/components/number-input/number-input-texts.ts +46 -0
  56. package/dist/templates/components/number-input/number-input.ts +450 -0
  57. package/dist/templates/components/option-list/index.ts +11 -0
  58. package/dist/templates/components/option-list/option-list.ts +518 -0
  59. package/dist/templates/components/pagination/index.ts +2 -0
  60. package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
  61. package/dist/templates/components/pagination/pagination-texts.ts +69 -0
  62. package/dist/templates/components/pagination/pagination.ts +443 -0
  63. package/dist/templates/components/password/index.ts +2 -0
  64. package/dist/templates/components/password/password-texts.ts +68 -0
  65. package/dist/templates/components/password/password.ts +363 -0
  66. package/dist/templates/components/popover/index.ts +12 -0
  67. package/dist/templates/components/popover/popover.ts +407 -0
  68. package/dist/templates/components/progress/index.ts +1 -0
  69. package/dist/templates/components/progress/progress.ts +217 -0
  70. package/dist/templates/components/select/index.ts +12 -0
  71. package/dist/templates/components/select/select-texts.ts +62 -0
  72. package/dist/templates/components/select/select.ts +1086 -0
  73. package/dist/templates/components/separator/separator.variants.ts +10 -7
  74. package/dist/templates/components/skeleton/skeleton.ts +4 -1
  75. package/dist/templates/components/slider/index.ts +2 -0
  76. package/dist/templates/components/slider/slider-texts.ts +26 -0
  77. package/dist/templates/components/slider/slider.ts +425 -0
  78. package/dist/templates/components/switch/switch.ts +6 -6
  79. package/dist/templates/components/tabs/index.ts +2 -0
  80. package/dist/templates/components/tabs/tabs.ts +426 -0
  81. package/dist/templates/components/tags-input/index.ts +2 -0
  82. package/dist/templates/components/tags-input/tags-input-texts.ts +44 -0
  83. package/dist/templates/components/tags-input/tags-input.ts +303 -0
  84. package/dist/templates/components/textarea/textarea.variants.ts +14 -11
  85. package/dist/templates/components/toast/index.ts +3 -0
  86. package/dist/templates/components/toast/toast-config.ts +45 -0
  87. package/dist/templates/components/toast/toast-texts.ts +46 -0
  88. package/dist/templates/components/toast/toast.ts +1000 -0
  89. package/dist/templates/components/toggle-group/index.ts +1 -0
  90. package/dist/templates/components/toggle-group/toggle-group.ts +406 -0
  91. package/dist/templates/components/toolbar/index.ts +1 -0
  92. package/dist/templates/components/toolbar/toolbar.ts +377 -0
  93. package/dist/templates/components/tooltip/index.ts +7 -0
  94. package/dist/templates/components/tooltip/tooltip.ts +407 -0
  95. package/dist/templates/theme/provider.ts +75 -6
  96. package/dist/templates/theme/theme-base.css +410 -9
  97. package/dist/templates/theme/types.ts +494 -4
  98. package/dist/templates/utils/cn.ts +15 -1
  99. package/dist/templates/utils/control-styles.ts +2 -2
  100. package/dist/templates/utils/overlay.ts +153 -0
  101. package/dist/ui/index.js +184 -8
  102. package/dist/ui/schema.json +100 -0
  103. package/dist/utils/write-files.js +166 -59
  104. package/package.json +1 -1
@@ -0,0 +1,701 @@
1
+ import { Dialog, type DialogRef } from '@angular/cdk/dialog';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ DOCUMENT,
6
+ DestroyRef,
7
+ Directive,
8
+ ElementRef,
9
+ Injectable,
10
+ InjectionToken,
11
+ Injector,
12
+ TemplateRef,
13
+ type Type,
14
+ ViewContainerRef,
15
+ afterNextRender,
16
+ booleanAttribute,
17
+ computed,
18
+ contentChild,
19
+ effect,
20
+ inject,
21
+ input,
22
+ isDevMode,
23
+ model,
24
+ output,
25
+ signal,
26
+ untracked,
27
+ viewChild,
28
+ } from '@angular/core';
29
+ import { type Observable, Subject } from 'rxjs';
30
+ import { cn } from '@/components/ui/utils/cn';
31
+ import { buttonFocusStyles } from '@/components/ui/utils/control-styles';
32
+ import { MIMI_OVERLAY_ANIMATION, mimiWaitForExit } from '@/components/ui/utils/overlay';
33
+ import { MIMI_DIALOG_TEXT } from './dialog-texts';
34
+
35
+ /*
36
+ * Dialog (docs/design/Mimi F2 Overlays.dc.html, «1 · Dialog»; prefijo de tokens `dialog`, spec
37
+ * 13): panel centrado de 420px sobre un fondo oscurecido y desenfocado, con la X arriba a la
38
+ * derecha (32px, a 12px del borde), encabezado, cuerpo y pie con 24px a los lados.
39
+ *
40
+ * Por dentro usa @angular/cdk/dialog (decisión 4.3): el CDK da el rol, el encierro del foco, el
41
+ * foco de vuelta, el `aria-hidden` del resto de la página y el bloqueo del scroll. Mimi pone el
42
+ * panel, la X, la animación y el cierre (al CDK se le pasa `disableClose`, para animar la salida
43
+ * antes de cerrar de verdad). Nada del CDK sale en la API pública.
44
+ */
45
+
46
+ /** Opciones de un diálogo. */
47
+ export interface MimiDialogConfig<D = unknown> {
48
+ /** Datos para el contenido: `inject(MIMI_DIALOG_DATA)`. */
49
+ data?: D;
50
+ /** Ancho del panel (un valor CSS). Por defecto, `--mimi-dialog-width` (420px). */
51
+ width?: string;
52
+ /** Ancho máximo del panel (un valor CSS). Nunca supera el ancho de la pantalla menos 32px. */
53
+ maxWidth?: string;
54
+ /** Clases del panel, mezcladas con `cn()` (gana la del usuario). */
55
+ class?: string;
56
+ /** Escape cierra. Por defecto, `true`. */
57
+ closeOnEscape?: boolean;
58
+ /** Un clic en el fondo cierra. Por defecto, `true`. */
59
+ closeOnBackdropClick?: boolean;
60
+ /** Atajo: ni Escape ni el clic en el fondo cierran. La X sigue funcionando. */
61
+ disableClose?: boolean;
62
+ /**
63
+ * La X se ve siempre; ocúltala solo en diálogos de confirmación que tienen sus propios botones
64
+ * de salida. Por defecto, `true`.
65
+ */
66
+ closeButton?: boolean;
67
+ /** `alertdialog` para una confirmación que interrumpe. Por defecto, `dialog`. */
68
+ role?: 'dialog' | 'alertdialog';
69
+ /** Nombre accesible, si el contenido no tiene un `mimiDialogTitle`. */
70
+ ariaLabel?: string;
71
+ /**
72
+ * A dónde va el foco al abrir: `'first'` (el primer elemento enfocable; por defecto),
73
+ * `'dialog'` (el panel) o un selector CSS.
74
+ */
75
+ autoFocus?: 'first' | 'dialog' | (string & {});
76
+ /** Injector para el contenido (por defecto, el de la aplicación). */
77
+ injector?: Injector;
78
+ }
79
+
80
+ /** Datos que se pasaron en `open(…, { data })`. */
81
+ export const MIMI_DIALOG_DATA = new InjectionToken<unknown>('MIMI_DIALOG_DATA');
82
+
83
+ /** Uso interno: lo que el panel necesita para pintarse. */
84
+ interface MimiDialogShellOptions {
85
+ content: Type<unknown> | TemplateRef<unknown>;
86
+ config: MimiDialogConfig;
87
+ }
88
+
89
+ const MIMI_DIALOG_SHELL_OPTIONS = new InjectionToken<MimiDialogShellOptions>(
90
+ 'MIMI_DIALOG_SHELL_OPTIONS',
91
+ );
92
+
93
+ /**
94
+ * Referencia a un diálogo abierto. La devuelve `MimiDialogService.open()` y el contenido la
95
+ * recibe con `inject(MimiDialogRef)`.
96
+ */
97
+ export class MimiDialogRef<R = unknown> {
98
+ /** Uso interno: ids registrados por `mimiDialogTitle` y `mimiDialogDescription`. */
99
+ readonly titleId = signal<string | null>(null);
100
+ readonly descriptionId = signal<string | null>(null);
101
+ /** Uso interno: «closed» mientras dura la animación de salida. */
102
+ readonly state = signal<'open' | 'closed'>('open');
103
+
104
+ private cdkRef: DialogRef<R> | null = null;
105
+ private result: R | undefined;
106
+ private readonly closed = new Subject<R | undefined>();
107
+ private onClosing: (() => void) | null = null;
108
+
109
+ /** Cierra el diálogo, con un resultado opcional para `afterClosed()`. */
110
+ close(result?: R): void {
111
+ if (this.state() === 'closed') return;
112
+ this.result = result;
113
+ this.state.set('closed');
114
+ this.onClosing?.();
115
+ // El fondo se desvanece junto con el panel; el diálogo se cierra al terminar la animación.
116
+ // Si todavía no se abrió, ya no se abre (el servicio lo descarta).
117
+ this.cdkRef?.overlayRef.detachBackdrop();
118
+ }
119
+
120
+ /**
121
+ * Emite una vez, con el resultado, cuando el diálogo ya se cerró (después de la animación de
122
+ * salida, o enseguida si lo cerró una navegación).
123
+ */
124
+ afterClosed(): Observable<R | undefined> {
125
+ return this.closed.asObservable();
126
+ }
127
+
128
+ /** Uso interno: lo conecta el servicio al abrir. `onClosing` corre al empezar a cerrarse. */
129
+ connect(cdkRef: DialogRef<R>, onClosing: () => void): void {
130
+ this.cdkRef = cdkRef;
131
+ this.onClosing = onClosing;
132
+ cdkRef.closed.subscribe((result) => this.emit(result));
133
+ }
134
+
135
+ /** Uso interno: cierra de verdad (terminó la animación de salida, o no hay que esperarla). */
136
+ finish(): void {
137
+ this.state.set('closed');
138
+ this.cdkRef?.close(this.result);
139
+ }
140
+
141
+ /** Uso interno: se cerró antes de abrirse. */
142
+ discard(): void {
143
+ this.state.set('closed');
144
+ this.emit(this.result);
145
+ }
146
+
147
+ private emit(result: R | undefined): void {
148
+ this.closed.next(result);
149
+ this.closed.complete();
150
+ }
151
+ }
152
+
153
+ let nextId = 0;
154
+
155
+ const PANEL_CLASSES = cn(
156
+ 'group/dialog relative flex max-h-[calc(100dvh_-_32px)] w-[var(--mimi-dialog-width,420px)] max-w-[calc(100vw_-_32px)] flex-col overflow-y-auto font-sans outline-none',
157
+ 'rounded-[var(--mimi-dialog-radius,var(--mimi-radius-card))] border border-border',
158
+ 'bg-[color:var(--mimi-dialog-bg,var(--mimi-popover))] text-popover-foreground',
159
+ 'shadow-[shadow:var(--mimi-dialog-shadow,var(--mimi-shadow-popover))]',
160
+ );
161
+
162
+ const CLOSE_CLASSES = cn(
163
+ 'absolute top-3 right-3 inline-flex size-[var(--mimi-dialog-close-size,32px)] cursor-pointer items-center justify-center',
164
+ 'rounded-[var(--mimi-radius-sm)] bg-transparent text-muted-foreground',
165
+ 'mimi-transition hover:bg-accent hover:text-foreground active:scale-90',
166
+ buttonFocusStyles,
167
+ );
168
+
169
+ /**
170
+ * Fondo: color y desenfoque de la spec 13. La opacidad la anima el CDK (overlay-prebuilt.css, que
171
+ * no está en una capa: por eso el `!`); dura lo mismo que la animación del panel.
172
+ */
173
+ const BACKDROP_CLASSES = [
174
+ 'bg-[color:var(--mimi-dialog-overlay,var(--mimi-overlay))]',
175
+ 'backdrop-blur-[var(--mimi-dialog-overlay-blur,var(--mimi-overlay-blur))]',
176
+ 'duration-150!',
177
+ 'motion-reduce:duration-[1ms]!',
178
+ ];
179
+
180
+ /** El contenedor del CDK recibe el foco con `autoFocus: 'dialog'`: sin contorno del navegador. */
181
+ const PANE_CLASSES = ['[&>.cdk-dialog-container]:outline-none'];
182
+
183
+ /**
184
+ * Uso interno (se exporta porque el compilador lo exige): crea el contenido del usuario dentro
185
+ * del panel. Hace lo mismo que NgComponentOutlet y NgTemplateOutlet, sin @angular/common.
186
+ * @internal No está en el índice; no forma parte de la API de Mimi.
187
+ */
188
+ @Directive({ selector: '[mimiDialogOutlet]' })
189
+ export class MimiDialogOutlet {
190
+ readonly content = input.required<Type<unknown> | TemplateRef<unknown>>({
191
+ alias: 'mimiDialogOutlet',
192
+ });
193
+ private readonly viewContainer = inject(ViewContainerRef);
194
+ private readonly injector = inject(Injector);
195
+ private readonly data = inject(MIMI_DIALOG_DATA, { optional: true });
196
+
197
+ private readonly render = effect(() => {
198
+ const content = this.content();
199
+ untracked(() => {
200
+ this.viewContainer.clear();
201
+ if (content instanceof TemplateRef) {
202
+ // El injector deja que las piezas (título, cerrar…) encuentren su MimiDialogRef.
203
+ this.viewContainer.createEmbeddedView(
204
+ content,
205
+ { $implicit: this.data },
206
+ { injector: this.injector },
207
+ );
208
+ return;
209
+ }
210
+ const host = this.viewContainer.createComponent(content, { injector: this.injector }).location
211
+ .nativeElement as HTMLElement;
212
+ // El host del componente no dibuja caja: así el encabezado, el cuerpo y el pie son hijos
213
+ // directos del panel (la columna y el scroll del cuerpo dependen de eso).
214
+ host.style.display = 'contents';
215
+ });
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Uso interno (se exporta porque el compilador lo exige): el panel del diálogo, con la X y el
221
+ * contenido del usuario. Es lo que Mimi le abre al `Dialog` del CDK.
222
+ * @internal No está en el índice; no forma parte de la API de Mimi.
223
+ */
224
+ @Component({
225
+ selector: 'mimi-dialog-shell',
226
+ imports: [MimiDialogOutlet],
227
+ template: `
228
+ <div
229
+ #panel
230
+ data-mimi-overlay-panel
231
+ [class]="classes"
232
+ [style.width]="options.config.width ?? null"
233
+ [style.max-width]="maxWidth"
234
+ [attr.data-state]="ref.state()"
235
+ [attr.data-close-button]="closeButton ? '' : null"
236
+ [attr.inert]="ref.state() === 'closed' ? '' : null"
237
+ >
238
+ <ng-container [mimiDialogOutlet]="options.content" />
239
+ <!-- Después del contenido: así el primer elemento enfocable es del contenido, no la X. -->
240
+ @if (closeButton) {
241
+ <button
242
+ type="button"
243
+ data-slot="close"
244
+ [class]="closeClasses"
245
+ [attr.aria-label]="text.close"
246
+ (click)="ref.close()"
247
+ >
248
+ <svg
249
+ aria-hidden="true"
250
+ class="size-[18px]"
251
+ viewBox="0 0 24 24"
252
+ fill="none"
253
+ stroke="currentColor"
254
+ stroke-width="2"
255
+ stroke-linecap="round"
256
+ stroke-linejoin="round"
257
+ >
258
+ <path d="M18 6 6 18" />
259
+ <path d="m6 6 12 12" />
260
+ </svg>
261
+ </button>
262
+ }
263
+ </div>
264
+ `,
265
+ styles: MIMI_OVERLAY_ANIMATION,
266
+ host: { style: 'display: contents' },
267
+ changeDetection: ChangeDetectionStrategy.OnPush,
268
+ })
269
+ export class MimiDialogShell {
270
+ private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
271
+ private readonly document = inject(DOCUMENT);
272
+ private readonly injector = inject(Injector);
273
+ private readonly service = inject(MimiDialogService);
274
+ protected readonly ref = inject<MimiDialogRef>(MimiDialogRef);
275
+ protected readonly options = inject(MIMI_DIALOG_SHELL_OPTIONS);
276
+ protected readonly text = inject(MIMI_DIALOG_TEXT);
277
+
278
+ private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
279
+
280
+ protected readonly closeButton = this.options.config.closeButton ?? true;
281
+ protected readonly closeClasses = CLOSE_CLASSES;
282
+ protected readonly classes = cn(PANEL_CLASSES, this.options.config.class);
283
+ /** El máximo del usuario, sin salirse de la pantalla (sin él, lo limita la clase del panel). */
284
+ protected readonly maxWidth = this.options.config.maxWidth
285
+ ? `min(${this.options.config.maxWidth}, calc(100vw - 32px))`
286
+ : null;
287
+
288
+ /** El `cdk-dialog-container`: lleva el rol y los atributos ARIA. */
289
+ private get container(): HTMLElement | null {
290
+ return this.host.parentElement;
291
+ }
292
+
293
+ /**
294
+ * Nombre y descripción: los ids de `mimiDialogTitle` y `mimiDialogDescription`, cuando existen.
295
+ * No se pasan al CDK porque los fija al abrir, antes de que exista el contenido.
296
+ */
297
+ private readonly describe = effect(() => {
298
+ const titleId = this.options.config.ariaLabel ? null : this.ref.titleId();
299
+ const descriptionId = this.ref.descriptionId();
300
+ const container = this.container;
301
+ if (!container) return;
302
+ setAttribute(container, 'aria-labelledby', titleId);
303
+ setAttribute(container, 'aria-describedby', descriptionId);
304
+ });
305
+
306
+ /** Al cerrar: espera la animación de salida y recién entonces cierra el diálogo del CDK. */
307
+ private readonly exit = effect(() => {
308
+ if (this.ref.state() !== 'closed') return;
309
+ untracked(() =>
310
+ afterNextRender(
311
+ {
312
+ read: () =>
313
+ void mimiWaitForExit(this.panel().nativeElement).then(() => this.ref.finish()),
314
+ },
315
+ { injector: this.injector },
316
+ ),
317
+ );
318
+ });
319
+
320
+ constructor() {
321
+ // En la fase de lectura: después de que el CDK movió el foco al abrir.
322
+ afterNextRender({
323
+ read: () => {
324
+ const container = this.container;
325
+ // `autoFocus` con un selector que no encuentra nada deja el foco fuera: va al panel.
326
+ if (container && !container.contains(this.document.activeElement)) container.focus();
327
+ // Con el foco ya aquí, los diálogos de abajo se ocultan a los lectores de pantalla.
328
+ this.service.syncStack();
329
+ if (isDevMode() && !this.options.config.ariaLabel && !this.ref.titleId()) {
330
+ console.warn(
331
+ '[Mimi] Dialog: el diálogo no tiene nombre accesible. Marca su título con ' +
332
+ 'mimiDialogTitle o pasa ariaLabel (aria-label en <mimi-dialog>).',
333
+ );
334
+ }
335
+ },
336
+ });
337
+ }
338
+ }
339
+
340
+ function setAttribute(element: HTMLElement, name: string, value: string | null): void {
341
+ if (value) element.setAttribute(name, value);
342
+ else element.removeAttribute(name);
343
+ }
344
+
345
+ /**
346
+ * Abre diálogos:
347
+ *
348
+ * ```ts
349
+ * const ref = inject(MimiDialogService).open(EditarUsuario, { data: usuario });
350
+ * ref.afterClosed().subscribe((resultado) => …);
351
+ * ```
352
+ *
353
+ * El contenido es un componente o un `TemplateRef`. Dentro: `inject(MIMI_DIALOG_DATA)` y
354
+ * `inject(MimiDialogRef).close(resultado)`.
355
+ *
356
+ * El diálogo se abre después del render en curso: si lo pide un ítem de menú, el menú ya se
357
+ * cerró y devolvió el foco a su botón, y el diálogo lo devuelve ahí al cerrar (no al ítem, que ya
358
+ * no existe). Con varios abiertos, los lectores de pantalla solo ven el de arriba.
359
+ */
360
+ @Injectable({ providedIn: 'root' })
361
+ export class MimiDialogService {
362
+ private readonly dialog = inject(Dialog);
363
+ private readonly injector = inject(Injector);
364
+ /** Los diálogos, en el orden en que se pidieron (también los que todavía no se abrieron). */
365
+ private readonly refs = new Set<MimiDialogRef<unknown>>();
366
+ /** El elemento de cada diálogo ya abierto, el que se oculta a los lectores. */
367
+ private readonly hosts = new Map<MimiDialogRef<unknown>, HTMLElement>();
368
+
369
+ open<R = unknown, D = unknown>(
370
+ content: Type<unknown> | TemplateRef<unknown>,
371
+ config: MimiDialogConfig<D> = {},
372
+ ): MimiDialogRef<R> {
373
+ const ref = new MimiDialogRef<R>();
374
+ const tracked = ref as MimiDialogRef<unknown>;
375
+ this.refs.add(tracked);
376
+ afterNextRender(
377
+ {
378
+ write: () => {
379
+ // Se cerró antes de abrirse (closeAll, o el componente que lo pidió se destruyó).
380
+ if (ref.state() === 'closed') {
381
+ this.refs.delete(tracked);
382
+ ref.discard();
383
+ return;
384
+ }
385
+ try {
386
+ this.attach(ref, content, config);
387
+ } catch (error) {
388
+ // El error llega al ErrorHandler; afterClosed() emite sin resultado.
389
+ this.refs.delete(tracked);
390
+ ref.discard();
391
+ throw error;
392
+ }
393
+ },
394
+ },
395
+ { injector: this.injector },
396
+ );
397
+ return ref;
398
+ }
399
+
400
+ /** Cierra todos los diálogos abiertos. */
401
+ closeAll(): void {
402
+ for (const ref of [...this.refs]) ref.close();
403
+ }
404
+
405
+ /**
406
+ * Uso interno: solo el diálogo de arriba queda visible para los lectores de pantalla; los de
407
+ * abajo llevan `aria-hidden` hasta que el de arriba empieza a cerrarse. El CDK hace lo mismo
408
+ * con el resto de la página, pero no entre diálogos.
409
+ */
410
+ syncStack(): void {
411
+ const open = [...this.hosts.keys()].filter((ref) => ref.state() === 'open');
412
+ const top = open.at(-1);
413
+ for (const [ref, host] of this.hosts) {
414
+ if (ref.state() === 'open' && ref !== top) host.setAttribute('aria-hidden', 'true');
415
+ else host.removeAttribute('aria-hidden');
416
+ }
417
+ }
418
+
419
+ private attach<R, D>(
420
+ ref: MimiDialogRef<R>,
421
+ content: Type<unknown> | TemplateRef<unknown>,
422
+ config: MimiDialogConfig<D>,
423
+ ): void {
424
+ const options: MimiDialogShellOptions = { content, config };
425
+ const autoFocus = config.autoFocus ?? 'first';
426
+ const cdkRef = this.dialog.open<R, D>(MimiDialogShell, {
427
+ // Escape y el clic en el fondo los atiende Mimi (abajo), para animar la salida.
428
+ disableClose: true,
429
+ role: config.role ?? 'dialog',
430
+ ariaModal: true,
431
+ ariaLabel: config.ariaLabel ?? null,
432
+ autoFocus: autoFocus === 'first' ? 'first-tabbable' : autoFocus,
433
+ restoreFocus: true,
434
+ injector: config.injector,
435
+ backdropClass: BACKDROP_CLASSES,
436
+ panelClass: PANE_CLASSES,
437
+ providers: [
438
+ { provide: MimiDialogRef, useValue: ref },
439
+ { provide: MIMI_DIALOG_DATA, useValue: config.data },
440
+ { provide: MIMI_DIALOG_SHELL_OPTIONS, useValue: options },
441
+ ],
442
+ });
443
+ const tracked = ref as MimiDialogRef<unknown>;
444
+ // Al empezar a cerrarse, el de abajo vuelve a los lectores antes de recibir el foco.
445
+ ref.connect(cdkRef, () => this.syncStack());
446
+ this.hosts.set(tracked, cdkRef.overlayRef.hostElement);
447
+
448
+ const closeOnEscape = !config.disableClose && (config.closeOnEscape ?? true);
449
+ const closeOnBackdropClick = !config.disableClose && (config.closeOnBackdropClick ?? true);
450
+ cdkRef.keydownEvents.subscribe((event) => {
451
+ if (event.key !== 'Escape' || event.altKey || event.ctrlKey || event.metaKey) return;
452
+ if (!closeOnEscape) return;
453
+ event.preventDefault();
454
+ ref.close();
455
+ });
456
+ cdkRef.backdropClick.subscribe(() => {
457
+ if (closeOnBackdropClick) ref.close();
458
+ });
459
+
460
+ // También cuando lo cierra el CDK por su cuenta (una navegación, o al destruirse).
461
+ cdkRef.closed.subscribe(() => {
462
+ this.refs.delete(tracked);
463
+ this.hosts.delete(tracked);
464
+ this.syncStack();
465
+ });
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Diálogo en la plantilla, sobre el mismo servicio:
471
+ *
472
+ * ```html
473
+ * <mimi-dialog [(open)]="abierto">
474
+ * <button mimiBtn mimiDialogTrigger>Editar usuario</button>
475
+ * <ng-template mimiDialogContent>…</ng-template>
476
+ * </mimi-dialog>
477
+ * ```
478
+ *
479
+ * No dibuja nada propio (`display: contents`): su `class` va al panel. Las opciones se leen al
480
+ * abrir.
481
+ */
482
+ @Component({
483
+ selector: 'mimi-dialog',
484
+ template: '<ng-content />',
485
+ host: {
486
+ style: 'display: contents',
487
+ // El class es del panel; aria-label, del diálogo: fuera del anfitrión.
488
+ '[attr.class]': 'null',
489
+ '[attr.aria-label]': 'null',
490
+ '[attr.data-state]': 'open() ? "open" : "closed"',
491
+ },
492
+ changeDetection: ChangeDetectionStrategy.OnPush,
493
+ })
494
+ export class MimiDialog {
495
+ private readonly service = inject(MimiDialogService);
496
+ private readonly injector = inject(Injector);
497
+
498
+ /** Abierto o cerrado. Admite `[(open)]`. */
499
+ readonly open = model(false);
500
+ /** Clases del panel, mezcladas con `cn()` (gana la del usuario). */
501
+ readonly userClass = input('', { alias: 'class' });
502
+ /** Ancho del panel (un valor CSS). */
503
+ readonly width = input<string>();
504
+ /** Ancho máximo del panel (un valor CSS). */
505
+ readonly maxWidth = input<string>();
506
+ readonly closeOnEscape = input(true, { transform: booleanAttribute });
507
+ readonly closeOnBackdropClick = input(true, { transform: booleanAttribute });
508
+ /** Atajo: ni Escape ni el clic en el fondo cierran. */
509
+ readonly disableClose = input(false, { transform: booleanAttribute });
510
+ /** Ocúltala solo en confirmaciones con sus propios botones de salida. */
511
+ readonly closeButton = input(true, { transform: booleanAttribute });
512
+ /** Nombre accesible, si no hay un `mimiDialogTitle`. */
513
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
514
+ /** `'first'` (por defecto), `'dialog'` o un selector CSS. */
515
+ readonly autoFocus = input<'first' | 'dialog' | (string & {})>('first');
516
+
517
+ /** Se cerró, con el resultado de `[mimiDialogClose]="…"` si lo hubo. */
518
+ readonly closed = output<unknown>();
519
+
520
+ protected readonly content = contentChild(MimiDialogContent);
521
+ private ref: MimiDialogRef | null = null;
522
+
523
+ private readonly syncOpen = effect(() => {
524
+ const open = this.open();
525
+ untracked(() => (open ? this.show() : this.ref?.close()));
526
+ });
527
+
528
+ constructor() {
529
+ // Si se destruye abierto, se cierra sin animación.
530
+ inject(DestroyRef).onDestroy(() => this.ref?.finish());
531
+ }
532
+
533
+ private show(): void {
534
+ const content = this.content();
535
+ if (this.ref || !content) return;
536
+ const ref = this.service.open(content.templateRef, {
537
+ class: this.userClass(),
538
+ width: this.width(),
539
+ maxWidth: this.maxWidth(),
540
+ closeOnEscape: this.closeOnEscape(),
541
+ closeOnBackdropClick: this.closeOnBackdropClick(),
542
+ disableClose: this.disableClose(),
543
+ closeButton: this.closeButton(),
544
+ ariaLabel: this.ariaLabel(),
545
+ autoFocus: this.autoFocus(),
546
+ injector: this.injector,
547
+ });
548
+ this.ref = ref;
549
+ ref.afterClosed().subscribe((result) => {
550
+ this.ref = null;
551
+ this.open.set(false);
552
+ this.closed.emit(result);
553
+ });
554
+ }
555
+ }
556
+
557
+ /** Botón que abre el diálogo: `<button mimiBtn mimiDialogTrigger>`. */
558
+ @Directive({
559
+ selector: '[mimiDialogTrigger]',
560
+ host: {
561
+ 'aria-haspopup': 'dialog',
562
+ '[attr.data-state]': 'dialog.open() ? "open" : "closed"',
563
+ '(click)': 'dialog.open.set(true)',
564
+ },
565
+ })
566
+ export class MimiDialogTrigger {
567
+ protected readonly dialog = inject(MimiDialog);
568
+ }
569
+
570
+ /** Contenido del diálogo: `<ng-template mimiDialogContent>`. Se crea al abrir. */
571
+ @Directive({ selector: 'ng-template[mimiDialogContent]' })
572
+ export class MimiDialogContent {
573
+ readonly templateRef = inject(TemplateRef);
574
+ }
575
+
576
+ /** Encabezado: el título y la descripción, con sitio para la X a la derecha. */
577
+ @Component({
578
+ selector: 'mimi-dialog-header',
579
+ template: '<ng-content />',
580
+ host: { '[class]': 'classes()' },
581
+ changeDetection: ChangeDetectionStrategy.OnPush,
582
+ })
583
+ export class MimiDialogHeader {
584
+ readonly userClass = input('', { alias: 'class' });
585
+ protected readonly classes = computed(() =>
586
+ cn(
587
+ 'flex shrink-0 flex-col gap-1 px-[var(--mimi-dialog-padding,24px)] pt-[var(--mimi-dialog-padding,24px)] pb-1',
588
+ 'group-data-[close-button]/dialog:pr-[calc(var(--mimi-dialog-padding,24px)_+_var(--mimi-dialog-close-size,32px))]',
589
+ this.userClass(),
590
+ ),
591
+ );
592
+ }
593
+
594
+ /** Título: le da al diálogo su nombre accesible (aria-labelledby). Úsalo en un `<h2>`. */
595
+ @Directive({
596
+ selector: '[mimiDialogTitle]',
597
+ host: { '[id]': 'id', '[class]': 'classes()' },
598
+ })
599
+ export class MimiDialogTitle {
600
+ private readonly ref = inject<MimiDialogRef>(MimiDialogRef);
601
+ readonly id = `mimi-dialog-title-${nextId++}`;
602
+ readonly userClass = input('', { alias: 'class' });
603
+ protected readonly classes = computed(() =>
604
+ cn('text-[18px] leading-tight font-bold tracking-[-0.01em]', this.userClass()),
605
+ );
606
+
607
+ constructor() {
608
+ this.ref.titleId.set(this.id);
609
+ inject(DestroyRef).onDestroy(() => {
610
+ if (this.ref.titleId() === this.id) this.ref.titleId.set(null);
611
+ });
612
+ }
613
+ }
614
+
615
+ /** Descripción del diálogo (aria-describedby). */
616
+ @Directive({
617
+ selector: '[mimiDialogDescription]',
618
+ host: { '[id]': 'id', '[class]': 'classes()' },
619
+ })
620
+ export class MimiDialogDescription {
621
+ private readonly ref = inject<MimiDialogRef>(MimiDialogRef);
622
+ readonly id = `mimi-dialog-description-${nextId++}`;
623
+ readonly userClass = input('', { alias: 'class' });
624
+ protected readonly classes = computed(() =>
625
+ cn('text-sm text-muted-foreground', this.userClass()),
626
+ );
627
+
628
+ constructor() {
629
+ this.ref.descriptionId.set(this.id);
630
+ inject(DestroyRef).onDestroy(() => {
631
+ if (this.ref.descriptionId() === this.id) this.ref.descriptionId.set(null);
632
+ });
633
+ }
634
+ }
635
+
636
+ /** Cuerpo: si el contenido no cabe, el scroll queda aquí y el encabezado y el pie no se mueven. */
637
+ @Component({
638
+ selector: 'mimi-dialog-body',
639
+ template: '<ng-content />',
640
+ host: { '[class]': 'classes()' },
641
+ changeDetection: ChangeDetectionStrategy.OnPush,
642
+ })
643
+ export class MimiDialogBody {
644
+ readonly userClass = input('', { alias: 'class' });
645
+ protected readonly classes = computed(() =>
646
+ cn(
647
+ 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-[var(--mimi-dialog-padding,24px)] py-4',
648
+ this.userClass(),
649
+ ),
650
+ );
651
+ }
652
+
653
+ /** Pie: las acciones, a la derecha. */
654
+ @Component({
655
+ selector: 'mimi-dialog-footer',
656
+ template: '<ng-content />',
657
+ host: { '[class]': 'classes()' },
658
+ changeDetection: ChangeDetectionStrategy.OnPush,
659
+ })
660
+ export class MimiDialogFooter {
661
+ readonly userClass = input('', { alias: 'class' });
662
+ protected readonly classes = computed(() =>
663
+ cn(
664
+ 'flex shrink-0 justify-end gap-2 px-[var(--mimi-dialog-padding,24px)] pt-2 pb-[var(--mimi-dialog-padding,24px)]',
665
+ this.userClass(),
666
+ ),
667
+ );
668
+ }
669
+
670
+ /**
671
+ * Cierra el diálogo al hacer clic: `<button mimiBtn mimiDialogClose>Cancelar</button>`. Con un
672
+ * valor (`[mimiDialogClose]="true"`), ese es el resultado de `afterClosed()` y de `(closed)`.
673
+ */
674
+ @Directive({
675
+ selector: '[mimiDialogClose]',
676
+ host: { '(click)': 'close()' },
677
+ })
678
+ export class MimiDialogClose {
679
+ private readonly ref = inject<MimiDialogRef>(MimiDialogRef);
680
+ /** Resultado con el que se cierra. */
681
+ readonly result = input<unknown>(undefined, { alias: 'mimiDialogClose' });
682
+
683
+ protected close(): void {
684
+ const result = this.result();
685
+ // `mimiDialogClose` sin valor llega como cadena vacía: se cierra sin resultado.
686
+ this.ref.close(result === '' ? undefined : result);
687
+ }
688
+ }
689
+
690
+ /** Todas las piezas de Dialog, para importarlas juntas. */
691
+ export const MimiDialogImports = [
692
+ MimiDialog,
693
+ MimiDialogTrigger,
694
+ MimiDialogContent,
695
+ MimiDialogHeader,
696
+ MimiDialogTitle,
697
+ MimiDialogDescription,
698
+ MimiDialogBody,
699
+ MimiDialogFooter,
700
+ MimiDialogClose,
701
+ ] as const;