@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.
Files changed (64) hide show
  1. package/README.md +19 -1
  2. package/dist/collection.json +1 -1
  3. package/dist/init/css.js +17 -0
  4. package/dist/init/index.js +7 -5
  5. package/dist/init/items.js +9 -0
  6. package/dist/ng-add/index.js +17 -1
  7. package/dist/ng-add/schema.json +27 -0
  8. package/dist/registry.json +222 -1
  9. package/dist/templates/components/avatar/avatar.ts +1 -1
  10. package/dist/templates/components/avatar/avatar.variants.ts +4 -4
  11. package/dist/templates/components/badge/badge.ts +41 -6
  12. package/dist/templates/components/badge/badge.variants.ts +105 -11
  13. package/dist/templates/components/button/button.ts +74 -7
  14. package/dist/templates/components/button/button.variants.ts +145 -21
  15. package/dist/templates/components/card/card.ts +8 -5
  16. package/dist/templates/components/checkbox/checkbox.ts +3 -3
  17. package/dist/templates/components/confirm/confirm-texts.ts +46 -0
  18. package/dist/templates/components/confirm/confirm.ts +190 -0
  19. package/dist/templates/components/confirm/index.ts +2 -0
  20. package/dist/templates/components/context-menu/context-menu.ts +378 -0
  21. package/dist/templates/components/context-menu/index.ts +14 -0
  22. package/dist/templates/components/dialog/dialog-texts.ts +41 -0
  23. package/dist/templates/components/dialog/dialog.ts +701 -0
  24. package/dist/templates/components/dialog/index.ts +18 -0
  25. package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
  26. package/dist/templates/components/dropdown-menu/index.ts +14 -0
  27. package/dist/templates/components/form-field/form-error.ts +1 -1
  28. package/dist/templates/components/form-field/form-field.ts +3 -2
  29. package/dist/templates/components/input/input.variants.ts +6 -4
  30. package/dist/templates/components/menu/index.ts +15 -0
  31. package/dist/templates/components/menu/menu.ts +656 -0
  32. package/dist/templates/components/pagination/index.ts +2 -0
  33. package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
  34. package/dist/templates/components/pagination/pagination-texts.ts +69 -0
  35. package/dist/templates/components/pagination/pagination.ts +443 -0
  36. package/dist/templates/components/popover/index.ts +12 -0
  37. package/dist/templates/components/popover/popover.ts +399 -0
  38. package/dist/templates/components/select/index.ts +12 -0
  39. package/dist/templates/components/select/select-texts.ts +62 -0
  40. package/dist/templates/components/select/select.ts +1085 -0
  41. package/dist/templates/components/separator/separator.variants.ts +10 -7
  42. package/dist/templates/components/skeleton/skeleton.ts +4 -1
  43. package/dist/templates/components/switch/switch.ts +6 -6
  44. package/dist/templates/components/tabs/index.ts +2 -0
  45. package/dist/templates/components/tabs/tabs.ts +426 -0
  46. package/dist/templates/components/textarea/textarea.variants.ts +14 -11
  47. package/dist/templates/components/toast/index.ts +3 -0
  48. package/dist/templates/components/toast/toast-config.ts +45 -0
  49. package/dist/templates/components/toast/toast-texts.ts +46 -0
  50. package/dist/templates/components/toast/toast.ts +1000 -0
  51. package/dist/templates/components/toolbar/index.ts +1 -0
  52. package/dist/templates/components/toolbar/toolbar.ts +377 -0
  53. package/dist/templates/components/tooltip/index.ts +7 -0
  54. package/dist/templates/components/tooltip/tooltip.ts +407 -0
  55. package/dist/templates/theme/provider.ts +62 -6
  56. package/dist/templates/theme/theme-base.css +307 -9
  57. package/dist/templates/theme/types.ts +278 -4
  58. package/dist/templates/utils/cn.ts +15 -1
  59. package/dist/templates/utils/control-styles.ts +2 -2
  60. package/dist/templates/utils/overlay.ts +140 -0
  61. package/dist/ui/index.js +80 -8
  62. package/dist/ui/schema.json +44 -0
  63. package/dist/utils/write-files.js +156 -59
  64. package/package.json +6 -5
@@ -0,0 +1,399 @@
1
+ import { CdkConnectedOverlay, type ConnectedOverlayPositionChange } from '@angular/cdk/overlay';
2
+ import { InteractivityChecker } from '@angular/cdk/a11y';
3
+ import {
4
+ ChangeDetectionStrategy,
5
+ Component,
6
+ DOCUMENT,
7
+ DestroyRef,
8
+ Directive,
9
+ ElementRef,
10
+ Injector,
11
+ TemplateRef,
12
+ ViewContainerRef,
13
+ afterNextRender,
14
+ booleanAttribute,
15
+ computed,
16
+ contentChild,
17
+ effect,
18
+ inject,
19
+ input,
20
+ isDevMode,
21
+ model,
22
+ signal,
23
+ untracked,
24
+ viewChild,
25
+ } from '@angular/core';
26
+ import { cn } from '@/components/ui/utils/cn';
27
+ import {
28
+ MIMI_OVERLAY_ANIMATION,
29
+ MIMI_VIEWPORT_MARGIN,
30
+ type MimiOverlayAlign,
31
+ type MimiOverlaySide,
32
+ mimiArrowOffset,
33
+ mimiConnectedPositions,
34
+ mimiPlacedSide,
35
+ mimiWaitForExit,
36
+ } from '@/components/ui/utils/overlay';
37
+
38
+ /*
39
+ * Popover (docs/design/Mimi F2 Overlays.dc.html, «3 · Popover»): panel de 300px, padding 16px,
40
+ * gap 12px, a 12px del trigger, con flecha de 10px. Tokens de la spec 13 (MimiPopoverTokens), con
41
+ * la cascada de la 6.2: --mimi-popover-* → token global → valor del diseño.
42
+ *
43
+ * Por dentro usa @angular/cdk/overlay (decisión 4.3): CdkConnectedOverlay posiciona el panel como
44
+ * popover nativo (top layer) insertado junto al trigger ('inline'), con flip si no cabe. Nada del
45
+ * CDK sale en la API pública. Los estilos del overlay vienen de @angular/cdk/overlay-prebuilt.css,
46
+ * que la CLI agrega al CSS global (spec 4.3). Posición, flecha y animación: utils/overlay.ts.
47
+ */
48
+
49
+ export type MimiPopoverSide = MimiOverlaySide;
50
+ export type MimiPopoverAlign = MimiOverlayAlign;
51
+
52
+ /** Distancia entre el trigger y el panel (diseño: margin-top 12px). */
53
+ const OFFSET = 12;
54
+
55
+ let nextId = 0;
56
+
57
+ /**
58
+ * Uso interno de mimi-popover (no está en MimiPopoverImports; se exporta porque el compilador de
59
+ * Angular lo exige): pinta el contenido del usuario dentro del panel. Hace lo mismo que
60
+ * NgTemplateOutlet, pero sin importar @angular/common: esbuild pondría ese módulo en el chunk
61
+ * inicial del proyecto, aunque Popover sea lazy (spec 10).
62
+ * @internal No está en el índice; no forma parte de la API de Mimi.
63
+ */
64
+ @Directive({ selector: '[mimiPopoverOutlet]' })
65
+ export class MimiPopoverOutlet {
66
+ readonly template = input.required<TemplateRef<unknown>>({ alias: 'mimiPopoverOutlet' });
67
+ private readonly viewContainer = inject(ViewContainerRef);
68
+ private readonly render = effect(() => {
69
+ const template = this.template();
70
+ untracked(() => {
71
+ this.viewContainer.clear();
72
+ this.viewContainer.createEmbeddedView(template);
73
+ });
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Contenedor del popover: un trigger (`[mimiPopoverTrigger]`) y el contenido en un
79
+ * `<ng-template mimiPopoverContent>`. No dibuja nada propio (`display: contents`): su `class` y
80
+ * su `aria-label` van al panel.
81
+ */
82
+ @Component({
83
+ selector: 'mimi-popover',
84
+ imports: [CdkConnectedOverlay, MimiPopoverOutlet],
85
+ template: `
86
+ <ng-content />
87
+ <ng-template
88
+ cdkConnectedOverlay
89
+ [cdkConnectedOverlayOrigin]="originElement()!"
90
+ [cdkConnectedOverlayOpen]="rendered()"
91
+ [cdkConnectedOverlayPositions]="positions()"
92
+ [cdkConnectedOverlayViewportMargin]="viewportMargin"
93
+ [cdkConnectedOverlayDisableClose]="true"
94
+ cdkConnectedOverlayUsePopover="inline"
95
+ cdkConnectedOverlayTransformOriginOn="[data-mimi-overlay-panel]"
96
+ (attach)="onAttach()"
97
+ (detach)="onDetach()"
98
+ (overlayKeydown)="onKeydown($event)"
99
+ (overlayOutsideClick)="close()"
100
+ (positionChange)="onPositionChange($event)"
101
+ >
102
+ <div
103
+ #panel
104
+ data-mimi-overlay-panel
105
+ role="dialog"
106
+ tabindex="-1"
107
+ [id]="panelId"
108
+ [class]="panelClasses()"
109
+ [attr.aria-label]="ariaLabel() || null"
110
+ [attr.aria-labelledby]="ariaLabel() ? null : titleId()"
111
+ [attr.aria-describedby]="descriptionId()"
112
+ [attr.data-state]="state()"
113
+ [attr.data-side]="placedSide()"
114
+ [attr.data-align]="align()"
115
+ [attr.inert]="state() === 'closed' ? '' : null"
116
+ [style.--mimi-popover-arrow-offset]="arrowOffset()"
117
+ >
118
+ @if (arrow()) {
119
+ <span aria-hidden="true" [attr.data-side]="placedSide()" [class]="arrowClasses"></span>
120
+ }
121
+ @if (content(); as content) {
122
+ <ng-container [mimiPopoverOutlet]="content.templateRef" />
123
+ }
124
+ </div>
125
+ </ng-template>
126
+ `,
127
+ styles: MIMI_OVERLAY_ANIMATION,
128
+ host: {
129
+ style: 'display: contents',
130
+ // El class y el aria-label son del panel: en el host no tendrían efecto o se heredarían.
131
+ '[attr.class]': 'null',
132
+ '[attr.aria-label]': 'null',
133
+ '[attr.data-state]': 'open() ? "open" : "closed"',
134
+ '[attr.data-side]': 'side()',
135
+ '[attr.data-align]': 'align()',
136
+ // `align` es un atributo HTML antiguo: el navegador lo convierte en text-align (se heredaría).
137
+ '[attr.align]': 'null',
138
+ },
139
+ changeDetection: ChangeDetectionStrategy.OnPush,
140
+ })
141
+ export class MimiPopover {
142
+ private readonly document = inject(DOCUMENT);
143
+ private readonly injector = inject(Injector);
144
+ private readonly checker = inject(InteractivityChecker);
145
+
146
+ /** Abierto o cerrado. Admite `[(open)]`. */
147
+ readonly open = model(false);
148
+ /** Lado preferido. Si no cabe, pasa al lado opuesto. */
149
+ readonly side = input<MimiPopoverSide>('bottom');
150
+ /** Alineación con el trigger. */
151
+ readonly align = input<MimiPopoverAlign>('center');
152
+ /** Flecha hacia el trigger (decorativa). */
153
+ readonly arrow = input(true, { transform: booleanAttribute });
154
+ /** Nombre accesible del panel (`aria-label`). Si hay `mimiPopoverTitle`, no hace falta. */
155
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
156
+ /** Clases del panel, mezcladas con `cn()` (gana la del usuario). */
157
+ readonly userClass = input('', { alias: 'class' });
158
+
159
+ protected readonly trigger = contentChild(MimiPopoverTrigger);
160
+ protected readonly content = contentChild(MimiPopoverContent);
161
+ private readonly panel = viewChild<ElementRef<HTMLElement>>('panel');
162
+
163
+ /** id del panel (aria-controls del trigger). */
164
+ readonly panelId = `mimi-popover-${nextId++}`;
165
+ /** Registrados por `mimiPopoverTitle` y `mimiPopoverDescription`. */
166
+ readonly titleId = signal<string | null>(null);
167
+ readonly descriptionId = signal<string | null>(null);
168
+
169
+ /** El panel está en el DOM (también mientras dura la animación de salida). */
170
+ protected readonly rendered = signal(false);
171
+ protected readonly state = signal<'open' | 'closed'>('closed');
172
+ protected readonly placedSide = signal<MimiPopoverSide>('bottom');
173
+ protected readonly arrowOffset = signal<string | null>(null);
174
+ protected readonly viewportMargin = MIMI_VIEWPORT_MARGIN;
175
+
176
+ protected readonly originElement = computed(() => this.trigger()?.element ?? null);
177
+
178
+ /** Lado y alineación pedidos, el lado opuesto y, por último, los otros dos lados. */
179
+ protected readonly positions = computed(() =>
180
+ mimiConnectedPositions(this.side(), this.align(), OFFSET),
181
+ );
182
+
183
+ protected readonly panelClasses = computed(() =>
184
+ cn(
185
+ 'relative flex w-[300px] max-w-[calc(100vw_-_16px)] flex-col gap-3 outline-none',
186
+ 'rounded-[var(--mimi-popover-radius,var(--mimi-radius))] border border-[color:var(--mimi-popover-border,var(--mimi-border))]',
187
+ 'bg-[color:var(--mimi-popover-bg,var(--mimi-popover))] text-popover-foreground',
188
+ 'p-[var(--mimi-popover-padding,16px)] shadow-[shadow:var(--mimi-popover-shadow,var(--mimi-shadow-popover))]',
189
+ this.userClass(),
190
+ ),
191
+ );
192
+
193
+ protected readonly arrowClasses = cn(
194
+ 'pointer-events-none absolute size-[var(--mimi-popover-arrow,10px)] rounded-tl-[2px] border-t border-l',
195
+ 'border-[color:var(--mimi-popover-border,var(--mimi-border))] bg-[color:var(--mimi-popover-bg,var(--mimi-popover))]',
196
+ 'data-[side=bottom]:top-[calc(var(--mimi-popover-arrow,10px)/-2_-_1px)] data-[side=bottom]:left-[var(--mimi-popover-arrow-offset,50%)] data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:rotate-45',
197
+ 'data-[side=top]:bottom-[calc(var(--mimi-popover-arrow,10px)/-2_-_1px)] data-[side=top]:left-[var(--mimi-popover-arrow-offset,50%)] data-[side=top]:-translate-x-1/2 data-[side=top]:rotate-[225deg]',
198
+ 'data-[side=right]:left-[calc(var(--mimi-popover-arrow,10px)/-2_-_1px)] data-[side=right]:top-[var(--mimi-popover-arrow-offset,50%)] data-[side=right]:-translate-y-1/2 data-[side=right]:-rotate-45',
199
+ 'data-[side=left]:right-[calc(var(--mimi-popover-arrow,10px)/-2_-_1px)] data-[side=left]:top-[var(--mimi-popover-arrow-offset,50%)] data-[side=left]:-translate-y-1/2 data-[side=left]:rotate-[135deg]',
200
+ );
201
+
202
+ /** Cuenta los cierres: si se vuelve a abrir durante la animación de salida, no se desmonta. */
203
+ private closing = 0;
204
+ private warnedLabel = false;
205
+
206
+ private readonly syncOpen = effect(() => {
207
+ const open = this.open();
208
+ untracked(() => (open ? this.show() : this.hide()));
209
+ });
210
+
211
+ /** Abre o cierra (lo usa el trigger). */
212
+ toggle(): void {
213
+ this.open.update((open) => !open);
214
+ }
215
+
216
+ /** Cierra. El foco vuelve al trigger si estaba dentro del panel o en ningún lado. */
217
+ close(): void {
218
+ this.open.set(false);
219
+ }
220
+
221
+ private show(): void {
222
+ this.closing++;
223
+ this.placedSide.set(this.side());
224
+ this.state.set('open');
225
+ if (this.rendered()) {
226
+ // Se reabrió durante la salida: el panel sigue montado, así que no habrá (attach).
227
+ this.focusPanel();
228
+ } else {
229
+ this.rendered.set(true);
230
+ }
231
+ }
232
+
233
+ private hide(): void {
234
+ if (!this.rendered()) return;
235
+ this.state.set('closed');
236
+ const panel = this.panel()?.nativeElement;
237
+ const active = this.document.activeElement;
238
+ if (!active || active === this.document.body || panel?.contains(active)) {
239
+ this.trigger()?.element.focus();
240
+ }
241
+ const closing = ++this.closing;
242
+ afterNextRender(
243
+ {
244
+ read: () => {
245
+ // Espera la animación de salida (sin animación, como con movimiento reducido, no hay).
246
+ void mimiWaitForExit(panel).then(() => {
247
+ if (closing === this.closing) this.rendered.set(false);
248
+ });
249
+ },
250
+ },
251
+ { injector: this.injector },
252
+ );
253
+ }
254
+
255
+ protected onAttach(): void {
256
+ this.focusPanel();
257
+ }
258
+
259
+ protected onDetach(): void {
260
+ // El CDK lo desmontó por su cuenta (p. ej. al destruirse): se sincroniza el estado.
261
+ this.rendered.set(false);
262
+ if (this.open()) this.open.set(false);
263
+ }
264
+
265
+ protected onKeydown(event: KeyboardEvent): void {
266
+ if (event.key === 'Escape' && !event.altKey && !event.ctrlKey && !event.metaKey) {
267
+ event.preventDefault();
268
+ this.close();
269
+ }
270
+ }
271
+
272
+ protected onPositionChange({ connectionPair }: ConnectedOverlayPositionChange): void {
273
+ const side = mimiPlacedSide(connectionPair);
274
+ this.placedSide.set(side);
275
+ // La flecha apunta al centro del trigger.
276
+ const trigger = this.trigger()?.element.getBoundingClientRect();
277
+ const panel = this.panel()?.nativeElement.getBoundingClientRect();
278
+ this.arrowOffset.set(trigger && panel ? mimiArrowOffset(trigger, panel, side) : null);
279
+ }
280
+
281
+ /** Al abrir, el foco va al primer elemento enfocable del panel o, si no hay, al panel. */
282
+ private focusPanel(): void {
283
+ afterNextRender(
284
+ {
285
+ write: () => {
286
+ const panel = this.panel()?.nativeElement;
287
+ if (!panel || this.state() !== 'open') return;
288
+ this.warnMissingLabel();
289
+ const first = Array.from(panel.querySelectorAll<HTMLElement>('*')).find(
290
+ (element) => this.checker.isFocusable(element) && this.checker.isTabbable(element),
291
+ );
292
+ (first ?? panel).focus();
293
+ },
294
+ },
295
+ { injector: this.injector },
296
+ );
297
+ }
298
+
299
+ private warnMissingLabel(): void {
300
+ if (!isDevMode() || this.warnedLabel) return;
301
+ if (this.ariaLabel() || this.titleId()) return;
302
+ this.warnedLabel = true;
303
+ console.warn(
304
+ '[Mimi] Popover: el panel no tiene nombre accesible. Agrega aria-label="…" al ' +
305
+ '<mimi-popover> o marca su título con mimiPopoverTitle.',
306
+ );
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Botón que abre y cierra el popover: `<button mimiBtn mimiPopoverTrigger>`. Lleva
312
+ * `aria-haspopup="dialog"`, `aria-expanded` y, abierto, `aria-controls`.
313
+ */
314
+ @Directive({
315
+ selector: '[mimiPopoverTrigger]',
316
+ host: {
317
+ 'aria-haspopup': 'dialog',
318
+ '[attr.aria-expanded]': 'popover.open()',
319
+ '[attr.aria-controls]': 'popover.open() ? popover.panelId : null',
320
+ '[attr.data-state]': 'popover.open() ? "open" : "closed"',
321
+ '(click)': 'popover.toggle()',
322
+ },
323
+ })
324
+ export class MimiPopoverTrigger {
325
+ protected readonly popover = inject(MimiPopover);
326
+ readonly element = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
327
+ }
328
+
329
+ /**
330
+ * Contenido del popover: `<ng-template mimiPopoverContent>`. Se crea al abrir y se destruye al
331
+ * cerrar. El `class` y el `label` del panel van en `<mimi-popover>`: Angular no pasa un `class`
332
+ * estático de un ng-template a la directiva.
333
+ */
334
+ @Directive({ selector: 'ng-template[mimiPopoverContent]' })
335
+ export class MimiPopoverContent {
336
+ readonly templateRef = inject(TemplateRef);
337
+ }
338
+
339
+ /** Título del panel: le da su nombre accesible (aria-labelledby). */
340
+ @Directive({
341
+ selector: '[mimiPopoverTitle]',
342
+ host: { '[id]': 'id', '[class]': 'classes()' },
343
+ })
344
+ export class MimiPopoverTitle {
345
+ private readonly popover = inject(MimiPopover);
346
+ private readonly destroyRef = inject(DestroyRef);
347
+ readonly id = `mimi-popover-title-${nextId++}`;
348
+ readonly userClass = input('', { alias: 'class' });
349
+ protected readonly classes = computed(() =>
350
+ cn('text-[15px] leading-tight font-bold', this.userClass()),
351
+ );
352
+
353
+ constructor() {
354
+ this.popover.titleId.set(this.id);
355
+ this.destroyRef.onDestroy(() => {
356
+ if (this.popover.titleId() === this.id) this.popover.titleId.set(null);
357
+ });
358
+ }
359
+ }
360
+
361
+ /** Descripción del panel (aria-describedby). */
362
+ @Directive({
363
+ selector: '[mimiPopoverDescription]',
364
+ host: { '[id]': 'id', '[class]': 'classes()' },
365
+ })
366
+ export class MimiPopoverDescription {
367
+ private readonly popover = inject(MimiPopover);
368
+ private readonly destroyRef = inject(DestroyRef);
369
+ readonly id = `mimi-popover-description-${nextId++}`;
370
+ readonly userClass = input('', { alias: 'class' });
371
+ protected readonly classes = computed(() =>
372
+ cn('text-[13px] text-muted-foreground', this.userClass()),
373
+ );
374
+
375
+ constructor() {
376
+ this.popover.descriptionId.set(this.id);
377
+ this.destroyRef.onDestroy(() => {
378
+ if (this.popover.descriptionId() === this.id) this.popover.descriptionId.set(null);
379
+ });
380
+ }
381
+ }
382
+
383
+ /** Cierra el popover al hacer clic (p. ej. «Cancelar» o «Enviar»). El foco vuelve al trigger. */
384
+ @Directive({
385
+ selector: '[mimiPopoverClose]',
386
+ host: { '(click)': 'popover.close()' },
387
+ })
388
+ export class MimiPopoverClose {
389
+ protected readonly popover = inject(MimiPopover);
390
+ }
391
+
392
+ export const MimiPopoverImports = [
393
+ MimiPopover,
394
+ MimiPopoverTrigger,
395
+ MimiPopoverContent,
396
+ MimiPopoverTitle,
397
+ MimiPopoverDescription,
398
+ MimiPopoverClose,
399
+ ] as const;
@@ -0,0 +1,12 @@
1
+ // Solo la API pública: MimiSelectList y MimiSelectOutlet son internos (se quedan en './select').
2
+ export {
3
+ type MimiOption,
4
+ type MimiOptionGroup,
5
+ MimiSelect,
6
+ type MimiSelectFilter,
7
+ MimiSelectHighlight,
8
+ MimiSelectImports,
9
+ MimiSelectOption,
10
+ type MimiSelectOptionContext,
11
+ } from './select';
12
+ export * from './select-texts';
@@ -0,0 +1,62 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /*
4
+ * Textos de Select, en archivo propio y sin @angular/aria ni @angular/cdk: así se pueden proveer
5
+ * en `app.config.ts` sin llevar el Select al chunk inicial.
6
+ */
7
+
8
+ /** Textos que Select muestra o anuncia. Los que dependen de un dato son funciones. */
9
+ export interface MimiSelectText {
10
+ /** Placeholder y nombre accesible del buscador, si no se da `searchPlaceholder`. */
11
+ search: string;
12
+ /** Contador del buscador: «3 resultados». */
13
+ results: (count: number) => string;
14
+ /** Título cuando no hay opciones que mostrar. */
15
+ noResults: string;
16
+ /** Detalle cuando la búsqueda no coincide con ninguna opción. */
17
+ noResultsFor: (query: string) => string;
18
+ /** Texto del estado de carga. */
19
+ loading: string;
20
+ /** Nombre accesible del botón de limpiar. */
21
+ clear: string;
22
+ }
23
+
24
+ /** Textos predeterminados (inglés). */
25
+ export const MIMI_SELECT_TEXT_EN: MimiSelectText = {
26
+ search: 'Search…',
27
+ results: (count) => `${count} ${count === 1 ? 'result' : 'results'}`,
28
+ noResults: 'No results',
29
+ noResultsFor: (query) => `No options match “${query}”.`,
30
+ loading: 'Loading…',
31
+ clear: 'Clear selection',
32
+ };
33
+
34
+ /** Textos en español: `provideMimiSelectText(MIMI_SELECT_TEXT_ES)`. */
35
+ export const MIMI_SELECT_TEXT_ES: MimiSelectText = {
36
+ search: 'Buscar…',
37
+ results: (count) => `${count} ${count === 1 ? 'resultado' : 'resultados'}`,
38
+ noResults: 'Sin resultados',
39
+ noResultsFor: (query) => `No hay opciones que coincidan con «${query}».`,
40
+ loading: 'Cargando…',
41
+ clear: 'Limpiar selección',
42
+ };
43
+
44
+ /** Textos activos. Sin `provideMimiSelectText`, los predeterminados en inglés. */
45
+ export const MIMI_SELECT_TEXT = new InjectionToken<MimiSelectText>('MIMI_SELECT_TEXT', {
46
+ providedIn: 'root',
47
+ factory: () => MIMI_SELECT_TEXT_EN,
48
+ });
49
+
50
+ /**
51
+ * Cambia los textos de Select. Lo que no definas sigue en inglés:
52
+ *
53
+ * ```ts
54
+ * provideMimiSelectText(MIMI_SELECT_TEXT_ES)
55
+ * provideMimiSelectText({ ...MIMI_SELECT_TEXT_ES, loading: 'Cargando países…' })
56
+ * ```
57
+ *
58
+ * Va en `app.config.ts` o en los `providers` de un componente o ruta.
59
+ */
60
+ export function provideMimiSelectText(text: Partial<MimiSelectText>): Provider {
61
+ return { provide: MIMI_SELECT_TEXT, useValue: { ...MIMI_SELECT_TEXT_EN, ...text } };
62
+ }