@mimi-ng/cli 0.1.0 → 0.2.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.
- package/README.md +19 -1
- package/dist/collection.json +1 -1
- package/dist/init/css.js +17 -0
- package/dist/init/index.js +7 -5
- package/dist/init/items.js +9 -0
- package/dist/ng-add/index.js +17 -1
- package/dist/ng-add/schema.json +27 -0
- package/dist/registry.json +222 -1
- package/dist/templates/components/avatar/avatar.ts +1 -1
- package/dist/templates/components/avatar/avatar.variants.ts +4 -4
- package/dist/templates/components/badge/badge.ts +41 -6
- package/dist/templates/components/badge/badge.variants.ts +105 -11
- package/dist/templates/components/button/button.ts +74 -7
- package/dist/templates/components/button/button.variants.ts +145 -21
- package/dist/templates/components/card/card.ts +8 -5
- package/dist/templates/components/checkbox/checkbox.ts +3 -3
- package/dist/templates/components/confirm/confirm-texts.ts +46 -0
- package/dist/templates/components/confirm/confirm.ts +190 -0
- package/dist/templates/components/confirm/index.ts +2 -0
- package/dist/templates/components/context-menu/context-menu.ts +378 -0
- package/dist/templates/components/context-menu/index.ts +14 -0
- package/dist/templates/components/dialog/dialog-texts.ts +41 -0
- package/dist/templates/components/dialog/dialog.ts +701 -0
- package/dist/templates/components/dialog/index.ts +18 -0
- package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
- package/dist/templates/components/dropdown-menu/index.ts +14 -0
- package/dist/templates/components/form-field/form-error.ts +1 -1
- package/dist/templates/components/form-field/form-field.ts +3 -2
- package/dist/templates/components/input/input.variants.ts +6 -4
- package/dist/templates/components/menu/index.ts +15 -0
- package/dist/templates/components/menu/menu.ts +656 -0
- package/dist/templates/components/pagination/index.ts +2 -0
- package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
- package/dist/templates/components/pagination/pagination-texts.ts +69 -0
- package/dist/templates/components/pagination/pagination.ts +443 -0
- package/dist/templates/components/popover/index.ts +12 -0
- package/dist/templates/components/popover/popover.ts +399 -0
- package/dist/templates/components/select/index.ts +12 -0
- package/dist/templates/components/select/select-texts.ts +62 -0
- package/dist/templates/components/select/select.ts +1085 -0
- package/dist/templates/components/separator/separator.variants.ts +10 -7
- package/dist/templates/components/skeleton/skeleton.ts +4 -1
- package/dist/templates/components/switch/switch.ts +6 -6
- package/dist/templates/components/tabs/index.ts +2 -0
- package/dist/templates/components/tabs/tabs.ts +426 -0
- package/dist/templates/components/textarea/textarea.variants.ts +14 -11
- package/dist/templates/components/toast/index.ts +3 -0
- package/dist/templates/components/toast/toast-config.ts +45 -0
- package/dist/templates/components/toast/toast-texts.ts +46 -0
- package/dist/templates/components/toast/toast.ts +1000 -0
- package/dist/templates/components/toolbar/index.ts +1 -0
- package/dist/templates/components/toolbar/toolbar.ts +377 -0
- package/dist/templates/components/tooltip/index.ts +7 -0
- package/dist/templates/components/tooltip/tooltip.ts +407 -0
- package/dist/templates/theme/provider.ts +62 -6
- package/dist/templates/theme/theme-base.css +307 -9
- package/dist/templates/theme/types.ts +278 -4
- package/dist/templates/utils/cn.ts +15 -1
- package/dist/templates/utils/control-styles.ts +2 -2
- package/dist/templates/utils/overlay.ts +140 -0
- package/dist/ui/index.js +80 -8
- package/dist/ui/schema.json +44 -0
- package/dist/utils/write-files.js +156 -59
- package/package.json +6 -5
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
|
2
|
+
import {
|
|
3
|
+
ApplicationRef,
|
|
4
|
+
ChangeDetectionStrategy,
|
|
5
|
+
Component,
|
|
6
|
+
type ComponentRef,
|
|
7
|
+
DOCUMENT,
|
|
8
|
+
DestroyRef,
|
|
9
|
+
Directive,
|
|
10
|
+
ElementRef,
|
|
11
|
+
EnvironmentInjector,
|
|
12
|
+
Injectable,
|
|
13
|
+
Injector,
|
|
14
|
+
PLATFORM_ID,
|
|
15
|
+
TemplateRef,
|
|
16
|
+
ViewContainerRef,
|
|
17
|
+
afterNextRender,
|
|
18
|
+
assertInInjectionContext,
|
|
19
|
+
computed,
|
|
20
|
+
createComponent,
|
|
21
|
+
effect,
|
|
22
|
+
inject,
|
|
23
|
+
input,
|
|
24
|
+
signal,
|
|
25
|
+
untracked,
|
|
26
|
+
viewChild,
|
|
27
|
+
} from '@angular/core';
|
|
28
|
+
import { cva } from 'class-variance-authority';
|
|
29
|
+
import { MimiButton } from '@/components/ui/button';
|
|
30
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
31
|
+
import { buttonFocusStyles } from '@/components/ui/utils/control-styles';
|
|
32
|
+
import { mimiWaitForExit } from '@/components/ui/utils/overlay';
|
|
33
|
+
import { MIMI_TOAST_CONFIG, type MimiToastPosition } from './toast-config';
|
|
34
|
+
import { MIMI_TOAST_TEXT } from './toast-texts';
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* Toast (docs/design/Mimi F2 Toast Tabs Toolbar.dc.html, «1 · Toast · tonos» y «2 · Toast ·
|
|
38
|
+
* apilados»; prefijo de tokens `toast`, spec 13): avisos breves en una pila abajo a la derecha,
|
|
39
|
+
* colapsada (cada uno 12px más arriba y un 5 % más chico, tres visibles) y expandida al pasar el
|
|
40
|
+
* puntero o con el foco dentro.
|
|
41
|
+
*
|
|
42
|
+
* No hace falta poner un `<mimi-toaster>`: el contenedor se crea solo, la primera vez que se
|
|
43
|
+
* muestra un toast, al final de `body`. Es un popover (`popover="manual"`), así queda encima de
|
|
44
|
+
* los diálogos. Los anuncios los hace el LiveAnnouncer de @angular/cdk/a11y, cuya región existe
|
|
45
|
+
* desde que se llama a `injectMimiToast()`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** Cómo se ve: tarjeta con borde (por defecto) o del color del tono. */
|
|
49
|
+
export type MimiToastVariant = 'outline' | 'solid';
|
|
50
|
+
|
|
51
|
+
/** Botón de acción del toast. Al pulsarlo, el toast se cierra. */
|
|
52
|
+
export interface MimiToastAction {
|
|
53
|
+
label: string;
|
|
54
|
+
onClick: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Opciones de un toast. */
|
|
58
|
+
export interface MimiToastOptions {
|
|
59
|
+
description?: string;
|
|
60
|
+
/** Un toast con acción no se cierra solo, salvo que se pase `duration`. */
|
|
61
|
+
action?: MimiToastAction;
|
|
62
|
+
/** Milisegundos hasta cerrarse. `Infinity`: no se cierra solo. Por defecto, 6000. */
|
|
63
|
+
duration?: number;
|
|
64
|
+
/** Ícono propio (una plantilla con un `<svg>`), o `false` para no mostrar ninguno. */
|
|
65
|
+
icon?: TemplateRef<unknown> | false;
|
|
66
|
+
/** Por defecto, `outline`. */
|
|
67
|
+
variant?: MimiToastVariant;
|
|
68
|
+
/** Con el id de un toast abierto, lo reemplaza. */
|
|
69
|
+
id?: string;
|
|
70
|
+
/** Clases del toast, mezcladas con `cn()` (gana la del usuario). */
|
|
71
|
+
class?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Texto de un toast: un título, o un título con descripción y acción. */
|
|
75
|
+
export type MimiToastMessage =
|
|
76
|
+
string | { title: string; description?: string; action?: MimiToastAction };
|
|
77
|
+
|
|
78
|
+
/** Mensajes de `toast.promise()`. */
|
|
79
|
+
export interface MimiToastPromiseMessages<T> {
|
|
80
|
+
loading: MimiToastMessage;
|
|
81
|
+
success: MimiToastMessage | ((value: T) => MimiToastMessage);
|
|
82
|
+
error: MimiToastMessage | ((error: unknown) => MimiToastMessage);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** La función que devuelve `injectMimiToast()`. Cada llamada devuelve el id del toast. */
|
|
86
|
+
export interface MimiToastFn {
|
|
87
|
+
(title: string, options?: MimiToastOptions): string;
|
|
88
|
+
success(title: string, options?: MimiToastOptions): string;
|
|
89
|
+
info(title: string, options?: MimiToastOptions): string;
|
|
90
|
+
warning(title: string, options?: MimiToastOptions): string;
|
|
91
|
+
/** Tono danger (tokens destructive). Se anuncia con prioridad. */
|
|
92
|
+
error(title: string, options?: MimiToastOptions): string;
|
|
93
|
+
/**
|
|
94
|
+
* Muestra «cargando» mientras la promesa está pendiente y después el éxito o el error. Devuelve
|
|
95
|
+
* la misma promesa (su rechazo ya queda atendido por el toast).
|
|
96
|
+
*/
|
|
97
|
+
promise<T>(
|
|
98
|
+
promise: Promise<T> | (() => Promise<T>),
|
|
99
|
+
messages: MimiToastPromiseMessages<T>,
|
|
100
|
+
options?: MimiToastOptions,
|
|
101
|
+
): Promise<T>;
|
|
102
|
+
/** Cierra un toast, o todos sin id. */
|
|
103
|
+
dismiss(id?: string): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Tonos de Toast: un subconjunto de la lista común (spec 4, «Variante y tono»). Sin tono (`toast()`),
|
|
108
|
+
* la tarjeta neutra con la campana; no hay un valor «neutro» con nombre.
|
|
109
|
+
*/
|
|
110
|
+
type ToastTone = 'success' | 'warning' | 'info' | 'danger';
|
|
111
|
+
|
|
112
|
+
/** Uso interno: un toast en la pila. */
|
|
113
|
+
interface ToastRecord {
|
|
114
|
+
id: string;
|
|
115
|
+
title: string;
|
|
116
|
+
description?: string;
|
|
117
|
+
action?: MimiToastAction;
|
|
118
|
+
icon?: TemplateRef<unknown> | false;
|
|
119
|
+
variant: MimiToastVariant;
|
|
120
|
+
/** Sin tono: `toast()` y el «cargando» de `promise()`. */
|
|
121
|
+
tone: ToastTone | undefined;
|
|
122
|
+
loading: boolean;
|
|
123
|
+
class?: string;
|
|
124
|
+
duration: number;
|
|
125
|
+
closeLabel: string;
|
|
126
|
+
state: 'open' | 'closed';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Uso interno: dónde va cada toast de la pila. */
|
|
130
|
+
interface ToastLayout {
|
|
131
|
+
transform: string;
|
|
132
|
+
hidden: boolean;
|
|
133
|
+
height: number | null;
|
|
134
|
+
front: boolean;
|
|
135
|
+
zIndex: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Del diseño: tres visibles con la pila colapsada, cinco en total, 8px entre ellos expandida. */
|
|
139
|
+
const VISIBLE = 3;
|
|
140
|
+
const MAX = 5;
|
|
141
|
+
const EXPANDED_GAP = 8;
|
|
142
|
+
/** Alto mínimo del diseño (la pila usa 68px). */
|
|
143
|
+
const MIN_HEIGHT = 68;
|
|
144
|
+
/** setTimeout convierte en 0 cualquier valor mayor (cerraría el toast enseguida). */
|
|
145
|
+
const MAX_DELAY = 2 ** 31 - 1;
|
|
146
|
+
|
|
147
|
+
/** El tono sin `solid` y el `solid` sin tono se ven como la tarjeta (el diseño no los muestra). */
|
|
148
|
+
const OUTLINE =
|
|
149
|
+
'border border-[color:var(--mimi-toast-border,var(--mimi-border))] bg-[color:var(--mimi-toast-bg,var(--mimi-popover))] text-popover-foreground shadow-[shadow:var(--mimi-toast-shadow,var(--mimi-shadow-popover))]';
|
|
150
|
+
|
|
151
|
+
/** Colores de cada tono en cada variante (spec 4, «Variante y tono»). */
|
|
152
|
+
const TONE_CLASSES = {
|
|
153
|
+
success: { outline: OUTLINE, solid: 'bg-success text-success-foreground shadow-success-hover' },
|
|
154
|
+
warning: { outline: OUTLINE, solid: 'bg-warning text-warning-foreground shadow-warning-hover' },
|
|
155
|
+
info: { outline: OUTLINE, solid: 'bg-info text-info-foreground shadow-info-hover' },
|
|
156
|
+
danger: {
|
|
157
|
+
outline: OUTLINE,
|
|
158
|
+
solid: 'bg-destructive text-destructive-foreground shadow-destructive-hover',
|
|
159
|
+
},
|
|
160
|
+
} as const satisfies Record<ToastTone, Record<MimiToastVariant, string>>;
|
|
161
|
+
|
|
162
|
+
/** Color del ícono en la tarjeta (en el sólido, el del texto). Sin tono, el del texto. */
|
|
163
|
+
const ICON_NO_TONE = 'text-foreground';
|
|
164
|
+
const ICON_TONE_CLASSES = {
|
|
165
|
+
success: 'text-success-soft-foreground',
|
|
166
|
+
warning: 'text-warning-soft-foreground',
|
|
167
|
+
info: 'text-info-soft-foreground',
|
|
168
|
+
danger: 'text-destructive-soft-foreground',
|
|
169
|
+
} as const satisfies Record<ToastTone, string>;
|
|
170
|
+
|
|
171
|
+
const toastVariants = cva(
|
|
172
|
+
[
|
|
173
|
+
// 12px arriba y abajo: con una línea de descripción mide los 68px de la pila del diseño.
|
|
174
|
+
'relative flex min-h-[68px] items-center gap-3 py-3 pr-10 pl-3.5 text-left font-sans [overflow-wrap:anywhere]',
|
|
175
|
+
'rounded-[var(--mimi-toast-radius,var(--mimi-radius))]',
|
|
176
|
+
],
|
|
177
|
+
{
|
|
178
|
+
variants: {
|
|
179
|
+
variant: { outline: '', solid: '' },
|
|
180
|
+
tone: { success: '', warning: '', info: '', danger: '' },
|
|
181
|
+
},
|
|
182
|
+
compoundVariants: Object.entries(TONE_CLASSES).flatMap(([tone, byVariant]) =>
|
|
183
|
+
Object.entries(byVariant).map(([variant, cls]) => ({
|
|
184
|
+
tone: tone as ToastTone,
|
|
185
|
+
variant: variant as MimiToastVariant,
|
|
186
|
+
class: cls,
|
|
187
|
+
})),
|
|
188
|
+
),
|
|
189
|
+
// Sin tono no hay combinación: la tarjeta (OUTLINE) la agrega MimiToastItem.
|
|
190
|
+
defaultVariants: { variant: 'outline' },
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const POSITION_CLASSES = {
|
|
195
|
+
'top-left': 'top-[var(--mimi-toast-offset,16px)] left-[var(--mimi-toast-offset,16px)]',
|
|
196
|
+
'top-center': 'top-[var(--mimi-toast-offset,16px)] left-1/2 -translate-x-1/2',
|
|
197
|
+
'top-right': 'top-[var(--mimi-toast-offset,16px)] right-[var(--mimi-toast-offset,16px)]',
|
|
198
|
+
'bottom-left': 'bottom-[var(--mimi-toast-offset,16px)] left-[var(--mimi-toast-offset,16px)]',
|
|
199
|
+
'bottom-center': 'bottom-[var(--mimi-toast-offset,16px)] left-1/2 -translate-x-1/2',
|
|
200
|
+
'bottom-right': 'right-[var(--mimi-toast-offset,16px)] bottom-[var(--mimi-toast-offset,16px)]',
|
|
201
|
+
} as const satisfies Record<MimiToastPosition, string>;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* El contenedor: un popover sin los estilos del navegador (margen, inset, borde, fondo), que no
|
|
205
|
+
* recibe clics; los toasts sí. 340px, sin pasarse del ancho de la pantalla menos los márgenes.
|
|
206
|
+
*/
|
|
207
|
+
const HOST_CLASSES =
|
|
208
|
+
'pointer-events-none fixed inset-auto z-[2147483647] m-0 h-auto w-[min(var(--mimi-toast-width,340px),calc(100vw_-_2*var(--mimi-toast-offset,16px)))] overflow-visible border-0 bg-transparent p-0 text-popover-foreground';
|
|
209
|
+
|
|
210
|
+
const CLOSE_CLASSES = cn(
|
|
211
|
+
'absolute top-2 right-2 inline-flex size-6 cursor-pointer items-center justify-center rounded-[var(--mimi-radius-sm)] bg-transparent',
|
|
212
|
+
'mimi-transition active:scale-(--mimi-press-scale-sm) [&_svg]:size-3.5',
|
|
213
|
+
buttonFocusStyles,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
/** Crea la plantilla del ícono. Hace lo mismo que NgTemplateOutlet, sin @angular/common. */
|
|
217
|
+
@Directive({ selector: '[mimiToastIcon]' })
|
|
218
|
+
class MimiToastIcon {
|
|
219
|
+
readonly template = input.required<TemplateRef<unknown>>({ alias: 'mimiToastIcon' });
|
|
220
|
+
private readonly viewContainer = inject(ViewContainerRef);
|
|
221
|
+
|
|
222
|
+
private readonly render = effect(() => {
|
|
223
|
+
const template = this.template();
|
|
224
|
+
untracked(() => {
|
|
225
|
+
this.viewContainer.clear();
|
|
226
|
+
this.viewContainer.createEmbeddedView(template);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Un toast de la pila. */
|
|
232
|
+
@Component({
|
|
233
|
+
selector: 'li[mimiToastItem]',
|
|
234
|
+
imports: [MimiButton, MimiToastIcon],
|
|
235
|
+
template: `
|
|
236
|
+
@let t = toast();
|
|
237
|
+
<!-- Recorta a los de atrás al alto del de adelante (pila colapsada). -->
|
|
238
|
+
<div
|
|
239
|
+
[style.height.px]="layout().height"
|
|
240
|
+
[class.overflow-hidden]="layout().height !== null"
|
|
241
|
+
class="rounded-[var(--mimi-toast-radius,var(--mimi-radius))]"
|
|
242
|
+
>
|
|
243
|
+
<div #row data-slot="toast" [class]="rowClasses()">
|
|
244
|
+
@if (t.loading) {
|
|
245
|
+
<span data-slot="icon" aria-hidden="true" [class]="iconClasses()">
|
|
246
|
+
<svg
|
|
247
|
+
class="animate-[mimi-spin_0.8s_linear_infinite] text-muted-foreground"
|
|
248
|
+
viewBox="0 0 24 24"
|
|
249
|
+
fill="none"
|
|
250
|
+
stroke="currentColor"
|
|
251
|
+
stroke-width="2"
|
|
252
|
+
stroke-linecap="round"
|
|
253
|
+
stroke-linejoin="round"
|
|
254
|
+
>
|
|
255
|
+
<!-- Lucide loader-circle (ISC) -->
|
|
256
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
257
|
+
</svg>
|
|
258
|
+
</span>
|
|
259
|
+
} @else if (t.icon) {
|
|
260
|
+
<span data-slot="icon" aria-hidden="true" [class]="iconClasses()">
|
|
261
|
+
<ng-container [mimiToastIcon]="t.icon" />
|
|
262
|
+
</span>
|
|
263
|
+
} @else if (t.icon !== false) {
|
|
264
|
+
<span data-slot="icon" aria-hidden="true" [class]="iconClasses()">
|
|
265
|
+
<svg
|
|
266
|
+
viewBox="0 0 24 24"
|
|
267
|
+
fill="none"
|
|
268
|
+
stroke="currentColor"
|
|
269
|
+
stroke-width="2"
|
|
270
|
+
stroke-linecap="round"
|
|
271
|
+
stroke-linejoin="round"
|
|
272
|
+
>
|
|
273
|
+
<!-- Lucide bell, circle-check, triangle-alert, info y circle-x (ISC) -->
|
|
274
|
+
@switch (t.tone) {
|
|
275
|
+
@case ('success') {
|
|
276
|
+
<circle cx="12" cy="12" r="10" />
|
|
277
|
+
<path d="m9 12 2 2 4-4" />
|
|
278
|
+
}
|
|
279
|
+
@case ('warning') {
|
|
280
|
+
<path
|
|
281
|
+
d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"
|
|
282
|
+
/>
|
|
283
|
+
<path d="M12 9v4" />
|
|
284
|
+
<path d="M12 17h.01" />
|
|
285
|
+
}
|
|
286
|
+
@case ('info') {
|
|
287
|
+
<circle cx="12" cy="12" r="10" />
|
|
288
|
+
<path d="M12 16v-4" />
|
|
289
|
+
<path d="M12 8h.01" />
|
|
290
|
+
}
|
|
291
|
+
@case ('danger') {
|
|
292
|
+
<circle cx="12" cy="12" r="10" />
|
|
293
|
+
<path d="m15 9-6 6" />
|
|
294
|
+
<path d="m9 9 6 6" />
|
|
295
|
+
}
|
|
296
|
+
@default {
|
|
297
|
+
<path d="M10.268 21a2 2 0 0 0 3.464 0" />
|
|
298
|
+
<path
|
|
299
|
+
d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"
|
|
300
|
+
/>
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
</svg>
|
|
304
|
+
</span>
|
|
305
|
+
}
|
|
306
|
+
<div class="flex min-w-0 flex-1 flex-col gap-px">
|
|
307
|
+
<span data-slot="title" [class]="titleClasses()">{{ t.title }}</span>
|
|
308
|
+
@if (t.description) {
|
|
309
|
+
<span data-slot="description" [class]="descriptionClasses()">{{ t.description }}</span>
|
|
310
|
+
}
|
|
311
|
+
</div>
|
|
312
|
+
@if (t.action) {
|
|
313
|
+
<button
|
|
314
|
+
mimiBtn
|
|
315
|
+
type="button"
|
|
316
|
+
size="sm"
|
|
317
|
+
data-slot="action"
|
|
318
|
+
[class]="actionClasses()"
|
|
319
|
+
(click)="act()"
|
|
320
|
+
>
|
|
321
|
+
{{ t.action.label }}
|
|
322
|
+
</button>
|
|
323
|
+
}
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
data-slot="close"
|
|
327
|
+
[class]="closeClasses()"
|
|
328
|
+
[attr.aria-label]="t.closeLabel"
|
|
329
|
+
(click)="store.dismiss(t.id)"
|
|
330
|
+
>
|
|
331
|
+
<svg
|
|
332
|
+
aria-hidden="true"
|
|
333
|
+
viewBox="0 0 24 24"
|
|
334
|
+
fill="none"
|
|
335
|
+
stroke="currentColor"
|
|
336
|
+
stroke-width="2"
|
|
337
|
+
stroke-linecap="round"
|
|
338
|
+
stroke-linejoin="round"
|
|
339
|
+
>
|
|
340
|
+
<path d="M18 6 6 18" />
|
|
341
|
+
<path d="m6 6 12 12" />
|
|
342
|
+
</svg>
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
`,
|
|
347
|
+
styles: `
|
|
348
|
+
@keyframes mimi-toast-in {
|
|
349
|
+
from {
|
|
350
|
+
opacity: 0;
|
|
351
|
+
translate: 0 var(--mimi-toast-enter, 100%);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
@keyframes mimi-toast-out {
|
|
355
|
+
to {
|
|
356
|
+
opacity: 0;
|
|
357
|
+
translate: 0 var(--mimi-toast-enter, 100%);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
:host {
|
|
361
|
+
transition:
|
|
362
|
+
transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1),
|
|
363
|
+
opacity 0.3s ease;
|
|
364
|
+
}
|
|
365
|
+
:host([data-state='open']:not([data-entered])) {
|
|
366
|
+
animation: mimi-toast-in 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
367
|
+
}
|
|
368
|
+
:host([data-state='closed']) {
|
|
369
|
+
animation: mimi-toast-out 0.3s ease forwards;
|
|
370
|
+
}
|
|
371
|
+
@media (prefers-reduced-motion: reduce) {
|
|
372
|
+
:host {
|
|
373
|
+
animation: none !important;
|
|
374
|
+
transition: none !important;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
`,
|
|
378
|
+
host: {
|
|
379
|
+
tabindex: '-1',
|
|
380
|
+
'[class]': 'hostClasses()',
|
|
381
|
+
'[style.transform]': 'layout().transform',
|
|
382
|
+
'[style.opacity]': 'layout().hidden ? 0 : null',
|
|
383
|
+
'[style.z-index]': 'layout().zIndex',
|
|
384
|
+
'[style.--mimi-toast-enter]': 'top() ? "-100%" : null',
|
|
385
|
+
'[attr.inert]': 'layout().hidden || toast().state === "closed" ? "" : null',
|
|
386
|
+
'[attr.data-state]': 'toast().state',
|
|
387
|
+
'[attr.data-variant]': 'variant()',
|
|
388
|
+
'[attr.data-tone]': 'toast().tone ?? null',
|
|
389
|
+
'[attr.data-front]': 'layout().front ? "" : null',
|
|
390
|
+
'[attr.data-entered]': 'entered() ? "" : null',
|
|
391
|
+
},
|
|
392
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
393
|
+
})
|
|
394
|
+
class MimiToastItem {
|
|
395
|
+
readonly toast = input.required<ToastRecord>();
|
|
396
|
+
readonly layout = input.required<ToastLayout>();
|
|
397
|
+
readonly top = input(false);
|
|
398
|
+
|
|
399
|
+
protected readonly store = inject(MimiToastStore);
|
|
400
|
+
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
401
|
+
private readonly injector = inject(Injector);
|
|
402
|
+
private readonly row = viewChild.required<ElementRef<HTMLElement>>('row');
|
|
403
|
+
protected readonly entered = signal(false);
|
|
404
|
+
|
|
405
|
+
/** El sólido sin tono se ve como la tarjeta (el diseño no lo muestra). */
|
|
406
|
+
protected readonly variant = computed<MimiToastVariant>(() =>
|
|
407
|
+
this.toast().variant === 'solid' && this.toast().tone ? 'solid' : 'outline',
|
|
408
|
+
);
|
|
409
|
+
private readonly solid = computed(() => this.variant() === 'solid');
|
|
410
|
+
|
|
411
|
+
protected readonly hostClasses = computed(() =>
|
|
412
|
+
cn(
|
|
413
|
+
'absolute inset-x-0 block rounded-[var(--mimi-toast-radius,var(--mimi-radius))]',
|
|
414
|
+
this.top() ? 'top-0 origin-top' : 'bottom-0 origin-bottom',
|
|
415
|
+
// Sin outline-none: en Tailwind 4 anula este anillo (focus-styles.spec.ts).
|
|
416
|
+
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
protected readonly rowClasses = computed(() =>
|
|
420
|
+
cn(
|
|
421
|
+
'pointer-events-auto',
|
|
422
|
+
toastVariants({ variant: this.variant(), tone: this.toast().tone }),
|
|
423
|
+
!this.toast().tone && OUTLINE,
|
|
424
|
+
this.toast().class,
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
protected readonly iconClasses = computed(() => {
|
|
428
|
+
if (this.solid()) {
|
|
429
|
+
return 'inline-flex size-[34px] shrink-0 items-center justify-center rounded-full bg-[color-mix(in_oklch,currentColor_16%,transparent)] [&_svg]:size-[18px]';
|
|
430
|
+
}
|
|
431
|
+
const tone = this.toast().tone;
|
|
432
|
+
return cn(
|
|
433
|
+
'inline-flex shrink-0 [&_svg]:size-[18px]',
|
|
434
|
+
tone ? ICON_TONE_CLASSES[tone] : ICON_NO_TONE,
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
protected readonly titleClasses = computed(() =>
|
|
438
|
+
this.solid() ? 'text-sm font-bold' : 'text-sm font-semibold',
|
|
439
|
+
);
|
|
440
|
+
protected readonly descriptionClasses = computed(() =>
|
|
441
|
+
this.solid() ? 'text-[13px] leading-[1.4]' : 'text-[12.5px] text-muted-foreground',
|
|
442
|
+
);
|
|
443
|
+
/** Acción del diseño: 28px, 10px a los lados, radio sm, 12.5px/600. */
|
|
444
|
+
protected readonly actionClasses = computed(() =>
|
|
445
|
+
cn(
|
|
446
|
+
'h-7 shrink-0 rounded-[var(--mimi-radius-sm)] px-2.5 text-[12.5px] font-semibold',
|
|
447
|
+
// En el sólido (sin diseño), el mismo fondo translúcido que el círculo del ícono.
|
|
448
|
+
this.solid() &&
|
|
449
|
+
'bg-[color-mix(in_oklch,currentColor_16%,transparent)] text-current shadow-none hover:not-disabled:bg-[color-mix(in_oklch,currentColor_24%,transparent)] hover:not-disabled:shadow-none',
|
|
450
|
+
),
|
|
451
|
+
);
|
|
452
|
+
protected readonly closeClasses = computed(() =>
|
|
453
|
+
cn(
|
|
454
|
+
CLOSE_CLASSES,
|
|
455
|
+
this.solid()
|
|
456
|
+
? 'text-current hover:bg-[color-mix(in_oklch,currentColor_16%,transparent)]'
|
|
457
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
|
458
|
+
),
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
/** Cuando se cierra: espera la animación de salida y lo saca de la pila. */
|
|
462
|
+
private readonly exit = effect(() => {
|
|
463
|
+
if (this.toast().state !== 'closed') return;
|
|
464
|
+
const id = this.toast().id;
|
|
465
|
+
untracked(() =>
|
|
466
|
+
afterNextRender(
|
|
467
|
+
{ read: () => void mimiWaitForExit(this.host).then(() => this.store.remove(id)) },
|
|
468
|
+
{ injector: this.injector },
|
|
469
|
+
),
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
/** El elemento de cada toast, para moverle el foco (antes de los hooks de render). */
|
|
474
|
+
private readonly register = effect(() => this.store.elements.set(this.toast().id, this.host));
|
|
475
|
+
|
|
476
|
+
constructor() {
|
|
477
|
+
const destroyRef = inject(DestroyRef);
|
|
478
|
+
afterNextRender({
|
|
479
|
+
read: () => {
|
|
480
|
+
const id = this.toast().id;
|
|
481
|
+
// Terminada la entrada, no se repite (al volver a mostrar el popover, por ejemplo).
|
|
482
|
+
void mimiWaitForExit(this.host).then(() => this.entered.set(true));
|
|
483
|
+
// El alto natural, medido en la fila (el recorte no la afecta).
|
|
484
|
+
const row = this.row().nativeElement;
|
|
485
|
+
this.store.setHeight(id, row.offsetHeight);
|
|
486
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
487
|
+
const observer = new ResizeObserver(() => this.store.setHeight(id, row.offsetHeight));
|
|
488
|
+
observer.observe(row);
|
|
489
|
+
destroyRef.onDestroy(() => observer.disconnect());
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
destroyRef.onDestroy(() => {
|
|
493
|
+
if (this.store.elements.get(this.toast().id) === this.host) {
|
|
494
|
+
this.store.elements.delete(this.toast().id);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
protected act(): void {
|
|
500
|
+
const { action, id } = this.toast();
|
|
501
|
+
action?.onClick();
|
|
502
|
+
this.store.dismiss(id);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** El contenedor de la pila. Lo crea el servicio; no se usa en plantillas. */
|
|
507
|
+
@Component({
|
|
508
|
+
selector: 'mimi-toaster',
|
|
509
|
+
imports: [MimiToastItem],
|
|
510
|
+
template: `
|
|
511
|
+
<section
|
|
512
|
+
tabindex="-1"
|
|
513
|
+
class="outline-none"
|
|
514
|
+
[attr.aria-label]="label"
|
|
515
|
+
(keydown)="onKeydown($event)"
|
|
516
|
+
(focusin)="setFocused(true)"
|
|
517
|
+
(focusout)="onFocusOut($event)"
|
|
518
|
+
>
|
|
519
|
+
<ol
|
|
520
|
+
class="pointer-events-auto relative m-0 list-none p-0"
|
|
521
|
+
[style.height]="stackHeight()"
|
|
522
|
+
(mouseenter)="setHovered(true)"
|
|
523
|
+
(mouseleave)="setHovered(false)"
|
|
524
|
+
>
|
|
525
|
+
@for (toast of store.toasts(); track toast.id; let i = $index) {
|
|
526
|
+
<li mimiToastItem [toast]="toast" [layout]="layouts()[i]" [top]="top"></li>
|
|
527
|
+
}
|
|
528
|
+
</ol>
|
|
529
|
+
</section>
|
|
530
|
+
`,
|
|
531
|
+
host: {
|
|
532
|
+
popover: 'manual',
|
|
533
|
+
'[class]': 'classes',
|
|
534
|
+
'[attr.data-position]': 'position',
|
|
535
|
+
'[attr.data-expanded]': 'expanded() ? "" : null',
|
|
536
|
+
},
|
|
537
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
538
|
+
})
|
|
539
|
+
class MimiToaster {
|
|
540
|
+
protected readonly store = inject(MimiToastStore);
|
|
541
|
+
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
542
|
+
|
|
543
|
+
protected readonly position = this.store.config.position;
|
|
544
|
+
protected readonly top = this.position.startsWith('top');
|
|
545
|
+
protected readonly classes = cn(HOST_CLASSES, POSITION_CLASSES[this.position]);
|
|
546
|
+
protected readonly label = this.store.text.region(this.store.config.hotkey);
|
|
547
|
+
|
|
548
|
+
private readonly hovered = signal(false);
|
|
549
|
+
private readonly focused = signal(false);
|
|
550
|
+
protected readonly expanded = computed(() => this.hovered() || this.focused());
|
|
551
|
+
|
|
552
|
+
/** Alto de cada toast: el medido, o el mínimo del diseño mientras no hay medida. */
|
|
553
|
+
private height(id: string | undefined): number {
|
|
554
|
+
return (id && this.store.heights().get(id)) || MIN_HEIGHT;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
protected readonly layouts = computed<ToastLayout[]>(() => {
|
|
558
|
+
const toasts = this.store.toasts();
|
|
559
|
+
const expanded = this.expanded();
|
|
560
|
+
const front = this.height(toasts[0]?.id);
|
|
561
|
+
const sign = this.top ? '' : '-';
|
|
562
|
+
let offset = 0;
|
|
563
|
+
return toasts.map((toast, i) => {
|
|
564
|
+
const layout: ToastLayout = expanded
|
|
565
|
+
? {
|
|
566
|
+
transform: `translateY(${sign}${offset}px)`,
|
|
567
|
+
hidden: false,
|
|
568
|
+
height: null,
|
|
569
|
+
front: i === 0,
|
|
570
|
+
zIndex: toasts.length - i,
|
|
571
|
+
}
|
|
572
|
+
: {
|
|
573
|
+
// Colapsada: cada uno --mimi-toast-stack-gap más atrás y un 5 % más chico, con el alto
|
|
574
|
+
// del de adelante; del cuarto en adelante, ocultos.
|
|
575
|
+
transform: `translateY(calc(${sign}1 * var(--mimi-toast-stack-gap, 12px) * ${i})) scale(${1 - 0.05 * i})`,
|
|
576
|
+
hidden: i >= VISIBLE,
|
|
577
|
+
height: i === 0 ? null : front,
|
|
578
|
+
front: i === 0,
|
|
579
|
+
zIndex: toasts.length - i,
|
|
580
|
+
};
|
|
581
|
+
offset += this.height(toast.id) + EXPANDED_GAP;
|
|
582
|
+
return layout;
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
/** El alto de la pila: así el puntero no sale por los huecos entre toasts. */
|
|
587
|
+
protected readonly stackHeight = computed(() => {
|
|
588
|
+
const toasts = this.store.toasts();
|
|
589
|
+
if (!toasts.length) return '0px';
|
|
590
|
+
if (this.expanded()) {
|
|
591
|
+
const total = toasts.reduce((sum, toast) => sum + this.height(toast.id), 0);
|
|
592
|
+
return `${total + EXPANDED_GAP * (toasts.length - 1)}px`;
|
|
593
|
+
}
|
|
594
|
+
const behind = Math.min(toasts.length - 1, VISIBLE - 1);
|
|
595
|
+
return `calc(${this.height(toasts[0].id)}px + var(--mimi-toast-stack-gap, 12px) * ${behind})`;
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
constructor() {
|
|
599
|
+
// Sin toasts, el puntero y el foco ya no están sobre la pila (mouseleave puede no llegar).
|
|
600
|
+
effect(() => {
|
|
601
|
+
if (this.store.toasts().length) return;
|
|
602
|
+
untracked(() => {
|
|
603
|
+
this.setHovered(false);
|
|
604
|
+
this.setFocused(false);
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
protected setHovered(value: boolean): void {
|
|
610
|
+
this.hovered.set(value);
|
|
611
|
+
this.store.setPaused('hover', value);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
protected setFocused(value: boolean): void {
|
|
615
|
+
this.focused.set(value);
|
|
616
|
+
this.store.setPaused('focus', value);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
protected onFocusOut(event: FocusEvent): void {
|
|
620
|
+
const next = event.relatedTarget as Node | null;
|
|
621
|
+
if (!next || !this.host.contains(next)) this.setFocused(false);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Escape devuelve el foco a donde estaba. No llega al Dialog de abajo. */
|
|
625
|
+
protected onKeydown(event: KeyboardEvent): void {
|
|
626
|
+
if (event.key !== 'Escape' || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
627
|
+
event.preventDefault();
|
|
628
|
+
event.stopPropagation();
|
|
629
|
+
this.store.restoreFocus();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Uso interno: la pila de toasts, los tiempos, los anuncios y el contenedor. Uno por aplicación.
|
|
635
|
+
*/
|
|
636
|
+
@Injectable({ providedIn: 'root' })
|
|
637
|
+
class MimiToastStore {
|
|
638
|
+
private readonly injector = inject(Injector);
|
|
639
|
+
private readonly document = inject(DOCUMENT);
|
|
640
|
+
private readonly browser = inject(PLATFORM_ID) === 'browser';
|
|
641
|
+
// Crea la región de los anuncios ya, antes del primer toast.
|
|
642
|
+
private readonly announcer = inject(LiveAnnouncer);
|
|
643
|
+
readonly config = inject(MIMI_TOAST_CONFIG);
|
|
644
|
+
readonly text = inject(MIMI_TOAST_TEXT);
|
|
645
|
+
|
|
646
|
+
/** Los toasts, el más nuevo primero (también los que están saliendo). */
|
|
647
|
+
readonly toasts = signal<ToastRecord[]>([]);
|
|
648
|
+
readonly heights = signal<ReadonlyMap<string, number>>(new Map());
|
|
649
|
+
/** El elemento de cada toast, para moverle el foco. */
|
|
650
|
+
readonly elements = new Map<string, HTMLElement>();
|
|
651
|
+
|
|
652
|
+
private nextId = 0;
|
|
653
|
+
private container: ComponentRef<MimiToaster> | null = null;
|
|
654
|
+
private readonly timers = new Map<
|
|
655
|
+
string,
|
|
656
|
+
{ remaining: number; started: number; handle: ReturnType<typeof setTimeout> | null }
|
|
657
|
+
>();
|
|
658
|
+
private readonly pauses = new Set<'hover' | 'focus' | 'hidden'>();
|
|
659
|
+
/** Otro popover se mostró después del contenedor y le quedó encima. */
|
|
660
|
+
private covered = false;
|
|
661
|
+
private previousFocus: Element | null = null;
|
|
662
|
+
private pendingAnnouncement: { texts: string[]; assertive: boolean; done: Promise<void> } | null =
|
|
663
|
+
null;
|
|
664
|
+
|
|
665
|
+
private readonly onKeydown = (event: KeyboardEvent) => this.hotkey(event);
|
|
666
|
+
private readonly onVisibility = () =>
|
|
667
|
+
this.setPaused('hidden', this.document.visibilityState === 'hidden');
|
|
668
|
+
private readonly onToggle = (event: Event) => {
|
|
669
|
+
const host = this.container?.location.nativeElement;
|
|
670
|
+
if (event.target !== host && (event as Event & { newState?: string }).newState === 'open') {
|
|
671
|
+
this.covered = true;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
constructor() {
|
|
676
|
+
inject(DestroyRef).onDestroy(() => {
|
|
677
|
+
for (const timer of this.timers.values()) {
|
|
678
|
+
if (timer.handle !== null) clearTimeout(timer.handle);
|
|
679
|
+
}
|
|
680
|
+
this.timers.clear();
|
|
681
|
+
if (!this.container) return;
|
|
682
|
+
this.document.removeEventListener('keydown', this.onKeydown);
|
|
683
|
+
this.document.removeEventListener('visibilitychange', this.onVisibility);
|
|
684
|
+
this.document.removeEventListener('toggle', this.onToggle, { capture: true });
|
|
685
|
+
const host = this.container.location.nativeElement as HTMLElement;
|
|
686
|
+
this.container.destroy();
|
|
687
|
+
host.remove();
|
|
688
|
+
this.container = null;
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
show(
|
|
693
|
+
title: string,
|
|
694
|
+
tone: ToastTone | undefined,
|
|
695
|
+
options: MimiToastOptions,
|
|
696
|
+
closeLabel: string,
|
|
697
|
+
extra: { loading?: boolean; replaceOnly?: boolean } = {},
|
|
698
|
+
): string {
|
|
699
|
+
const id = options.id ?? `mimi-toast-${this.nextId++}`;
|
|
700
|
+
const open = this.toasts().find((toast) => toast.id === id && toast.state === 'open');
|
|
701
|
+
if (!this.browser || (extra.replaceOnly && !open)) return id;
|
|
702
|
+
this.ensureContainer();
|
|
703
|
+
|
|
704
|
+
const record: ToastRecord = {
|
|
705
|
+
id,
|
|
706
|
+
title,
|
|
707
|
+
description: options.description,
|
|
708
|
+
action: options.action,
|
|
709
|
+
icon: options.icon,
|
|
710
|
+
variant: options.variant ?? 'outline',
|
|
711
|
+
tone,
|
|
712
|
+
loading: extra.loading ?? false,
|
|
713
|
+
class: options.class,
|
|
714
|
+
duration: options.duration ?? (options.action ? Infinity : this.config.duration),
|
|
715
|
+
closeLabel,
|
|
716
|
+
state: 'open',
|
|
717
|
+
};
|
|
718
|
+
if (open) {
|
|
719
|
+
this.toasts.update((list) => list.map((toast) => (toast.id === id ? record : toast)));
|
|
720
|
+
} else {
|
|
721
|
+
this.toasts.update((list) => [record, ...list.filter((toast) => toast.id !== id)]);
|
|
722
|
+
this.trim();
|
|
723
|
+
}
|
|
724
|
+
this.startTimer(record);
|
|
725
|
+
this.announce(record);
|
|
726
|
+
this.raise();
|
|
727
|
+
return id;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Cierra un toast (animado), o todos sin id. */
|
|
731
|
+
dismiss(id?: string): void {
|
|
732
|
+
const ids = id === undefined ? this.toasts().map((toast) => toast.id) : [id];
|
|
733
|
+
for (const target of ids) {
|
|
734
|
+
const toast = this.toasts().find((t) => t.id === target);
|
|
735
|
+
if (!toast || toast.state === 'closed') continue;
|
|
736
|
+
this.clearTimer(target);
|
|
737
|
+
this.moveFocusFrom(target);
|
|
738
|
+
this.toasts.update((list) =>
|
|
739
|
+
list.map((t) => (t.id === target ? { ...t, state: 'closed' } : t)),
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Terminó la salida: fuera de la pila. */
|
|
745
|
+
remove(id: string): void {
|
|
746
|
+
const toast = this.toasts().find((t) => t.id === id);
|
|
747
|
+
if (!toast || toast.state !== 'closed') return;
|
|
748
|
+
this.toasts.update((list) => list.filter((t) => t !== toast));
|
|
749
|
+
this.heights.update((heights) => {
|
|
750
|
+
const next = new Map(heights);
|
|
751
|
+
next.delete(id);
|
|
752
|
+
return next;
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
setHeight(id: string, height: number): void {
|
|
757
|
+
if (!height || this.heights().get(id) === height) return;
|
|
758
|
+
this.heights.update((heights) => new Map(heights).set(id, height));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Detiene o reanuda el tiempo de todos (puntero, foco o pestaña oculta). */
|
|
762
|
+
setPaused(reason: 'hover' | 'focus' | 'hidden', paused: boolean): void {
|
|
763
|
+
const before = this.pauses.size > 0;
|
|
764
|
+
if (paused) this.pauses.add(reason);
|
|
765
|
+
else this.pauses.delete(reason);
|
|
766
|
+
const after = this.pauses.size > 0;
|
|
767
|
+
if (before === after) return;
|
|
768
|
+
for (const [id, timer] of this.timers) {
|
|
769
|
+
if (after) {
|
|
770
|
+
if (timer.handle === null) continue;
|
|
771
|
+
clearTimeout(timer.handle);
|
|
772
|
+
timer.handle = null;
|
|
773
|
+
timer.remaining = Math.max(0, timer.remaining - (Date.now() - timer.started));
|
|
774
|
+
} else {
|
|
775
|
+
this.run(id);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Devuelve el foco a donde estaba antes del atajo (o lo suelta si ya no existe). */
|
|
781
|
+
restoreFocus(): void {
|
|
782
|
+
const previous = this.previousFocus as HTMLElement | null;
|
|
783
|
+
this.previousFocus = null;
|
|
784
|
+
if (previous?.isConnected && typeof previous.focus === 'function') {
|
|
785
|
+
previous.focus();
|
|
786
|
+
} else {
|
|
787
|
+
(this.document.activeElement as HTMLElement | null)?.blur?.();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private ensureContainer(): void {
|
|
792
|
+
if (this.container) return;
|
|
793
|
+
// Recién aquí, y no al inyectar: un ErrorHandler propio puede usar injectMimiToast().
|
|
794
|
+
const appRef = this.injector.get(ApplicationRef);
|
|
795
|
+
this.container = createComponent(MimiToaster, {
|
|
796
|
+
environmentInjector: this.injector.get(EnvironmentInjector),
|
|
797
|
+
});
|
|
798
|
+
appRef.attachView(this.container.hostView);
|
|
799
|
+
this.document.body.appendChild(this.container.location.nativeElement);
|
|
800
|
+
this.document.addEventListener('keydown', this.onKeydown);
|
|
801
|
+
this.document.addEventListener('visibilitychange', this.onVisibility);
|
|
802
|
+
this.document.addEventListener('toggle', this.onToggle, { capture: true });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Deja el contenedor encima de todo: si otro popover se mostró después (un Dialog, un menú),
|
|
807
|
+
* lo vuelve a mostrar. No con el foco dentro: ocultarlo lo movería.
|
|
808
|
+
*/
|
|
809
|
+
private raise(): void {
|
|
810
|
+
const host = this.container?.location.nativeElement as HTMLElement | undefined;
|
|
811
|
+
if (!host || typeof host.showPopover !== 'function') return;
|
|
812
|
+
if (!host.matches(':popover-open')) {
|
|
813
|
+
host.showPopover();
|
|
814
|
+
} else if (this.covered && !host.contains(this.document.activeElement)) {
|
|
815
|
+
host.hidePopover();
|
|
816
|
+
host.showPopover();
|
|
817
|
+
} else {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.covered = false;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Más de cinco: sale el más viejo sin acción ni foco. */
|
|
824
|
+
private trim(): void {
|
|
825
|
+
const open = this.toasts().filter((toast) => toast.state === 'open');
|
|
826
|
+
let extra = open.length - MAX;
|
|
827
|
+
for (const toast of [...open].reverse()) {
|
|
828
|
+
if (extra <= 0) break;
|
|
829
|
+
if (toast.action || this.elements.get(toast.id)?.contains(this.document.activeElement)) {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
this.dismiss(toast.id);
|
|
833
|
+
extra--;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private startTimer(toast: ToastRecord): void {
|
|
838
|
+
this.clearTimer(toast.id);
|
|
839
|
+
if (toast.loading || !Number.isFinite(toast.duration)) return;
|
|
840
|
+
const remaining = Math.min(Math.max(toast.duration, 0), MAX_DELAY);
|
|
841
|
+
this.timers.set(toast.id, { remaining, started: 0, handle: null });
|
|
842
|
+
if (!this.pauses.size) this.run(toast.id);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private run(id: string): void {
|
|
846
|
+
const timer = this.timers.get(id);
|
|
847
|
+
if (!timer) return;
|
|
848
|
+
timer.started = Date.now();
|
|
849
|
+
timer.handle = setTimeout(() => {
|
|
850
|
+
this.timers.delete(id);
|
|
851
|
+
this.dismiss(id);
|
|
852
|
+
}, timer.remaining);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private clearTimer(id: string): void {
|
|
856
|
+
const timer = this.timers.get(id);
|
|
857
|
+
if (timer?.handle != null) clearTimeout(timer.handle);
|
|
858
|
+
this.timers.delete(id);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Anuncia «Título. Descripción». Si llega otro antes de que el anterior se escriba (el
|
|
863
|
+
* LiveAnnouncer espera 100 ms), se anuncian juntos: ninguno se pierde.
|
|
864
|
+
*/
|
|
865
|
+
private announce(toast: ToastRecord): void {
|
|
866
|
+
const text = toast.description ? `${toast.title}. ${toast.description}` : toast.title;
|
|
867
|
+
const pending = this.pendingAnnouncement;
|
|
868
|
+
const texts = pending ? [...pending.texts, text] : [text];
|
|
869
|
+
const assertive = (pending?.assertive ?? false) || toast.tone === 'danger';
|
|
870
|
+
const done = this.announcer.announce(texts.join(' '), assertive ? 'assertive' : 'polite');
|
|
871
|
+
const announcement = { texts, assertive, done };
|
|
872
|
+
this.pendingAnnouncement = announcement;
|
|
873
|
+
void done.then(() => {
|
|
874
|
+
if (this.pendingAnnouncement === announcement) this.pendingAnnouncement = null;
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** F8 (o la tecla configurada): el foco al toast más nuevo. */
|
|
879
|
+
private hotkey(event: KeyboardEvent): void {
|
|
880
|
+
if (event.key !== this.config.hotkey) return;
|
|
881
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
882
|
+
const first = this.toasts().find((toast) => toast.state === 'open');
|
|
883
|
+
const element = first && this.elements.get(first.id);
|
|
884
|
+
if (!element) return;
|
|
885
|
+
event.preventDefault();
|
|
886
|
+
event.stopPropagation();
|
|
887
|
+
const host = this.container?.location.nativeElement as HTMLElement | undefined;
|
|
888
|
+
if (!host?.contains(this.document.activeElement)) {
|
|
889
|
+
this.previousFocus = this.document.activeElement;
|
|
890
|
+
}
|
|
891
|
+
element.focus();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Si el toast que se cierra tiene el foco, pasa al vecino o vuelve a donde estaba. */
|
|
895
|
+
private moveFocusFrom(id: string): void {
|
|
896
|
+
const element = this.elements.get(id);
|
|
897
|
+
if (!element?.contains(this.document.activeElement)) return;
|
|
898
|
+
const open = this.toasts().filter((toast) => toast.state === 'open');
|
|
899
|
+
const index = open.findIndex((toast) => toast.id === id);
|
|
900
|
+
const neighbor = open[index + 1] ?? open[index - 1];
|
|
901
|
+
if (!neighbor) {
|
|
902
|
+
this.restoreFocus();
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const next = this.elements.get(neighbor.id);
|
|
906
|
+
if (next) {
|
|
907
|
+
next.focus();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
// Recién creado (la acción abrió otro toast): todavía no se pintó.
|
|
911
|
+
afterNextRender(
|
|
912
|
+
{
|
|
913
|
+
read: () => {
|
|
914
|
+
const element = this.elements.get(neighbor.id);
|
|
915
|
+
if (element) element.focus();
|
|
916
|
+
else this.restoreFocus();
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
{ injector: this.injector },
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function content(message: MimiToastMessage): {
|
|
925
|
+
title: string;
|
|
926
|
+
description?: string;
|
|
927
|
+
action?: MimiToastAction;
|
|
928
|
+
} {
|
|
929
|
+
return typeof message === 'string' ? { title: message } : message;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Prepara `toast()`:
|
|
934
|
+
*
|
|
935
|
+
* ```ts
|
|
936
|
+
* private readonly toast = injectMimiToast();
|
|
937
|
+
*
|
|
938
|
+
* save() {
|
|
939
|
+
* this.toast.success('Cambios guardados', { description: 'panel-ventas se publicó.' });
|
|
940
|
+
* }
|
|
941
|
+
* ```
|
|
942
|
+
*
|
|
943
|
+
* Se llama al declarar la propiedad (o en el constructor): necesita el contexto de inyección.
|
|
944
|
+
* Dentro de un método falla con NG0203. No hace falta poner nada en la plantilla: la pila se crea
|
|
945
|
+
* sola. Los toasts sobreviven a la navegación; una acción puede ejecutarse con el componente que
|
|
946
|
+
* la creó ya destruido. En el servidor (SSR) no muestra nada.
|
|
947
|
+
*/
|
|
948
|
+
export function injectMimiToast(): MimiToastFn {
|
|
949
|
+
assertInInjectionContext(injectMimiToast);
|
|
950
|
+
const store = inject(MimiToastStore);
|
|
951
|
+
// El nombre de la X sale de quien llama (sus providers o los de su ruta), como en Confirm.
|
|
952
|
+
const closeLabel = inject(MIMI_TOAST_TEXT).close;
|
|
953
|
+
const show =
|
|
954
|
+
(tone: ToastTone | undefined) =>
|
|
955
|
+
(title: string, options: MimiToastOptions = {}): string =>
|
|
956
|
+
store.show(title, tone, options, closeLabel);
|
|
957
|
+
|
|
958
|
+
return Object.assign(show(undefined), {
|
|
959
|
+
success: show('success'),
|
|
960
|
+
info: show('info'),
|
|
961
|
+
warning: show('warning'),
|
|
962
|
+
error: show('danger'),
|
|
963
|
+
promise: <T>(
|
|
964
|
+
source: Promise<T> | (() => Promise<T>),
|
|
965
|
+
messages: MimiToastPromiseMessages<T>,
|
|
966
|
+
options: MimiToastOptions = {},
|
|
967
|
+
): Promise<T> => {
|
|
968
|
+
const promise = typeof source === 'function' ? source() : source;
|
|
969
|
+
const loading = content(messages.loading);
|
|
970
|
+
const id = store.show(
|
|
971
|
+
loading.title,
|
|
972
|
+
undefined,
|
|
973
|
+
{ ...options, description: loading.description },
|
|
974
|
+
closeLabel,
|
|
975
|
+
{ loading: true },
|
|
976
|
+
);
|
|
977
|
+
// Si el usuario cerró el «cargando», el resultado no vuelve a abrirlo.
|
|
978
|
+
const settle = (tone: ToastTone, message: MimiToastMessage) => {
|
|
979
|
+
const result = content(message);
|
|
980
|
+
store.show(result.title, tone, { ...options, ...result, id }, closeLabel, {
|
|
981
|
+
replaceOnly: true,
|
|
982
|
+
});
|
|
983
|
+
};
|
|
984
|
+
promise.then(
|
|
985
|
+
(value) =>
|
|
986
|
+
settle(
|
|
987
|
+
'success',
|
|
988
|
+
typeof messages.success === 'function' ? messages.success(value) : messages.success,
|
|
989
|
+
),
|
|
990
|
+
(error: unknown) =>
|
|
991
|
+
settle(
|
|
992
|
+
'danger',
|
|
993
|
+
typeof messages.error === 'function' ? messages.error(error) : messages.error,
|
|
994
|
+
),
|
|
995
|
+
);
|
|
996
|
+
return promise;
|
|
997
|
+
},
|
|
998
|
+
dismiss: (id?: string) => store.dismiss(id),
|
|
999
|
+
});
|
|
1000
|
+
}
|