@mimi-ng/cli 0.2.0 → 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 (55) hide show
  1. package/README.md +15 -1
  2. package/dist/registry.json +274 -14
  3. package/dist/templates/components/calendar/calendar-texts.ts +72 -0
  4. package/dist/templates/components/calendar/calendar.ts +588 -0
  5. package/dist/templates/components/calendar/dates.ts +181 -0
  6. package/dist/templates/components/calendar/index.ts +3 -0
  7. package/dist/templates/components/chip/chip-texts.ts +40 -0
  8. package/dist/templates/components/chip/chip.ts +116 -0
  9. package/dist/templates/components/chip/index.ts +2 -0
  10. package/dist/templates/components/color-picker/color-picker-texts.ts +57 -0
  11. package/dist/templates/components/color-picker/color-picker.ts +718 -0
  12. package/dist/templates/components/color-picker/color.ts +157 -0
  13. package/dist/templates/components/color-picker/index.ts +3 -0
  14. package/dist/templates/components/combobox/combobox-texts.ts +70 -0
  15. package/dist/templates/components/combobox/combobox.ts +798 -0
  16. package/dist/templates/components/combobox/index.ts +2 -0
  17. package/dist/templates/components/date-picker/date-picker.ts +240 -0
  18. package/dist/templates/components/date-picker/index.ts +3 -0
  19. package/dist/templates/components/date-range/date-range.ts +308 -0
  20. package/dist/templates/components/date-range/index.ts +1 -0
  21. package/dist/templates/components/file-upload/file-upload-texts.ts +77 -0
  22. package/dist/templates/components/file-upload/file-upload.ts +698 -0
  23. package/dist/templates/components/file-upload/index.ts +2 -0
  24. package/dist/templates/components/input-mask/index.ts +1 -0
  25. package/dist/templates/components/input-mask/input-mask.ts +319 -0
  26. package/dist/templates/components/input-otp/index.ts +1 -0
  27. package/dist/templates/components/input-otp/input-otp.ts +212 -0
  28. package/dist/templates/components/number-input/index.ts +2 -0
  29. package/dist/templates/components/number-input/number-input-texts.ts +46 -0
  30. package/dist/templates/components/number-input/number-input.ts +450 -0
  31. package/dist/templates/components/option-list/index.ts +11 -0
  32. package/dist/templates/components/option-list/option-list.ts +518 -0
  33. package/dist/templates/components/password/index.ts +2 -0
  34. package/dist/templates/components/password/password-texts.ts +68 -0
  35. package/dist/templates/components/password/password.ts +363 -0
  36. package/dist/templates/components/popover/popover.ts +12 -4
  37. package/dist/templates/components/progress/index.ts +1 -0
  38. package/dist/templates/components/progress/progress.ts +217 -0
  39. package/dist/templates/components/select/select.ts +3 -2
  40. package/dist/templates/components/slider/index.ts +2 -0
  41. package/dist/templates/components/slider/slider-texts.ts +26 -0
  42. package/dist/templates/components/slider/slider.ts +425 -0
  43. package/dist/templates/components/tags-input/index.ts +2 -0
  44. package/dist/templates/components/tags-input/tags-input-texts.ts +44 -0
  45. package/dist/templates/components/tags-input/tags-input.ts +303 -0
  46. package/dist/templates/components/toggle-group/index.ts +1 -0
  47. package/dist/templates/components/toggle-group/toggle-group.ts +406 -0
  48. package/dist/templates/theme/provider.ts +13 -0
  49. package/dist/templates/theme/theme-base.css +103 -0
  50. package/dist/templates/theme/types.ts +216 -0
  51. package/dist/templates/utils/overlay.ts +17 -4
  52. package/dist/ui/index.js +105 -1
  53. package/dist/ui/schema.json +56 -0
  54. package/dist/utils/write-files.js +12 -2
  55. package/package.json +1 -1
@@ -0,0 +1,518 @@
1
+ import { ComboboxWidget } from '@angular/aria/combobox';
2
+ import { Listbox, Option } from '@angular/aria/listbox';
3
+ import {
4
+ ChangeDetectionStrategy,
5
+ Component,
6
+ Directive,
7
+ type EmbeddedViewRef,
8
+ TemplateRef,
9
+ ViewContainerRef,
10
+ afterRenderEffect,
11
+ booleanAttribute,
12
+ computed,
13
+ effect,
14
+ inject,
15
+ input,
16
+ output,
17
+ signal,
18
+ untracked,
19
+ viewChild,
20
+ } from '@angular/core';
21
+ import { cn } from '@/components/ui/utils/cn';
22
+
23
+ /*
24
+ * Utilidades de lista (ítem `option-list` de la CLI, G2.8; decidido por defecto el 05/11/2026):
25
+ * el filtro sin tildes ni mayúsculas, el resaltado de la coincidencia y la lista de opciones con
26
+ * @angular/aria (Listbox dentro de un Combobox), con «cargando», «sin resultados» y una fila
27
+ * «Crear». Salen de lo que Select tiene por dentro (`normalize()`, `MimiSelectHighlight`,
28
+ * `MimiSelectList`); Select no se toca en la 0.3.0 y la M.9 lo migra a estas.
29
+ *
30
+ * Nada de aria sale en la API pública: la lista es de uso interno de los componentes de Mimi.
31
+ */
32
+
33
+ /** Una opción de una lista. Tiene la misma forma que `MimiOption` de Select. */
34
+ export interface MimiListOption<T = unknown> {
35
+ label: string;
36
+ value: T;
37
+ disabled?: boolean;
38
+ /** Texto atenuado de la derecha. */
39
+ hint?: string;
40
+ }
41
+
42
+ /** Un grupo de opciones con su etiqueta. La misma forma que `MimiOptionGroup` de Select. */
43
+ export interface MimiListOptionGroup<T = unknown> {
44
+ label: string;
45
+ options: readonly MimiListOption<T>[];
46
+ }
47
+
48
+ /** Decide si una opción coincide con lo escrito. */
49
+ export type MimiListFilter<T = unknown> = (option: MimiListOption<T>, query: string) => boolean;
50
+
51
+ /** Contexto de la plantilla propia de una opción: `let-option let-query="query"`. */
52
+ export interface MimiListOptionContext<T = unknown> {
53
+ $implicit: MimiListOption<T>;
54
+ /** Lo escrito (para resaltar). */
55
+ query: string;
56
+ }
57
+
58
+ /** Minúsculas y sin tildes, para comparar textos. */
59
+ export function mimiNormalizeText(text: string): string {
60
+ return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
61
+ }
62
+
63
+ /** Filtro predeterminado: la etiqueta contiene lo escrito, sin tildes ni mayúsculas. */
64
+ export const mimiDefaultListFilter: MimiListFilter = (option, query) =>
65
+ mimiNormalizeText(option.label).includes(mimiNormalizeText(query));
66
+
67
+ /** Una fila de la lista, con su clave interna (aria compara por identidad). */
68
+ export interface MimiListRow<T = unknown> {
69
+ /** Posición en la lista completa: estable aunque se filtre. */
70
+ key: string;
71
+ option: MimiListOption<T>;
72
+ }
73
+
74
+ /** Una sección: las opciones sueltas seguidas, o un grupo con su etiqueta. */
75
+ export interface MimiListSection<T = unknown> {
76
+ id: string;
77
+ label: string | null;
78
+ rows: MimiListRow<T>[];
79
+ }
80
+
81
+ /** Arma las secciones (con claves internas) a partir de opciones sueltas o en grupos. */
82
+ export function mimiListSections<T>(
83
+ options: readonly (MimiListOption<T> | MimiListOptionGroup<T>)[],
84
+ idPrefix: string,
85
+ ): MimiListSection<T>[] {
86
+ const sections: MimiListSection<T>[] = [];
87
+ let loose: MimiListSection<T> | null = null;
88
+ let index = 0;
89
+ const row = (option: MimiListOption<T>): MimiListRow<T> => ({ key: String(index++), option });
90
+ for (const item of options) {
91
+ const id = `${idPrefix}-group-${sections.length}`;
92
+ if ('options' in item) {
93
+ loose = null;
94
+ sections.push({ id, label: item.label, rows: item.options.map(row) });
95
+ } else {
96
+ if (!loose) sections.push((loose = { id, label: null, rows: [] }));
97
+ loose.rows.push(row(item));
98
+ }
99
+ }
100
+ return sections;
101
+ }
102
+
103
+ /** Deja en cada sección las filas que pasan el filtro, y quita las secciones vacías. */
104
+ export function mimiFilterSections<T>(
105
+ sections: MimiListSection<T>[],
106
+ query: string,
107
+ filter: MimiListFilter<T> = mimiDefaultListFilter as MimiListFilter<T>,
108
+ ): MimiListSection<T>[] {
109
+ if (!query) return sections;
110
+ return sections
111
+ .map((section) => ({ ...section, rows: section.rows.filter((r) => filter(r.option, query)) }))
112
+ .filter((section) => section.rows.length > 0);
113
+ }
114
+
115
+ /**
116
+ * Resalta en `text` la parte que coincide con `query` (negrita y subrayado, como el diseño):
117
+ * `<mimi-highlight [text]="option.label" [query]="query" />`.
118
+ */
119
+ @Component({
120
+ selector: 'mimi-highlight',
121
+ // Tres elementos pegados, sin @if: un salto de línea entre ellos sería un espacio en el texto.
122
+ template: `<span>{{ parts().before }}</span
123
+ ><mark class="bg-transparent font-bold text-inherit underline underline-offset-[3px]">{{
124
+ parts().match
125
+ }}</mark
126
+ ><span>{{ parts().after }}</span>`,
127
+ host: { '[class]': 'classes()' },
128
+ changeDetection: ChangeDetectionStrategy.OnPush,
129
+ })
130
+ export class MimiHighlight {
131
+ readonly text = input.required<string>();
132
+ readonly query = input('');
133
+ readonly userClass = input('', { alias: 'class' });
134
+
135
+ protected readonly classes = computed(() => cn('min-w-0 truncate', this.userClass()));
136
+
137
+ protected readonly parts = computed(() => {
138
+ const text = this.text();
139
+ const query = mimiNormalizeText(this.query());
140
+ const start = query ? mimiNormalizeText(text).indexOf(query) : -1;
141
+ if (start < 0) return { before: text, match: '', after: '' };
142
+ const end = start + query.length;
143
+ return { before: text.slice(0, start), match: text.slice(start, end), after: text.slice(end) };
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Uso interno: pinta una plantilla de opción y actualiza su contexto sin recrearla (lo mismo que
149
+ * NgTemplateOutlet, sin @angular/common).
150
+ * @internal No forma parte de la API de Mimi.
151
+ */
152
+ @Directive({ selector: '[mimiListOutlet]' })
153
+ export class MimiListOutlet {
154
+ readonly template = input.required<TemplateRef<MimiListOptionContext>>({
155
+ alias: 'mimiListOutlet',
156
+ });
157
+ readonly option = input.required<MimiListOption>({ alias: 'mimiListOutletOption' });
158
+ readonly query = input('', { alias: 'mimiListOutletQuery' });
159
+
160
+ private readonly viewContainer = inject(ViewContainerRef);
161
+ private view: EmbeddedViewRef<MimiListOptionContext> | null = null;
162
+ private rendered: TemplateRef<MimiListOptionContext> | null = null;
163
+
164
+ private readonly render = effect(() => {
165
+ const template = this.template();
166
+ const context = { $implicit: this.option(), query: this.query() };
167
+ untracked(() => {
168
+ if (this.view && this.rendered === template) {
169
+ Object.assign(this.view.context, context);
170
+ this.view.markForCheck();
171
+ return;
172
+ }
173
+ this.viewContainer.clear();
174
+ this.rendered = template;
175
+ this.view = this.viewContainer.createEmbeddedView(template, context);
176
+ });
177
+ });
178
+ }
179
+
180
+ /** Clave de la fila «Crear». No choca con las de las opciones (son números). */
181
+ export const MIMI_LIST_CREATE_KEY = 'create';
182
+
183
+ const ICON = {
184
+ viewBox: '0 0 24 24',
185
+ check: 'M20 6 9 17l-5-5',
186
+ plus: 'M5 12h14M12 5v14',
187
+ spinner: 'M21 12a9 9 0 1 1-6.219-8.56',
188
+ } as const;
189
+
190
+ const GROUP_LABEL_CLASSES =
191
+ 'px-2 pt-1.5 pb-1 text-[11px] font-semibold tracking-[.05em] text-muted-foreground uppercase';
192
+ const CREATE_CLASSES =
193
+ 'mt-[3px] border-t border-border text-info-soft-foreground data-[active=true]:bg-info-soft';
194
+ const BOX_CLASSES =
195
+ 'inline-flex size-4 shrink-0 items-center justify-center rounded-[var(--mimi-radius-sm)] border border-input text-primary-foreground [&_svg]:size-3';
196
+ const BOX_CHECKED_CLASSES = cn(BOX_CLASSES, 'border-primary bg-primary');
197
+ const SKELETON_CLASSES =
198
+ 'h-[26px] rounded-[var(--mimi-radius-sm)] bg-muted animate-mimi-pulse motion-reduce:animate-none';
199
+
200
+ /**
201
+ * Uso interno de los componentes con lista (Combobox; después Select, M.9): el `ngListbox` con
202
+ * las opciones, la fila «Crear», «cargando» y «sin resultados». Va dentro de un
203
+ * `ngComboboxPopup`: el listbox se registra en él por DI (`ngComboboxWidget`).
204
+ *
205
+ * El `value` del listbox es solo lo que se pinta (el check y `aria-selected`): el valor real es
206
+ * del componente. Rodeo (como en Select): aria desmarca la opción elegida al volver a elegirla y
207
+ * vacía el valor cuando la opción queda fuera del filtro; aquí se le vuelve a escribir.
208
+ * @internal No forma parte de la API de Mimi.
209
+ */
210
+ @Component({
211
+ selector: 'mimi-option-list',
212
+ imports: [Listbox, Option, ComboboxWidget, MimiListOutlet, MimiHighlight],
213
+ template: `
214
+ <div
215
+ ngListbox
216
+ ngComboboxWidget
217
+ #listbox="ngListbox"
218
+ focusMode="activedescendant"
219
+ selectionMode="explicit"
220
+ tabindex="-1"
221
+ [multi]="multi()"
222
+ [softDisabled]="false"
223
+ [id]="listId()"
224
+ [value]="paintedKeys()"
225
+ [activeDescendant]="listbox.activeDescendant()"
226
+ [attr.aria-label]="label() || null"
227
+ [attr.aria-busy]="loading() || null"
228
+ [class]="listClasses()"
229
+ (valueChange)="resync()"
230
+ (click)="onClick($event)"
231
+ (pointermove)="onPointerMove($event)"
232
+ (keydown)="keepFirst.set(false)"
233
+ >
234
+ @for (section of sections(); track section.id) {
235
+ <div
236
+ class="flex flex-col gap-px"
237
+ [attr.role]="section.label ? 'group' : 'presentation'"
238
+ [attr.aria-labelledby]="section.label ? section.id : null"
239
+ >
240
+ @if (section.label) {
241
+ <span role="presentation" [id]="section.id" [class]="groupLabelClasses">{{
242
+ section.label
243
+ }}</span>
244
+ }
245
+ @for (row of section.rows; track row.key) {
246
+ <div
247
+ ngOption
248
+ [id]="optionId(row.key)"
249
+ [value]="row.key"
250
+ [label]="row.option.label"
251
+ [disabled]="!!row.option.disabled"
252
+ [class]="optionClass()"
253
+ [attr.data-key]="row.key"
254
+ [attr.data-state]="isSelected(row.key) ? 'checked' : 'unchecked'"
255
+ >
256
+ @if (multi()) {
257
+ <span
258
+ aria-hidden="true"
259
+ data-slot="checkbox"
260
+ [class]="isSelected(row.key) ? boxCheckedClasses : boxClasses"
261
+ >
262
+ @if (isSelected(row.key)) {
263
+ <svg
264
+ fill="none"
265
+ stroke="currentColor"
266
+ stroke-width="3"
267
+ stroke-linecap="round"
268
+ stroke-linejoin="round"
269
+ [attr.viewBox]="icon.viewBox"
270
+ >
271
+ <path [attr.d]="icon.check" />
272
+ </svg>
273
+ }
274
+ </span>
275
+ }
276
+ @if (optionTemplate(); as template) {
277
+ <span class="flex min-w-0 flex-1 items-center gap-2">
278
+ <ng-container
279
+ [mimiListOutlet]="template"
280
+ [mimiListOutletOption]="row.option"
281
+ [mimiListOutletQuery]="query()"
282
+ />
283
+ </span>
284
+ } @else {
285
+ <mimi-highlight class="flex-1" [text]="row.option.label" [query]="query()" />
286
+ }
287
+ @if (row.option.hint) {
288
+ <span class="shrink-0 text-xs text-muted-foreground">{{ row.option.hint }}</span>
289
+ }
290
+ @if (!multi() && isSelected(row.key)) {
291
+ <svg
292
+ aria-hidden="true"
293
+ class="size-4 shrink-0"
294
+ fill="none"
295
+ stroke="currentColor"
296
+ stroke-width="2"
297
+ stroke-linecap="round"
298
+ stroke-linejoin="round"
299
+ [attr.viewBox]="icon.viewBox"
300
+ >
301
+ <path [attr.d]="icon.check" />
302
+ </svg>
303
+ }
304
+ </div>
305
+ }
306
+ </div>
307
+ }
308
+ @if (createLabel(); as create) {
309
+ <div
310
+ ngOption
311
+ data-slot="create"
312
+ [id]="optionId(createKey)"
313
+ [value]="createKey"
314
+ [label]="create"
315
+ [class]="createClasses()"
316
+ [attr.data-key]="createKey"
317
+ >
318
+ <svg
319
+ aria-hidden="true"
320
+ class="size-4 shrink-0"
321
+ fill="none"
322
+ stroke="currentColor"
323
+ stroke-width="2"
324
+ stroke-linecap="round"
325
+ stroke-linejoin="round"
326
+ [attr.viewBox]="icon.viewBox"
327
+ >
328
+ <path [attr.d]="icon.plus" />
329
+ </svg>
330
+ <span class="min-w-0 flex-1 truncate">{{ create }}</span>
331
+ </div>
332
+ }
333
+ </div>
334
+ @if (loading()) {
335
+ <div aria-hidden="true" data-slot="loading" class="flex flex-col gap-2 p-2">
336
+ <span class="flex items-center gap-2 px-1 py-0.5 text-[13px] text-muted-foreground">
337
+ <svg
338
+ class="size-3.5 shrink-0 animate-[mimi-spin_0.8s_linear_infinite]"
339
+ fill="none"
340
+ stroke="currentColor"
341
+ stroke-width="2"
342
+ stroke-linecap="round"
343
+ stroke-linejoin="round"
344
+ [attr.viewBox]="icon.viewBox"
345
+ >
346
+ <path [attr.d]="icon.spinner" />
347
+ </svg>
348
+ {{ loadingText() }}
349
+ </span>
350
+ <div [class]="skeletonClasses"></div>
351
+ <div class="w-[72%]" [class]="skeletonClasses"></div>
352
+ </div>
353
+ }
354
+ <div
355
+ role="status"
356
+ data-slot="status"
357
+ [class]="empty() ? 'flex flex-col items-center gap-1 px-3 py-[18px] text-center' : 'sr-only'"
358
+ >
359
+ @if (empty()) {
360
+ <svg
361
+ aria-hidden="true"
362
+ class="size-5 text-muted-foreground"
363
+ fill="none"
364
+ stroke="currentColor"
365
+ stroke-width="2"
366
+ stroke-linecap="round"
367
+ stroke-linejoin="round"
368
+ [attr.viewBox]="icon.viewBox"
369
+ >
370
+ <path d="m13.5 8.5-5 5" />
371
+ <path d="m8.5 8.5 5 5" />
372
+ <circle cx="11" cy="11" r="8" />
373
+ <path d="m21 21-4.3-4.3" />
374
+ </svg>
375
+ <span class="text-sm font-semibold">{{ emptyTitle() }}</span>
376
+ @if (emptyText()) {
377
+ <span class="text-[13px] text-muted-foreground">{{ emptyText() }}</span>
378
+ }
379
+ } @else {
380
+ {{ status() }}
381
+ }
382
+ </div>
383
+ `,
384
+ host: { style: 'display: contents' },
385
+ changeDetection: ChangeDetectionStrategy.OnPush,
386
+ })
387
+ export class MimiOptionList {
388
+ readonly sections = input.required<MimiListSection[]>();
389
+ /** Claves elegidas (una en simple; varias con `multi`). */
390
+ readonly selectedKeys = input<readonly string[]>([]);
391
+ readonly multi = input(false, { transform: booleanAttribute });
392
+ /** Lo escrito: resalta la coincidencia. */
393
+ readonly query = input('');
394
+ readonly loading = input(false, { transform: booleanAttribute });
395
+ /** Texto de «cargando» (visible). Los lectores reciben `aria-busy` y el `status`. */
396
+ readonly loadingText = input('');
397
+ /** Título y texto de «sin resultados». Sin título, la lista vacía no muestra nada. */
398
+ readonly emptyTitle = input('');
399
+ readonly emptyText = input('');
400
+ /** Texto de la región `status` cuando hay resultados (por ejemplo, «3 resultados»). */
401
+ readonly status = input('');
402
+ /** Con texto, agrega al final la fila «Crear» (clave `MIMI_LIST_CREATE_KEY`). */
403
+ readonly createLabel = input<string | null>(null);
404
+ readonly optionTemplate = input<TemplateRef<MimiListOptionContext> | null>(null);
405
+ readonly listId = input.required<string>();
406
+ readonly label = input('');
407
+ /** Clases de la lista y de cada opción (literales, del componente que la usa). */
408
+ readonly listClass = input('');
409
+ readonly optionClass = input('');
410
+ /** Se eligió con el puntero una opción habilitada (o «Crear»). */
411
+ readonly pick = output<string>();
412
+
413
+ private readonly listbox = viewChild.required<Listbox<string>>('listbox');
414
+
415
+ protected readonly icon = ICON;
416
+ protected readonly createKey = MIMI_LIST_CREATE_KEY;
417
+ protected readonly groupLabelClasses = GROUP_LABEL_CLASSES;
418
+ protected readonly createClasses = computed(() => cn(this.optionClass(), CREATE_CLASSES));
419
+ protected readonly boxClasses = BOX_CLASSES;
420
+ protected readonly boxCheckedClasses = BOX_CHECKED_CLASSES;
421
+ protected readonly skeletonClasses = SKELETON_CLASSES;
422
+
423
+ private readonly keys = computed(() => {
424
+ const keys = this.sections().flatMap((section) => section.rows.map((row) => row.key));
425
+ if (this.createLabel()) keys.push(MIMI_LIST_CREATE_KEY);
426
+ return keys;
427
+ });
428
+ protected readonly empty = computed(
429
+ () => !this.loading() && this.keys().length === 0 && !!this.emptyTitle(),
430
+ );
431
+ protected readonly listClasses = computed(() =>
432
+ cn('flex flex-col gap-px overflow-y-auto p-1 outline-none', this.listClass(), {
433
+ hidden: this.keys().length === 0,
434
+ }),
435
+ );
436
+
437
+ /** Cambia para volver a escribir el valor en el listbox (un arreglo nuevo cada vez). */
438
+ private readonly version = signal(0);
439
+ protected readonly paintedKeys = computed(() => {
440
+ this.version();
441
+ const keys = this.keys();
442
+ return this.selectedKeys().filter((key) => keys.includes(key));
443
+ });
444
+
445
+ protected isSelected(key: string): boolean {
446
+ return this.selectedKeys().includes(key);
447
+ }
448
+
449
+ /** El activo cambió con el puntero: no hace falta traerlo a la vista. */
450
+ private byPointer = false;
451
+
452
+ /**
453
+ * La primera opción habilitada sigue activa mientras se escribe, hasta que el usuario mueva la
454
+ * activa con el teclado o el puntero (rodeo `keepFirst` de Select).
455
+ */
456
+ protected readonly keepFirst = signal(false);
457
+
458
+ constructor() {
459
+ afterRenderEffect({
460
+ read: () => {
461
+ if (!this.keepFirst()) return;
462
+ this.keys();
463
+ const listbox = this.listbox();
464
+ listbox.activeDescendant();
465
+ untracked(() => listbox.gotoFirst());
466
+ },
467
+ });
468
+ afterRenderEffect({
469
+ read: () => {
470
+ const listbox = this.listbox();
471
+ if (!listbox.activeDescendant()) return;
472
+ if (this.byPointer) this.byPointer = false;
473
+ else listbox.scrollActiveItemIntoView();
474
+ },
475
+ });
476
+ }
477
+
478
+ protected optionId(key: string): string {
479
+ return `${this.listId()}-${key}`;
480
+ }
481
+
482
+ protected resync(): void {
483
+ this.version.update((version) => version + 1);
484
+ }
485
+
486
+ /** Clave de la opción activa (la del `aria-activedescendant`), o null. */
487
+ activeKey(): string | null {
488
+ const id = this.listbox().activeDescendant();
489
+ return id ? id.slice(this.listId().length + 1) : null;
490
+ }
491
+
492
+ /** Activa la primera opción habilitada (al escribir). */
493
+ gotoFirst(): void {
494
+ this.keepFirst.set(true);
495
+ }
496
+
497
+ private optionAt(event: Event): HTMLElement | null {
498
+ return (event.target as Element | null)?.closest<HTMLElement>('[role="option"]') ?? null;
499
+ }
500
+
501
+ /** Rodeo: el listbox no avisa qué opción se eligió. Solo cuentan las habilitadas. */
502
+ protected onClick(event: MouseEvent): void {
503
+ const option = this.optionAt(event);
504
+ const key = option?.dataset['key'];
505
+ if (key !== undefined && option?.getAttribute('aria-disabled') !== 'true') this.pick.emit(key);
506
+ }
507
+
508
+ /** El puntero activa la opción, como en el diseño (aria solo la activa con el teclado). */
509
+ protected onPointerMove(event: PointerEvent): void {
510
+ const option = this.optionAt(event);
511
+ const key = option?.dataset['key'];
512
+ if (key === undefined || option?.dataset['active'] === 'true') return;
513
+ if (option?.getAttribute('aria-disabled') === 'true') return;
514
+ this.byPointer = true;
515
+ this.keepFirst.set(false);
516
+ this.listbox().gotoIndex(this.keys().indexOf(key));
517
+ }
518
+ }
@@ -0,0 +1,2 @@
1
+ export * from './password';
2
+ export * from './password-texts';
@@ -0,0 +1,68 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /*
4
+ * Textos de Password, en archivo propio: así se pueden proveer en `app.config.ts` sin llevar el
5
+ * componente al chunk inicial (como Select, G1.5).
6
+ */
7
+
8
+ export interface MimiPasswordText {
9
+ /**
10
+ * Nombre fijo del botón; el estado lo da `aria-pressed` (guía APG, «Button»: un botón de
11
+ * alternar no cambia de nombre).
12
+ */
13
+ show: string;
14
+ /** Nombres de los niveles del medidor. */
15
+ weak: string;
16
+ medium: string;
17
+ strong: string;
18
+ /** Lo que anuncia el lector cuando cambia el nivel. */
19
+ announce: (level: string) => string;
20
+ /** Las cuatro reglas por defecto. */
21
+ minLength: string;
22
+ uppercase: string;
23
+ number: string;
24
+ symbol: string;
25
+ }
26
+
27
+ /** Textos predeterminados (inglés). */
28
+ export const MIMI_PASSWORD_TEXT_EN: MimiPasswordText = {
29
+ show: 'Show password',
30
+ weak: 'Weak',
31
+ medium: 'Medium',
32
+ strong: 'Strong',
33
+ announce: (level) => `Password strength: ${level}`,
34
+ minLength: '8 characters or more',
35
+ uppercase: 'An uppercase letter',
36
+ number: 'A number',
37
+ symbol: 'A symbol',
38
+ };
39
+
40
+ /** Textos en español: `provideMimiPasswordText(MIMI_PASSWORD_TEXT_ES)`. */
41
+ export const MIMI_PASSWORD_TEXT_ES: MimiPasswordText = {
42
+ show: 'Mostrar contraseña',
43
+ weak: 'Débil',
44
+ medium: 'Media',
45
+ strong: 'Fuerte',
46
+ announce: (level) => `Seguridad de la contraseña: ${level}`,
47
+ minLength: '8 caracteres o más',
48
+ uppercase: 'Una mayúscula',
49
+ number: 'Un número',
50
+ symbol: 'Un símbolo',
51
+ };
52
+
53
+ /** Textos activos. Sin `provideMimiPasswordText`, los predeterminados en inglés. */
54
+ export const MIMI_PASSWORD_TEXT = new InjectionToken<MimiPasswordText>('MIMI_PASSWORD_TEXT', {
55
+ providedIn: 'root',
56
+ factory: () => MIMI_PASSWORD_TEXT_EN,
57
+ });
58
+
59
+ /**
60
+ * Cambia los textos de Password. Lo que no definas sigue en inglés:
61
+ *
62
+ * ```ts
63
+ * provideMimiPasswordText(MIMI_PASSWORD_TEXT_ES)
64
+ * ```
65
+ */
66
+ export function provideMimiPasswordText(text: Partial<MimiPasswordText>): Provider {
67
+ return { provide: MIMI_PASSWORD_TEXT, useValue: { ...MIMI_PASSWORD_TEXT_EN, ...text } };
68
+ }