@mimi-ng/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/dist/collection.json +1 -1
- package/dist/init/css.js +17 -0
- package/dist/init/index.js +7 -5
- package/dist/init/items.js +9 -0
- package/dist/ng-add/index.js +17 -1
- package/dist/ng-add/schema.json +27 -0
- package/dist/registry.json +222 -1
- package/dist/templates/components/avatar/avatar.ts +1 -1
- package/dist/templates/components/avatar/avatar.variants.ts +4 -4
- package/dist/templates/components/badge/badge.ts +41 -6
- package/dist/templates/components/badge/badge.variants.ts +105 -11
- package/dist/templates/components/button/button.ts +74 -7
- package/dist/templates/components/button/button.variants.ts +145 -21
- package/dist/templates/components/card/card.ts +8 -5
- package/dist/templates/components/checkbox/checkbox.ts +3 -3
- package/dist/templates/components/confirm/confirm-texts.ts +46 -0
- package/dist/templates/components/confirm/confirm.ts +190 -0
- package/dist/templates/components/confirm/index.ts +2 -0
- package/dist/templates/components/context-menu/context-menu.ts +378 -0
- package/dist/templates/components/context-menu/index.ts +14 -0
- package/dist/templates/components/dialog/dialog-texts.ts +41 -0
- package/dist/templates/components/dialog/dialog.ts +701 -0
- package/dist/templates/components/dialog/index.ts +18 -0
- package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
- package/dist/templates/components/dropdown-menu/index.ts +14 -0
- package/dist/templates/components/form-field/form-error.ts +1 -1
- package/dist/templates/components/form-field/form-field.ts +3 -2
- package/dist/templates/components/input/input.variants.ts +6 -4
- package/dist/templates/components/menu/index.ts +15 -0
- package/dist/templates/components/menu/menu.ts +656 -0
- package/dist/templates/components/pagination/index.ts +2 -0
- package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
- package/dist/templates/components/pagination/pagination-texts.ts +69 -0
- package/dist/templates/components/pagination/pagination.ts +443 -0
- package/dist/templates/components/popover/index.ts +12 -0
- package/dist/templates/components/popover/popover.ts +399 -0
- package/dist/templates/components/select/index.ts +12 -0
- package/dist/templates/components/select/select-texts.ts +62 -0
- package/dist/templates/components/select/select.ts +1085 -0
- package/dist/templates/components/separator/separator.variants.ts +10 -7
- package/dist/templates/components/skeleton/skeleton.ts +4 -1
- package/dist/templates/components/switch/switch.ts +6 -6
- package/dist/templates/components/tabs/index.ts +2 -0
- package/dist/templates/components/tabs/tabs.ts +426 -0
- package/dist/templates/components/textarea/textarea.variants.ts +14 -11
- package/dist/templates/components/toast/index.ts +3 -0
- package/dist/templates/components/toast/toast-config.ts +45 -0
- package/dist/templates/components/toast/toast-texts.ts +46 -0
- package/dist/templates/components/toast/toast.ts +1000 -0
- package/dist/templates/components/toolbar/index.ts +1 -0
- package/dist/templates/components/toolbar/toolbar.ts +377 -0
- package/dist/templates/components/tooltip/index.ts +7 -0
- package/dist/templates/components/tooltip/tooltip.ts +407 -0
- package/dist/templates/theme/provider.ts +62 -6
- package/dist/templates/theme/theme-base.css +307 -9
- package/dist/templates/theme/types.ts +278 -4
- package/dist/templates/utils/cn.ts +15 -1
- package/dist/templates/utils/control-styles.ts +2 -2
- package/dist/templates/utils/overlay.ts +140 -0
- package/dist/ui/index.js +80 -8
- package/dist/ui/schema.json +44 -0
- package/dist/utils/write-files.js +156 -59
- package/package.json +6 -5
|
@@ -0,0 +1,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;
|