@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,1085 @@
1
+ import { Combobox, ComboboxPopup, ComboboxWidget } from '@angular/aria/combobox';
2
+ import { Listbox, Option } from '@angular/aria/listbox';
3
+ import { CdkConnectedOverlay, type ConnectedOverlayPositionChange } from '@angular/cdk/overlay';
4
+ import {
5
+ ChangeDetectionStrategy,
6
+ Component,
7
+ Directive,
8
+ ElementRef,
9
+ type EmbeddedViewRef,
10
+ Injector,
11
+ TemplateRef,
12
+ ViewContainerRef,
13
+ afterNextRender,
14
+ afterRenderEffect,
15
+ booleanAttribute,
16
+ computed,
17
+ contentChild,
18
+ effect,
19
+ inject,
20
+ input,
21
+ linkedSignal,
22
+ model,
23
+ output,
24
+ signal,
25
+ untracked,
26
+ viewChild,
27
+ } from '@angular/core';
28
+ import type { FormValueControl } from '@angular/forms/signals';
29
+ import { cn } from '@/components/ui/utils/cn';
30
+ import { buttonFocusStyles } from '@/components/ui/utils/control-styles';
31
+ import {
32
+ MIMI_FIELD_CONTROL,
33
+ type MimiFieldControl,
34
+ injectFieldState,
35
+ } from '@/components/ui/utils/field-state';
36
+ import {
37
+ MIMI_OVERLAY_ANIMATION,
38
+ MIMI_VIEWPORT_MARGIN,
39
+ type MimiOverlayAlign,
40
+ type MimiOverlaySide,
41
+ mimiConnectedPositions,
42
+ mimiPlacedSide,
43
+ mimiWaitForExit,
44
+ } from '@/components/ui/utils/overlay';
45
+ import { MIMI_SELECT_TEXT } from './select-texts';
46
+
47
+ /*
48
+ * Select (docs/design/Mimi F2 Select.dc.html; prefijo de tokens `select`, spec 13): disparador del
49
+ * alto de Input, panel a 8px con el ancho del disparador, opción de 34px, buscador de 40px,
50
+ * limpiar, grupos, opciones deshabilitadas, sin resultados y cargando.
51
+ *
52
+ * Por dentro: @angular/aria (Combobox + Listbox) da los roles, aria-activedescendant y el teclado
53
+ * de la lista; @angular/cdk/overlay posiciona el panel (popover nativo 'inline'). Nada de aria ni
54
+ * del CDK sale en la API pública (decisión 4.3). Lo que aria no hace y pone Mimi está marcado con
55
+ * «Rodeo» (plan, G1.5b).
56
+ */
57
+
58
+ /** Una opción de Select. `hint` es el texto atenuado de la derecha («Sin cobertura»). */
59
+ export interface MimiOption<T = unknown> {
60
+ label: string;
61
+ value: T;
62
+ disabled?: boolean;
63
+ hint?: string;
64
+ }
65
+
66
+ /** Un grupo de opciones con su etiqueta. */
67
+ export interface MimiOptionGroup<T = unknown> {
68
+ label: string;
69
+ options: readonly MimiOption<T>[];
70
+ }
71
+
72
+ /** Decide si una opción coincide con la búsqueda. */
73
+ export type MimiSelectFilter<T = unknown> = (option: MimiOption<T>, query: string) => boolean;
74
+
75
+ /** Contexto de `<ng-template mimiSelectOption let-option let-query="query" let-trigger="trigger">`. */
76
+ export interface MimiSelectOptionContext<T = unknown> {
77
+ $implicit: MimiOption<T>;
78
+ /** Texto del buscador (vacío en el disparador). */
79
+ query: string;
80
+ /** `true` cuando se pinta el valor elegido en el disparador. */
81
+ trigger: boolean;
82
+ }
83
+
84
+ /** Distancia entre el disparador y el panel (diseño: 8px). */
85
+ const OFFSET = 8;
86
+ /** Pausa que reinicia la búsqueda por letra de la lista (la de aria por defecto). */
87
+ const TYPEAHEAD_DELAY = 500;
88
+
89
+ let nextId = 0;
90
+
91
+ /** Minúsculas y sin tildes, para comparar textos. */
92
+ function normalize(text: string): string {
93
+ return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
94
+ }
95
+
96
+ /** Filtro predeterminado: la etiqueta contiene la búsqueda, sin tildes ni mayúsculas. */
97
+ const defaultFilter: MimiSelectFilter = (option, query) =>
98
+ normalize(option.label).includes(normalize(query));
99
+
100
+ interface Row {
101
+ /** Clave interna: posición en la lista completa (estable aunque se filtre). */
102
+ key: string;
103
+ option: MimiOption;
104
+ }
105
+
106
+ interface Section {
107
+ id: string;
108
+ label: string | null;
109
+ rows: Row[];
110
+ }
111
+
112
+ const ICON = {
113
+ viewBox: '0 0 24 24',
114
+ check: 'M20 6 9 17l-5-5',
115
+ chevron: 'm6 9 6 6 6-6',
116
+ spinner: 'M21 12a9 9 0 1 1-6.219-8.56',
117
+ } as const;
118
+
119
+ /**
120
+ * Resalta en `text` la parte que coincide con `query` (negrita y subrayado, como el diseño). Lo
121
+ * usa la lista, y sirve dentro de una plantilla `mimiSelectOption`:
122
+ * `<mimi-select-highlight [text]="option.label" [query]="query" />`.
123
+ */
124
+ @Component({
125
+ selector: 'mimi-select-highlight',
126
+ // Tres elementos pegados, sin @if: un salto de línea entre ellos sería un espacio en el texto.
127
+ template: `<span>{{ parts().before }}</span
128
+ ><mark class="bg-transparent font-bold text-inherit underline underline-offset-[3px]">{{
129
+ parts().match
130
+ }}</mark
131
+ ><span>{{ parts().after }}</span>`,
132
+ host: { '[class]': 'classes()' },
133
+ changeDetection: ChangeDetectionStrategy.OnPush,
134
+ })
135
+ export class MimiSelectHighlight {
136
+ readonly text = input.required<string>();
137
+ readonly query = input('');
138
+ readonly userClass = input('', { alias: 'class' });
139
+
140
+ protected readonly classes = computed(() => cn('min-w-0 truncate', this.userClass()));
141
+
142
+ protected readonly parts = computed(() => {
143
+ const text = this.text();
144
+ const query = normalize(this.query());
145
+ const start = query ? normalize(text).indexOf(query) : -1;
146
+ if (start < 0) return { before: text, match: '', after: '' };
147
+ const end = start + query.length;
148
+ return { before: text.slice(0, start), match: text.slice(start, end), after: text.slice(end) };
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Contenido propio de cada opción, en la lista y en el disparador:
154
+ *
155
+ * ```html
156
+ * <ng-template mimiSelectOption let-option let-query="query" let-trigger="trigger">
157
+ * <span class="font-mono text-[11px]">{{ option.value }}</span>
158
+ * <mimi-select-highlight [text]="option.label" [query]="query" />
159
+ * </ng-template>
160
+ * ```
161
+ */
162
+ @Directive({ selector: 'ng-template[mimiSelectOption]' })
163
+ export class MimiSelectOption {
164
+ readonly templateRef = inject<TemplateRef<MimiSelectOptionContext>>(TemplateRef);
165
+
166
+ static ngTemplateContextGuard(
167
+ _directive: MimiSelectOption,
168
+ context: unknown,
169
+ ): context is MimiSelectOptionContext {
170
+ return true;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Uso interno (no está en los Imports; se exporta porque el compilador lo exige): pinta la
176
+ * plantilla `mimiSelectOption` y actualiza su contexto sin recrearla. Hace lo mismo que
177
+ * NgTemplateOutlet, sin @angular/common.
178
+ * @internal No está en el índice; no forma parte de la API de Mimi.
179
+ */
180
+ @Directive({ selector: '[mimiSelectOutlet]' })
181
+ export class MimiSelectOutlet {
182
+ readonly template = input.required<TemplateRef<MimiSelectOptionContext>>({
183
+ alias: 'mimiSelectOutlet',
184
+ });
185
+ readonly option = input.required<MimiOption>({ alias: 'mimiSelectOutletOption' });
186
+ readonly query = input('', { alias: 'mimiSelectOutletQuery' });
187
+ readonly trigger = input(false, { alias: 'mimiSelectOutletTrigger' });
188
+
189
+ private readonly viewContainer = inject(ViewContainerRef);
190
+ private view: EmbeddedViewRef<MimiSelectOptionContext> | null = null;
191
+ private rendered: TemplateRef<MimiSelectOptionContext> | null = null;
192
+
193
+ private readonly render = effect(() => {
194
+ const template = this.template();
195
+ const context = { $implicit: this.option(), query: this.query(), trigger: this.trigger() };
196
+ untracked(() => {
197
+ if (this.view && this.rendered === template) {
198
+ Object.assign(this.view.context, context);
199
+ this.view.markForCheck();
200
+ return;
201
+ }
202
+ this.viewContainer.clear();
203
+ this.rendered = template;
204
+ this.view = this.viewContainer.createEmbeddedView(template, context);
205
+ });
206
+ });
207
+ }
208
+
209
+ const LIST_CLASSES = 'flex max-h-[280px] flex-col gap-px overflow-y-auto p-1 outline-none';
210
+ const GROUP_LABEL_CLASSES =
211
+ 'px-2 pt-1.5 pb-1 text-[11px] font-semibold tracking-[.05em] text-muted-foreground uppercase';
212
+ const OPTION_CLASSES = cn(
213
+ 'flex h-[var(--mimi-select-option-height,34px)] shrink-0 cursor-pointer items-center gap-2 px-2 text-sm select-none',
214
+ 'rounded-[var(--mimi-select-option-radius,var(--mimi-radius-sm))]',
215
+ 'transition-[background-color] duration-120 ease-[ease] active:scale-[0.985]',
216
+ 'data-[active=true]:bg-[color:var(--mimi-select-option-hover,var(--mimi-accent))]',
217
+ 'aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:active:scale-100',
218
+ );
219
+ const SKELETON_CLASSES =
220
+ 'mx-2 h-7 rounded-[var(--mimi-radius-sm)] bg-muted animate-mimi-pulse motion-reduce:animate-none';
221
+
222
+ /**
223
+ * Uso interno de Select (no está en los Imports): el `ngListbox` con las opciones, más los estados
224
+ * de carga y sin resultados. Es un componente aparte para que el listbox se registre, por DI, en
225
+ * el popup que lo contiene: el del disparador (sin buscador) o el del buscador.
226
+ *
227
+ * El `value` del listbox es solo lo que se pinta (el check y `aria-selected`): el valor real es
228
+ * el de Select. Rodeo: aria desmarca la opción elegida al volver a elegirla y vacía el valor
229
+ * cuando la opción queda fuera del filtro; aquí se le vuelve a escribir (`resync`).
230
+ * @internal No está en el índice; no forma parte de la API de Mimi.
231
+ */
232
+ @Component({
233
+ selector: 'mimi-select-list',
234
+ imports: [Listbox, Option, ComboboxWidget, MimiSelectOutlet, MimiSelectHighlight],
235
+ template: `
236
+ <div
237
+ ngListbox
238
+ ngComboboxWidget
239
+ #listbox="ngListbox"
240
+ focusMode="activedescendant"
241
+ selectionMode="explicit"
242
+ tabindex="-1"
243
+ [softDisabled]="false"
244
+ [typeaheadDelay]="typeaheadDelay"
245
+ [id]="listId()"
246
+ [value]="selectedKeys()"
247
+ [activeDescendant]="listbox.activeDescendant()"
248
+ [attr.aria-label]="labelledBy() ? null : label() || null"
249
+ [attr.aria-labelledby]="labelledBy() || null"
250
+ [class]="listClasses()"
251
+ (valueChange)="resync()"
252
+ (click)="onClick($event)"
253
+ (pointermove)="onPointerMove($event)"
254
+ (keydown)="keepFirst.set(false)"
255
+ >
256
+ @for (section of sections(); track section.id) {
257
+ <div
258
+ class="flex flex-col gap-px"
259
+ [attr.role]="section.label ? 'group' : 'presentation'"
260
+ [attr.aria-labelledby]="section.label ? section.id : null"
261
+ >
262
+ @if (section.label) {
263
+ <span role="presentation" [id]="section.id" [class]="groupLabelClasses">{{
264
+ section.label
265
+ }}</span>
266
+ }
267
+ @for (row of section.rows; track row.key) {
268
+ <div
269
+ ngOption
270
+ [id]="optionId(row.key)"
271
+ [value]="row.key"
272
+ [label]="row.option.label"
273
+ [disabled]="!!row.option.disabled"
274
+ [class]="optionClasses"
275
+ [attr.data-key]="row.key"
276
+ [attr.data-state]="row.key === selectedKey() ? 'checked' : 'unchecked'"
277
+ >
278
+ @if (optionTemplate(); as template) {
279
+ <span class="flex min-w-0 flex-1 items-center gap-2">
280
+ <ng-container
281
+ [mimiSelectOutlet]="template"
282
+ [mimiSelectOutletOption]="row.option"
283
+ [mimiSelectOutletQuery]="query()"
284
+ />
285
+ </span>
286
+ } @else {
287
+ <mimi-select-highlight class="flex-1" [text]="row.option.label" [query]="query()" />
288
+ }
289
+ @if (row.option.hint) {
290
+ <span class="shrink-0 text-xs text-muted-foreground">{{ row.option.hint }}</span>
291
+ }
292
+ @if (row.key === selectedKey()) {
293
+ <svg
294
+ aria-hidden="true"
295
+ class="size-4 shrink-0"
296
+ fill="none"
297
+ stroke="currentColor"
298
+ stroke-width="2"
299
+ stroke-linecap="round"
300
+ stroke-linejoin="round"
301
+ [attr.viewBox]="icon.viewBox"
302
+ >
303
+ <path [attr.d]="icon.check" />
304
+ </svg>
305
+ }
306
+ </div>
307
+ }
308
+ </div>
309
+ }
310
+ </div>
311
+ @if (loading()) {
312
+ <div aria-busy="true" class="flex flex-col gap-1.5 p-2">
313
+ <div class="flex items-center gap-2 px-2 py-1.5 text-[13px] text-muted-foreground">
314
+ <svg
315
+ aria-hidden="true"
316
+ class="size-3.5 shrink-0 animate-[mimi-spin_0.8s_linear_infinite]"
317
+ fill="none"
318
+ stroke="currentColor"
319
+ stroke-width="2"
320
+ stroke-linecap="round"
321
+ stroke-linejoin="round"
322
+ [attr.viewBox]="icon.viewBox"
323
+ >
324
+ <path [attr.d]="icon.spinner" />
325
+ </svg>
326
+ {{ text.loading }}
327
+ </div>
328
+ <div aria-hidden="true" [class]="skeletonClasses"></div>
329
+ <div aria-hidden="true" class="w-[70%]" [class]="skeletonClasses"></div>
330
+ <div aria-hidden="true" class="mb-1.5 w-[84%]" [class]="skeletonClasses"></div>
331
+ </div>
332
+ }
333
+ <div
334
+ role="status"
335
+ [class]="empty() ? 'flex flex-col items-center gap-1.5 px-4 py-6 text-center' : 'sr-only'"
336
+ >
337
+ @if (empty()) {
338
+ <span
339
+ aria-hidden="true"
340
+ class="inline-flex size-9 items-center justify-center rounded-full bg-muted text-muted-foreground"
341
+ >
342
+ <svg
343
+ class="size-[18px]"
344
+ fill="none"
345
+ stroke="currentColor"
346
+ stroke-width="2"
347
+ stroke-linecap="round"
348
+ stroke-linejoin="round"
349
+ [attr.viewBox]="icon.viewBox"
350
+ >
351
+ <path d="m13.5 8.5-5 5" />
352
+ <path d="m8.5 8.5 5 5" />
353
+ <circle cx="11" cy="11" r="8" />
354
+ <path d="m21 21-4.3-4.3" />
355
+ </svg>
356
+ </span>
357
+ <span class="text-sm font-semibold">{{ text.noResults }}</span>
358
+ @if (query()) {
359
+ <span class="text-[13px] text-muted-foreground">{{ text.noResultsFor(query()) }}</span>
360
+ }
361
+ }
362
+ </div>
363
+ `,
364
+ host: { style: 'display: contents' },
365
+ changeDetection: ChangeDetectionStrategy.OnPush,
366
+ })
367
+ export class MimiSelectList {
368
+ readonly sections = input.required<Section[]>();
369
+ /** Clave de la opción elegida, o null. */
370
+ readonly selectedKey = input<string | null>(null);
371
+ readonly query = input('');
372
+ readonly loading = input(false);
373
+ readonly optionTemplate = input<TemplateRef<MimiSelectOptionContext> | null>(null);
374
+ readonly listId = input.required<string>();
375
+ readonly label = input('');
376
+ readonly labelledBy = input<string | null | undefined>(null);
377
+ /** Se eligió una opción habilitada con el puntero. */
378
+ readonly pick = output<string>();
379
+
380
+ private readonly listbox = viewChild.required<Listbox<string>>('listbox');
381
+
382
+ protected readonly text = inject(MIMI_SELECT_TEXT);
383
+ protected readonly icon = ICON;
384
+ protected readonly typeaheadDelay = TYPEAHEAD_DELAY;
385
+ protected readonly groupLabelClasses = GROUP_LABEL_CLASSES;
386
+ protected readonly optionClasses = OPTION_CLASSES;
387
+ protected readonly skeletonClasses = SKELETON_CLASSES;
388
+
389
+ private readonly keys = computed(() =>
390
+ this.sections().flatMap((section) => section.rows.map((row) => row.key)),
391
+ );
392
+ protected readonly empty = computed(() => !this.loading() && this.keys().length === 0);
393
+ protected readonly listClasses = computed(() =>
394
+ cn(LIST_CLASSES, this.keys().length === 0 && 'hidden'),
395
+ );
396
+
397
+ /** Cambia para volver a escribir el valor en el listbox (un arreglo nuevo cada vez). */
398
+ private readonly version = signal(0);
399
+ protected readonly selectedKeys = computed(() => {
400
+ this.version();
401
+ const key = this.selectedKey();
402
+ return key !== null && this.keys().includes(key) ? [key] : [];
403
+ });
404
+
405
+ /** El activo cambió con el puntero: no hace falta traerlo a la vista. */
406
+ private byPointer = false;
407
+
408
+ /**
409
+ * La primera opción habilitada debe seguir activa (se está escribiendo en el buscador), hasta
410
+ * que el usuario mueva la activa con el teclado o el puntero.
411
+ */
412
+ protected readonly keepFirst = signal(false);
413
+
414
+ constructor() {
415
+ // Rodeo (choque): mientras no se haya usado el teclado en la lista, aria vuelve a activar la
416
+ // opción elegida cada vez que cambian las opciones. Mimi repone la primera cada vez.
417
+ afterRenderEffect({
418
+ read: () => {
419
+ if (!this.keepFirst()) return;
420
+ this.keys();
421
+ const listbox = this.listbox();
422
+ listbox.activeDescendant();
423
+ untracked(() => listbox.gotoFirst());
424
+ },
425
+ });
426
+ afterRenderEffect({
427
+ read: () => {
428
+ const listbox = this.listbox();
429
+ if (!listbox.activeDescendant()) return;
430
+ if (this.byPointer) this.byPointer = false;
431
+ else listbox.scrollActiveItemIntoView();
432
+ },
433
+ });
434
+ }
435
+
436
+ protected optionId(key: string): string {
437
+ return `${this.listId()}-${key}`;
438
+ }
439
+
440
+ protected resync(): void {
441
+ this.version.update((version) => version + 1);
442
+ }
443
+
444
+ /** Clave de la opción activa (la del `aria-activedescendant`), o null. */
445
+ activeKey(): string | null {
446
+ const id = this.listbox().activeDescendant();
447
+ return id ? id.slice(this.listId().length + 1) : null;
448
+ }
449
+
450
+ /** Activa la primera opción habilitada (al escribir en el buscador). */
451
+ gotoFirst(): void {
452
+ this.keepFirst.set(true);
453
+ }
454
+
455
+ private optionAt(event: Event): HTMLElement | null {
456
+ return (event.target as Element | null)?.closest<HTMLElement>('[role="option"]') ?? null;
457
+ }
458
+
459
+ /** Rodeo: el listbox no avisa qué opción se eligió. Solo cuentan las habilitadas. */
460
+ protected onClick(event: MouseEvent): void {
461
+ const option = this.optionAt(event);
462
+ const key = option?.dataset['key'];
463
+ if (key !== undefined && option?.getAttribute('aria-disabled') !== 'true') this.pick.emit(key);
464
+ }
465
+
466
+ /** El puntero activa la opción, como en el diseño (aria solo la activa con el teclado). */
467
+ protected onPointerMove(event: PointerEvent): void {
468
+ const option = this.optionAt(event);
469
+ const key = option?.dataset['key'];
470
+ if (key === undefined || option?.dataset['active'] === 'true') return;
471
+ if (option?.getAttribute('aria-disabled') === 'true') return;
472
+ this.byPointer = true;
473
+ this.keepFirst.set(false);
474
+ this.listbox().gotoIndex(this.keys().indexOf(key));
475
+ }
476
+ }
477
+
478
+ const TRIGGER_CLASSES = cn(
479
+ 'group/select relative flex w-full min-w-0 items-center gap-2 pr-2.5 pl-3 font-sans text-sm text-foreground mimi-transition',
480
+ 'h-[var(--mimi-select-height,var(--mimi-control-height,2.5rem))] rounded-[var(--mimi-select-radius,var(--mimi-radius,0.75rem))]',
481
+ 'border border-[color:var(--mimi-select-border,var(--mimi-input))] bg-[color:var(--mimi-select-trigger-bg,var(--mimi-input-background))]',
482
+ // Foco, error y deshabilitado como en Input (utils/control-styles.ts). Aquí el foco y
483
+ // aria-invalid están en el botón de dentro, así que la caja los lee con has-[] y data-*.
484
+ 'has-[[role=combobox]:focus]:border-ring has-[[role=combobox]:focus]:ring-3 has-[[role=combobox]:focus]:ring-ring-soft',
485
+ 'data-[invalid]:border-destructive data-[invalid]:has-[[role=combobox]:focus]:border-destructive data-[invalid]:has-[[role=combobox]:focus]:ring-destructive-ring',
486
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-[var(--mimi-disabled-opacity,0.5)]',
487
+ 'has-[[role=combobox]:active]:scale-(--mimi-press-scale)',
488
+ );
489
+
490
+ // El ::after cubre toda la caja: un clic en cualquier parte (también en el chevron) es del botón.
491
+ const BUTTON_CLASSES =
492
+ 'flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 bg-transparent text-left outline-none after:absolute after:inset-0 disabled:cursor-not-allowed';
493
+
494
+ const CLEAR_CLASSES = cn(
495
+ 'relative z-10 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full bg-muted text-muted-foreground',
496
+ 'mimi-transition hover:bg-accent hover:text-foreground active:scale-90',
497
+ buttonFocusStyles,
498
+ );
499
+
500
+ const PANEL_CLASSES =
501
+ 'w-full overflow-hidden rounded-[var(--mimi-select-radius,var(--mimi-radius,0.75rem))] border border-border bg-[color:var(--mimi-select-menu-bg,var(--mimi-popover))] font-sans text-sm text-popover-foreground shadow-[shadow:var(--mimi-select-menu-shadow,var(--mimi-shadow-popover))] outline-none';
502
+
503
+ const SEARCH_INPUT_CLASSES =
504
+ 'h-full min-w-0 flex-1 border-0 bg-transparent font-sans text-sm text-foreground outline-none placeholder:text-muted-foreground';
505
+
506
+ /**
507
+ * Lista desplegable de una sola opción:
508
+ *
509
+ * ```html
510
+ * <mimi-select [(value)]="pais" [options]="paises" placeholder="Selecciona un país" search clearable />
511
+ * ```
512
+ *
513
+ * Implementa `FormValueControl`: funciona con `[formField]`, `formControlName` y `[(ngModel)]`
514
+ * sin adaptador (spec, sección 8). `class` va al disparador y `panelClass` al panel.
515
+ */
516
+ @Component({
517
+ selector: 'mimi-select',
518
+ imports: [
519
+ Combobox,
520
+ ComboboxPopup,
521
+ ComboboxWidget,
522
+ CdkConnectedOverlay,
523
+ MimiSelectList,
524
+ MimiSelectOutlet,
525
+ ],
526
+ // mimi-form-field lo encuentra por este token para mostrar el mensaje de error.
527
+ providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiSelect }],
528
+ template: `
529
+ <div
530
+ #box
531
+ [class]="classes()"
532
+ [attr.data-state]="open() ? 'open' : 'closed'"
533
+ [attr.data-invalid]="showInvalid() ? '' : null"
534
+ [attr.data-disabled]="disabled() ? '' : null"
535
+ [attr.data-placeholder]="selectedOption() ? null : ''"
536
+ >
537
+ <!-- preserveContent: el panel sigue montado mientras dura la animación de salida. -->
538
+ <button
539
+ #trigger
540
+ #combobox="ngCombobox"
541
+ ngCombobox
542
+ type="button"
543
+ [id]="controlId()"
544
+ [class]="buttonClasses"
545
+ [expanded]="open()"
546
+ [disabled]="disabled()"
547
+ [softDisabled]="false"
548
+ [preserveContent]="true"
549
+ [attr.aria-invalid]="showInvalid() || null"
550
+ [attr.aria-required]="required() || null"
551
+ [attr.aria-label]="ariaLabel() || null"
552
+ [attr.aria-labelledby]="ariaLabelledby() || null"
553
+ (expandedChange)="setOpen($event)"
554
+ (keydown)="onTriggerKeydown($event)"
555
+ (keyup)="onTriggerKeyup($event)"
556
+ >
557
+ @if (selectedOption(); as option) {
558
+ @if (optionTemplate(); as template) {
559
+ <span class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
560
+ <ng-container
561
+ [mimiSelectOutlet]="template.templateRef"
562
+ [mimiSelectOutletOption]="option"
563
+ [mimiSelectOutletTrigger]="true"
564
+ />
565
+ </span>
566
+ } @else {
567
+ <span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
568
+ }
569
+ } @else {
570
+ <span class="min-w-0 flex-1 truncate text-muted-foreground">{{ placeholder() }}</span>
571
+ }
572
+ </button>
573
+ @if (showClear()) {
574
+ <button
575
+ type="button"
576
+ [class]="clearClasses"
577
+ [attr.aria-label]="text.clear"
578
+ (click)="clear()"
579
+ >
580
+ <svg
581
+ aria-hidden="true"
582
+ class="size-3.5"
583
+ fill="none"
584
+ stroke="currentColor"
585
+ stroke-width="2"
586
+ stroke-linecap="round"
587
+ stroke-linejoin="round"
588
+ [attr.viewBox]="icon.viewBox"
589
+ >
590
+ <path d="M18 6 6 18" />
591
+ <path d="m6 6 12 12" />
592
+ </svg>
593
+ </button>
594
+ }
595
+ @if (loading()) {
596
+ <svg
597
+ aria-hidden="true"
598
+ data-slot="spinner"
599
+ class="size-4 shrink-0 animate-[mimi-spin_0.8s_linear_infinite] text-muted-foreground"
600
+ fill="none"
601
+ stroke="currentColor"
602
+ stroke-width="2"
603
+ stroke-linecap="round"
604
+ stroke-linejoin="round"
605
+ [attr.viewBox]="icon.viewBox"
606
+ >
607
+ <path [attr.d]="icon.spinner" />
608
+ </svg>
609
+ }
610
+ <svg
611
+ aria-hidden="true"
612
+ class="size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-[cubic-bezier(.2,.8,.2,1)] group-data-[state=open]/select:rotate-180"
613
+ fill="none"
614
+ stroke="currentColor"
615
+ stroke-width="2"
616
+ stroke-linecap="round"
617
+ stroke-linejoin="round"
618
+ [attr.viewBox]="icon.viewBox"
619
+ >
620
+ <path [attr.d]="icon.chevron" />
621
+ </svg>
622
+ </div>
623
+
624
+ <!-- El popup de aria va por fuera del overlay: así el disparador tiene aria-haspopup cerrado. -->
625
+ <ng-template
626
+ ngComboboxPopup
627
+ [combobox]="combobox"
628
+ [popupType]="search() ? 'dialog' : 'listbox'"
629
+ >
630
+ <ng-template
631
+ cdkConnectedOverlay
632
+ [cdkConnectedOverlayOrigin]="box"
633
+ [cdkConnectedOverlayOpen]="rendered()"
634
+ [cdkConnectedOverlayPositions]="positions()"
635
+ [cdkConnectedOverlayViewportMargin]="viewportMargin"
636
+ [cdkConnectedOverlayDisableClose]="true"
637
+ [cdkConnectedOverlayMatchWidth]="matchWidth()"
638
+ cdkConnectedOverlayUsePopover="inline"
639
+ cdkConnectedOverlayTransformOriginOn="[data-mimi-overlay-panel]"
640
+ (detach)="onDetach()"
641
+ (overlayOutsideClick)="setOpen(false)"
642
+ (positionChange)="onPositionChange($event)"
643
+ >
644
+ @if (search()) {
645
+ <!-- Rodeo: buscador dentro del panel con un segundo combobox (ejemplo oficial de aria). -->
646
+ <div
647
+ ngComboboxWidget
648
+ #panel
649
+ data-mimi-overlay-panel
650
+ role="dialog"
651
+ [class]="panelClasses()"
652
+ [attr.aria-label]="ariaLabelledby() ? null : panelLabel() || null"
653
+ [attr.aria-labelledby]="ariaLabelledby() || null"
654
+ [attr.data-state]="state()"
655
+ [attr.data-side]="placedSide()"
656
+ [attr.inert]="state() === 'closed' ? '' : null"
657
+ (mousedown)="onPanelMousedown($event)"
658
+ >
659
+ <div
660
+ class="flex h-10 items-center gap-2 border-b border-border px-3 text-muted-foreground"
661
+ >
662
+ <svg
663
+ aria-hidden="true"
664
+ class="size-[15px] shrink-0"
665
+ fill="none"
666
+ stroke="currentColor"
667
+ stroke-width="2"
668
+ stroke-linecap="round"
669
+ stroke-linejoin="round"
670
+ [attr.viewBox]="icon.viewBox"
671
+ >
672
+ <path d="m21 21-4.34-4.34" />
673
+ <circle cx="11" cy="11" r="8" />
674
+ </svg>
675
+ <input
676
+ #searchInput
677
+ #searchBox="ngCombobox"
678
+ ngCombobox
679
+ type="text"
680
+ autocomplete="off"
681
+ [alwaysExpanded]="true"
682
+ [class]="searchInputClasses"
683
+ [value]="query()"
684
+ [attr.aria-label]="searchPlaceholder() || text.search"
685
+ [attr.placeholder]="searchPlaceholder() || text.search"
686
+ (valueChange)="onQuery($event)"
687
+ (keydown)="onSearchKeydown($event)"
688
+ />
689
+ @if (query() && !loading()) {
690
+ <span data-slot="count" class="shrink-0 font-mono text-[11px]">{{
691
+ text.results(count())
692
+ }}</span>
693
+ }
694
+ </div>
695
+ <ng-template ngComboboxPopup [combobox]="searchBox">
696
+ <mimi-select-list
697
+ [sections]="visibleSections()"
698
+ [selectedKey]="selectedKey()"
699
+ [query]="query()"
700
+ [loading]="loading()"
701
+ [optionTemplate]="optionTemplate()?.templateRef ?? null"
702
+ [listId]="listId"
703
+ [label]="panelLabel()"
704
+ [labelledBy]="ariaLabelledby()"
705
+ (pick)="pick($event)"
706
+ />
707
+ </ng-template>
708
+ </div>
709
+ } @else {
710
+ <div
711
+ #panel
712
+ data-mimi-overlay-panel
713
+ [class]="panelClasses()"
714
+ [attr.data-state]="state()"
715
+ [attr.data-side]="placedSide()"
716
+ [attr.inert]="state() === 'closed' ? '' : null"
717
+ (mousedown)="onPanelMousedown($event)"
718
+ >
719
+ <mimi-select-list
720
+ [sections]="visibleSections()"
721
+ [selectedKey]="selectedKey()"
722
+ [loading]="loading()"
723
+ [optionTemplate]="optionTemplate()?.templateRef ?? null"
724
+ [listId]="listId"
725
+ [label]="panelLabel()"
726
+ [labelledBy]="ariaLabelledby()"
727
+ (pick)="pick($event)"
728
+ />
729
+ </div>
730
+ }
731
+ </ng-template>
732
+ </ng-template>
733
+ `,
734
+ styles: MIMI_OVERLAY_ANIMATION,
735
+ host: {
736
+ style: 'display: contents',
737
+ // El class es del disparador; id y aria-* pasan al botón: fuera del anfitrión.
738
+ '[attr.class]': 'null',
739
+ '[attr.id]': 'null',
740
+ '[attr.aria-label]': 'null',
741
+ '[attr.aria-labelledby]': 'null',
742
+ // `align` es un atributo HTML antiguo: el navegador lo convierte en text-align (se heredaría).
743
+ '[attr.align]': 'null',
744
+ '(focusout)': 'onFocusOut($event)',
745
+ },
746
+ changeDetection: ChangeDetectionStrategy.OnPush,
747
+ })
748
+ export class MimiSelect<T = unknown> implements FormValueControl<T | null>, MimiFieldControl {
749
+ private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
750
+ private readonly injector = inject(Injector);
751
+ protected readonly text = inject(MIMI_SELECT_TEXT);
752
+
753
+ /** Opciones sueltas o en grupos (`{ label, options }`). */
754
+ readonly options = input<readonly (MimiOption<T> | MimiOptionGroup<T>)[]>([]);
755
+ /** Valor de la opción elegida, o `null`. Con `[(value)]` o un formulario. */
756
+ readonly value = model<T | null>(null);
757
+ /** Compara el valor con el de cada opción (para objetos). Por defecto, `Object.is`. */
758
+ readonly compareWith = input<(a: T, b: T) => boolean>(Object.is);
759
+ readonly placeholder = input('');
760
+ /** Muestra el buscador dentro del panel. */
761
+ readonly search = input(false, { transform: booleanAttribute });
762
+ /** Placeholder y nombre accesible del buscador. */
763
+ readonly searchPlaceholder = input('');
764
+ /** Filtro propio. Por defecto: la etiqueta contiene la búsqueda, sin tildes ni mayúsculas. */
765
+ readonly filterWith = input<MimiSelectFilter<T>>();
766
+ /** Muestra el botón de limpiar cuando hay un valor. */
767
+ readonly clearable = input(false, { transform: booleanAttribute });
768
+ /** Cargando: spinner en el disparador y, en el panel, el estado de carga en vez de la lista. */
769
+ readonly loading = input(false, { transform: booleanAttribute });
770
+ readonly disabled = input(false, { transform: booleanAttribute });
771
+ /** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
772
+ readonly invalid = input(false, { transform: booleanAttribute });
773
+ readonly touched = input(false, { transform: booleanAttribute });
774
+ readonly dirty = input(false, { transform: booleanAttribute });
775
+ readonly required = input(false, { transform: booleanAttribute });
776
+ /** id del disparador, para una etiqueta externa (`<label for>`). */
777
+ readonly id = input<string>();
778
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
779
+ readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
780
+ /** Clases del disparador, mezcladas con `cn()` (gana la del usuario). */
781
+ readonly userClass = input('', { alias: 'class' });
782
+ /** Clases del panel. */
783
+ readonly panelClass = input('');
784
+ /** Lado preferido del panel (como Popover). Si no cabe, pasa al opuesto. */
785
+ readonly side = input<MimiOverlaySide>('bottom');
786
+ /** Alineación del panel con el disparador (como Popover). */
787
+ readonly align = input<MimiOverlayAlign>('start');
788
+ /** El panel mide lo mismo que el disparador. Con `false`, lo que pida su contenido. */
789
+ readonly matchWidth = input(true, { transform: booleanAttribute });
790
+
791
+ /** Se emite cuando el foco sale del Select (disparador y panel): marca el control como tocado. */
792
+ readonly touch = output<void>();
793
+ /** Texto del buscador, para buscar en el servidor. Al cerrar se emite vacío. */
794
+ readonly searchChange = output<string>();
795
+
796
+ protected readonly optionTemplate = contentChild(MimiSelectOption);
797
+ private readonly trigger = viewChild.required<ElementRef<HTMLButtonElement>>('trigger');
798
+ private readonly panel = viewChild<ElementRef<HTMLElement>>('panel');
799
+ private readonly searchInput = viewChild<ElementRef<HTMLInputElement>>('searchInput');
800
+ private readonly list = viewChild(MimiSelectList);
801
+
802
+ /** Estado del formulario, con los errores, para el mensaje de mimi-form-field. */
803
+ readonly fieldState = injectFieldState();
804
+
805
+ /** El disparador: mimi-form-field le agrega el aria-describedby del mensaje. */
806
+ get controlElement(): HTMLElement {
807
+ return this.trigger().nativeElement;
808
+ }
809
+
810
+ private readonly baseId = `mimi-select-${nextId++}`;
811
+ protected readonly listId = `${this.baseId}-list`;
812
+ protected readonly controlId = computed(() => this.id() || this.baseId);
813
+ /** Error visible (inválido y tocado o modificado). Lo lee también mimi-form-field. */
814
+ readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
815
+
816
+ protected readonly icon = ICON;
817
+ protected readonly buttonClasses = BUTTON_CLASSES;
818
+ protected readonly clearClasses = CLEAR_CLASSES;
819
+ protected readonly searchInputClasses = SEARCH_INPUT_CLASSES;
820
+ protected readonly viewportMargin = MIMI_VIEWPORT_MARGIN;
821
+ /** En el lado pedido (debajo, por defecto); si no cabe, en el opuesto. */
822
+ protected readonly positions = computed(() =>
823
+ mimiConnectedPositions(this.side(), this.align(), OFFSET).slice(0, 2),
824
+ );
825
+
826
+ protected readonly classes = computed(() => cn(TRIGGER_CLASSES, this.userClass()));
827
+ protected readonly panelClasses = computed(() => cn(PANEL_CLASSES, this.panelClass()));
828
+
829
+ /** Todas las opciones, con su clave interna, en secciones (sueltas o de un grupo). */
830
+ private readonly sections = computed<Section[]>(() => {
831
+ const sections: Section[] = [];
832
+ let loose: Section | null = null;
833
+ let index = 0;
834
+ const row = (option: MimiOption<T>): Row => ({ key: String(index++), option });
835
+ for (const item of this.options()) {
836
+ const id = `${this.listId}-group-${sections.length}`;
837
+ if ('options' in item) {
838
+ loose = null;
839
+ sections.push({ id, label: item.label, rows: item.options.map(row) });
840
+ } else {
841
+ if (!loose) sections.push((loose = { id, label: null, rows: [] }));
842
+ loose.rows.push(row(item));
843
+ }
844
+ }
845
+ return sections;
846
+ });
847
+ private readonly rows = computed(() => this.sections().flatMap((section) => section.rows));
848
+
849
+ /** Texto del buscador. */
850
+ protected readonly query = signal('');
851
+
852
+ /** Lo que se pinta: nada mientras carga, y solo las coincidencias al buscar. */
853
+ protected readonly visibleSections = computed<Section[]>(() => {
854
+ if (this.loading()) return [];
855
+ const query = this.search() ? this.query() : '';
856
+ if (!query) return this.sections();
857
+ const filter = (this.filterWith() ?? defaultFilter) as MimiSelectFilter;
858
+ return this.sections()
859
+ .map((section) => ({ ...section, rows: section.rows.filter((r) => filter(r.option, query)) }))
860
+ .filter((section) => section.rows.length > 0);
861
+ });
862
+ protected readonly count = computed(() =>
863
+ this.visibleSections().reduce((count, section) => count + section.rows.length, 0),
864
+ );
865
+
866
+ private matches(option: MimiOption, value: T | null): boolean {
867
+ return value != null && this.compareWith()(option.value as T, value);
868
+ }
869
+
870
+ /** Rodeo: aria compara por identidad, así que cada opción lleva una clave interna. */
871
+ protected readonly selectedKey = computed(() => {
872
+ const value = this.value();
873
+ return this.rows().find((row) => this.matches(row.option, value))?.key ?? null;
874
+ });
875
+
876
+ /**
877
+ * La opción elegida. Si deja de estar en `options` (búsqueda en el servidor), se conserva la
878
+ * última para seguir mostrando su texto.
879
+ */
880
+ protected readonly selectedOption = linkedSignal<
881
+ { value: T | null; rows: Row[] },
882
+ MimiOption | null
883
+ >({
884
+ source: () => ({ value: this.value(), rows: this.rows() }),
885
+ computation: ({ value, rows }, previous) => {
886
+ const found = rows.find((row) => this.matches(row.option, value))?.option;
887
+ if (found) return found;
888
+ const last = previous?.value;
889
+ return last && this.matches(last, value) ? last : null;
890
+ },
891
+ });
892
+
893
+ protected readonly showClear = computed(
894
+ () => this.clearable() && !this.disabled() && this.selectedOption() !== null,
895
+ );
896
+
897
+ /** Abierto (aria-expanded). */
898
+ protected readonly open = signal(false);
899
+ /** El panel está en el DOM (también mientras dura la animación de salida). */
900
+ protected readonly rendered = signal(false);
901
+ /** Estado ya pintado: cambia después de `open`, en el siguiente ciclo. */
902
+ protected readonly state = signal<'open' | 'closed'>('closed');
903
+ protected readonly placedSide = linkedSignal<MimiOverlaySide>(() => this.side());
904
+ /** Nombre del panel y de la lista: el del disparador. */
905
+ protected readonly panelLabel = signal('');
906
+
907
+ /** Cuenta los cierres: si se vuelve a abrir durante la animación de salida, no se desmonta. */
908
+ private closing = 0;
909
+ /** Última letra de la búsqueda por teclado: mientras dura, Espacio es parte del texto. */
910
+ private typedAt = -Infinity;
911
+
912
+ private readonly syncOpen = effect(() => {
913
+ const open = this.open();
914
+ untracked(() => (open ? this.show() : this.hide()));
915
+ });
916
+
917
+ private readonly closeWhenDisabled = effect(() => {
918
+ if (this.disabled()) untracked(() => this.open.set(false));
919
+ });
920
+
921
+ /** Rodeo: con buscador, el foco pasa al input al abrir (aria no lo mueve). */
922
+ private readonly focusSearch = effect(() => {
923
+ const input = this.searchInput()?.nativeElement;
924
+ if (!input || this.state() !== 'open') return;
925
+ untracked(() => afterNextRender({ read: () => input.focus() }, { injector: this.injector }));
926
+ });
927
+
928
+ /** Enfoca el disparador. Lo usa Signal Forms para llevar el foco al campo con error. */
929
+ focus(options?: FocusOptions): void {
930
+ this.trigger().nativeElement.focus(options);
931
+ }
932
+
933
+ /** Abre o cierra. Lo llama aria (clic, ↓, Enter, Espacio, Escape, foco fuera) y Mimi. */
934
+ protected setOpen(open: boolean): void {
935
+ if (open && this.disabled()) return;
936
+ this.open.set(open);
937
+ }
938
+
939
+ /** Cierra y, con `refocus`, devuelve el foco al disparador (al elegir o con Escape). */
940
+ private close(refocus: boolean): void {
941
+ if (refocus) this.focus();
942
+ this.open.set(false);
943
+ }
944
+
945
+ private show(): void {
946
+ this.closing++;
947
+ const trigger = this.trigger().nativeElement;
948
+ this.panelLabel.set(
949
+ this.ariaLabel() || trigger.labels?.[0]?.textContent?.trim() || this.placeholder(),
950
+ );
951
+ this.state.set('open');
952
+ this.rendered.set(true);
953
+ // Safari no enfoca un botón al hacer clic: sin foco no habría teclado.
954
+ if (!this.search()) this.focus({ preventScroll: true });
955
+ }
956
+
957
+ private hide(): void {
958
+ if (!this.rendered()) return;
959
+ this.state.set('closed');
960
+ const panel = this.panel()?.nativeElement;
961
+ const closing = ++this.closing;
962
+ afterNextRender(
963
+ {
964
+ read: () => {
965
+ void mimiWaitForExit(panel).then(() => {
966
+ if (closing === this.closing) this.unmount();
967
+ });
968
+ },
969
+ },
970
+ { injector: this.injector },
971
+ );
972
+ }
973
+
974
+ private unmount(): void {
975
+ this.rendered.set(false);
976
+ if (this.query()) {
977
+ this.query.set('');
978
+ this.searchChange.emit('');
979
+ }
980
+ }
981
+
982
+ protected onDetach(): void {
983
+ this.unmount();
984
+ if (this.open()) this.open.set(false);
985
+ }
986
+
987
+ protected onPositionChange({ connectionPair }: ConnectedOverlayPositionChange): void {
988
+ this.placedSide.set(mimiPlacedSide(connectionPair));
989
+ }
990
+
991
+ /** Elige la opción de esa clave y cierra. Elegir la ya elegida solo cierra. */
992
+ protected pick(key: string): void {
993
+ const option = this.rows().find((row) => row.key === key)?.option;
994
+ if (!option || option.disabled) return;
995
+ this.value.set(option.value as T);
996
+ this.close(true);
997
+ }
998
+
999
+ /** Elige la opción activa de la lista (Enter, Espacio, Alt+↑). */
1000
+ private pickActive(): boolean {
1001
+ const key = this.list()?.activeKey();
1002
+ if (key) this.pick(key);
1003
+ return !!key;
1004
+ }
1005
+
1006
+ /** Quita el valor (botón de limpiar). */
1007
+ protected clear(): void {
1008
+ this.value.set(null);
1009
+ this.focus();
1010
+ }
1011
+
1012
+ /**
1013
+ * Teclas del disparador que aria no cubre. aria ya atendió la tecla cuando esto corre; `state`
1014
+ * todavía es «closed» si esa misma tecla acaba de abrir.
1015
+ */
1016
+ protected onTriggerKeydown(event: KeyboardEvent): void {
1017
+ if (this.disabled() || event.ctrlKey || event.metaKey) return;
1018
+ const { key, altKey } = event;
1019
+ if (this.state() !== 'open') {
1020
+ // Rodeo: aria solo abre con ↓, Enter y Espacio (sin modificadores).
1021
+ if (key === 'ArrowUp' || (key === 'ArrowDown' && altKey)) {
1022
+ event.preventDefault();
1023
+ this.setOpen(true);
1024
+ }
1025
+ return;
1026
+ }
1027
+ if (key === 'ArrowUp' && altKey) {
1028
+ event.preventDefault();
1029
+ if (!this.pickActive()) this.close(true);
1030
+ } else if (key === 'Enter' && !altKey) {
1031
+ this.pickActive();
1032
+ } else if (key === ' ') {
1033
+ if (event.timeStamp - this.typedAt > TYPEAHEAD_DELAY) this.pickActive();
1034
+ } else if (key.length === 1 && !altKey) {
1035
+ this.typedAt = event.timeStamp;
1036
+ }
1037
+ }
1038
+
1039
+ /** Espacio activa un botón al soltar la tecla: sin esto, el clic volvería a abrir o cerrar. */
1040
+ protected onTriggerKeyup(event: KeyboardEvent): void {
1041
+ if (event.key === ' ') event.preventDefault();
1042
+ }
1043
+
1044
+ /** Rodeo: teclas del buscador que su combobox (siempre abierto) no cubre. */
1045
+ protected onSearchKeydown(event: KeyboardEvent): void {
1046
+ if (event.isComposing) return;
1047
+ const { key } = event;
1048
+ if (key === 'Escape') {
1049
+ event.preventDefault();
1050
+ this.close(true);
1051
+ } else if (key === 'Tab') {
1052
+ // El panel está fuera del orden del documento: el Tab sigue desde el disparador.
1053
+ this.close(true);
1054
+ } else if (key === 'Enter') {
1055
+ event.preventDefault();
1056
+ this.pickActive();
1057
+ } else if (key === 'ArrowUp' && event.altKey) {
1058
+ event.preventDefault();
1059
+ if (!this.pickActive()) this.close(true);
1060
+ }
1061
+ }
1062
+
1063
+ protected onQuery(query: string): void {
1064
+ if (query === this.query()) return;
1065
+ this.query.set(query);
1066
+ this.searchChange.emit(query);
1067
+ // Como en el diseño: al escribir, la primera coincidencia habilitada queda activa.
1068
+ this.list()?.gotoFirst();
1069
+ }
1070
+
1071
+ /** Un clic en el panel no mueve el foco (salvo al buscador): si no, aria cerraría. */
1072
+ protected onPanelMousedown(event: MouseEvent): void {
1073
+ if (event.target !== this.searchInput()?.nativeElement) event.preventDefault();
1074
+ }
1075
+
1076
+ /** El foco salió del Select entero (disparador, limpiar y panel). */
1077
+ protected onFocusOut(event: FocusEvent): void {
1078
+ const next = event.relatedTarget as Node | null;
1079
+ if (next && (this.host.contains(next) || this.panel()?.nativeElement.contains(next))) return;
1080
+ this.touch.emit();
1081
+ }
1082
+ }
1083
+
1084
+ /** Todas las piezas de Select, para importarlas juntas. */
1085
+ export const MimiSelectImports = [MimiSelect, MimiSelectOption, MimiSelectHighlight] as const;