@mimi-ng/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/dist/registry.json +274 -14
- package/dist/templates/components/calendar/calendar-texts.ts +72 -0
- package/dist/templates/components/calendar/calendar.ts +588 -0
- package/dist/templates/components/calendar/dates.ts +181 -0
- package/dist/templates/components/calendar/index.ts +3 -0
- package/dist/templates/components/chip/chip-texts.ts +40 -0
- package/dist/templates/components/chip/chip.ts +116 -0
- package/dist/templates/components/chip/index.ts +2 -0
- package/dist/templates/components/color-picker/color-picker-texts.ts +57 -0
- package/dist/templates/components/color-picker/color-picker.ts +718 -0
- package/dist/templates/components/color-picker/color.ts +157 -0
- package/dist/templates/components/color-picker/index.ts +3 -0
- package/dist/templates/components/combobox/combobox-texts.ts +70 -0
- package/dist/templates/components/combobox/combobox.ts +798 -0
- package/dist/templates/components/combobox/index.ts +2 -0
- package/dist/templates/components/date-picker/date-picker.ts +240 -0
- package/dist/templates/components/date-picker/index.ts +3 -0
- package/dist/templates/components/date-range/date-range.ts +308 -0
- package/dist/templates/components/date-range/index.ts +1 -0
- package/dist/templates/components/file-upload/file-upload-texts.ts +77 -0
- package/dist/templates/components/file-upload/file-upload.ts +698 -0
- package/dist/templates/components/file-upload/index.ts +2 -0
- package/dist/templates/components/input-mask/index.ts +1 -0
- package/dist/templates/components/input-mask/input-mask.ts +319 -0
- package/dist/templates/components/input-otp/index.ts +1 -0
- package/dist/templates/components/input-otp/input-otp.ts +212 -0
- package/dist/templates/components/number-input/index.ts +2 -0
- package/dist/templates/components/number-input/number-input-texts.ts +46 -0
- package/dist/templates/components/number-input/number-input.ts +450 -0
- package/dist/templates/components/option-list/index.ts +11 -0
- package/dist/templates/components/option-list/option-list.ts +518 -0
- package/dist/templates/components/password/index.ts +2 -0
- package/dist/templates/components/password/password-texts.ts +68 -0
- package/dist/templates/components/password/password.ts +363 -0
- package/dist/templates/components/popover/popover.ts +12 -4
- package/dist/templates/components/progress/index.ts +1 -0
- package/dist/templates/components/progress/progress.ts +217 -0
- package/dist/templates/components/select/select.ts +3 -2
- package/dist/templates/components/slider/index.ts +2 -0
- package/dist/templates/components/slider/slider-texts.ts +26 -0
- package/dist/templates/components/slider/slider.ts +425 -0
- package/dist/templates/components/tags-input/index.ts +2 -0
- package/dist/templates/components/tags-input/tags-input-texts.ts +44 -0
- package/dist/templates/components/tags-input/tags-input.ts +303 -0
- package/dist/templates/components/toggle-group/index.ts +1 -0
- package/dist/templates/components/toggle-group/toggle-group.ts +406 -0
- package/dist/templates/theme/provider.ts +13 -0
- package/dist/templates/theme/theme-base.css +103 -0
- package/dist/templates/theme/types.ts +216 -0
- package/dist/templates/utils/overlay.ts +17 -4
- package/dist/ui/index.js +105 -1
- package/dist/ui/schema.json +56 -0
- package/dist/utils/write-files.js +12 -2
- package/package.json +1 -1
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import { Combobox, ComboboxPopup } from '@angular/aria/combobox';
|
|
2
|
+
import { CdkConnectedOverlay, type ConnectedOverlayPositionChange } from '@angular/cdk/overlay';
|
|
3
|
+
import {
|
|
4
|
+
ChangeDetectionStrategy,
|
|
5
|
+
Component,
|
|
6
|
+
Directive,
|
|
7
|
+
ElementRef,
|
|
8
|
+
Injector,
|
|
9
|
+
TemplateRef,
|
|
10
|
+
afterNextRender,
|
|
11
|
+
afterRenderEffect,
|
|
12
|
+
booleanAttribute,
|
|
13
|
+
computed,
|
|
14
|
+
contentChild,
|
|
15
|
+
effect,
|
|
16
|
+
inject,
|
|
17
|
+
input,
|
|
18
|
+
isDevMode,
|
|
19
|
+
linkedSignal,
|
|
20
|
+
model,
|
|
21
|
+
output,
|
|
22
|
+
signal,
|
|
23
|
+
untracked,
|
|
24
|
+
viewChild,
|
|
25
|
+
} from '@angular/core';
|
|
26
|
+
import type { FormValueControl } from '@angular/forms/signals';
|
|
27
|
+
import { MimiChip, injectMimiChipAnnouncer } from '@/components/ui/chip';
|
|
28
|
+
import {
|
|
29
|
+
MIMI_LIST_CREATE_KEY,
|
|
30
|
+
type MimiListFilter,
|
|
31
|
+
type MimiListOption,
|
|
32
|
+
type MimiListOptionContext,
|
|
33
|
+
type MimiListOptionGroup,
|
|
34
|
+
type MimiListRow,
|
|
35
|
+
MimiOptionList,
|
|
36
|
+
mimiFilterSections,
|
|
37
|
+
mimiListSections,
|
|
38
|
+
mimiNormalizeText,
|
|
39
|
+
} from '@/components/ui/option-list/option-list';
|
|
40
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
41
|
+
import {
|
|
42
|
+
MIMI_FIELD_CONTROL,
|
|
43
|
+
type MimiFieldControl,
|
|
44
|
+
injectFieldState,
|
|
45
|
+
} from '@/components/ui/utils/field-state';
|
|
46
|
+
import {
|
|
47
|
+
MIMI_OVERLAY_ANIMATION,
|
|
48
|
+
MIMI_VIEWPORT_MARGIN,
|
|
49
|
+
type MimiOverlayAlign,
|
|
50
|
+
type MimiOverlaySide,
|
|
51
|
+
mimiConnectedPositions,
|
|
52
|
+
mimiPlacedSide,
|
|
53
|
+
mimiWaitForExit,
|
|
54
|
+
} from '@/components/ui/utils/overlay';
|
|
55
|
+
import { MIMI_COMBOBOX_TEXT } from './combobox-texts';
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* Combobox / Autocomplete (docs/design/Mimi F3 Combobox Fechas Hora.dc.html, «1 · Combobox /
|
|
59
|
+
* Autocomplete»; prefijo de tokens `combobox`, spec 13). Un campo donde se escribe para filtrar
|
|
60
|
+
* una lista y elegir; con `creatable`, crear un valor nuevo; con `loading` y `(searchChange)`,
|
|
61
|
+
* buscar en el servidor.
|
|
62
|
+
*
|
|
63
|
+
* Por dentro: @angular/aria (Combobox sobre el <input> + Listbox, del ítem `option-list`) da los
|
|
64
|
+
* roles, aria-activedescendant y las flechas; @angular/cdk/overlay posiciona el panel. Nada de
|
|
65
|
+
* aria ni del CDK sale en la API pública. Los rodeos son los del Select (spec 4, «Rodeos de aria»).
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** Distancia entre el campo y el panel (diseño: 2px). */
|
|
69
|
+
const OFFSET = 2;
|
|
70
|
+
|
|
71
|
+
let nextId = 0;
|
|
72
|
+
|
|
73
|
+
const ICON = {
|
|
74
|
+
viewBox: '0 0 24 24',
|
|
75
|
+
chevron: 'm6 9 6 6 6-6',
|
|
76
|
+
spinner: 'M21 12a9 9 0 1 1-6.219-8.56',
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
const BOX_CLASSES = cn(
|
|
80
|
+
'group/combobox relative flex w-full min-w-0 cursor-text items-center pr-2.5 font-sans text-sm text-foreground mimi-transition',
|
|
81
|
+
'rounded-[var(--mimi-combobox-radius,var(--mimi-radius,0.75rem))]',
|
|
82
|
+
// Los estados de Input (spec 13, «Campos»), como Tags Input: el foco está en el <input> de dentro.
|
|
83
|
+
'border border-input bg-input-background',
|
|
84
|
+
'not-data-[disabled]:hover:border-input-hover',
|
|
85
|
+
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring-soft',
|
|
86
|
+
'data-[invalid]:border-destructive data-[invalid]:focus-within:border-destructive data-[invalid]:focus-within:ring-destructive-ring',
|
|
87
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-[var(--mimi-disabled-opacity,0.5)]',
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
/** Simple: el alto de los controles. Múltiple: la caja de Tags Input, que crece con los chips. */
|
|
91
|
+
const BOX_MODE_CLASSES = {
|
|
92
|
+
single: 'h-[var(--mimi-combobox-height,var(--mimi-control-height,2.5rem))] gap-2 pl-3',
|
|
93
|
+
multiple:
|
|
94
|
+
'min-h-[var(--mimi-combobox-height,var(--mimi-control-height,2.5rem))] flex-wrap gap-1.5 py-[5px] pl-1.5',
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
const INPUT_BASE =
|
|
98
|
+
'min-w-0 flex-1 border-0 bg-transparent font-sans text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed';
|
|
99
|
+
const INPUT_MODE_CLASSES = {
|
|
100
|
+
single: cn(INPUT_BASE, 'h-full p-0'),
|
|
101
|
+
multiple: cn(INPUT_BASE, 'h-7 min-w-20 px-1'),
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
const PANEL_CLASSES =
|
|
105
|
+
'w-full overflow-hidden rounded-[var(--mimi-combobox-radius,var(--mimi-radius,0.75rem))] border border-border bg-[color:var(--mimi-combobox-menu-bg,var(--mimi-popover))] font-sans text-sm text-popover-foreground shadow-[shadow:var(--mimi-combobox-menu-shadow,var(--mimi-shadow-popover))] outline-none';
|
|
106
|
+
|
|
107
|
+
const LIST_CLASSES = 'max-h-[var(--mimi-combobox-menu-max-height,228px)]';
|
|
108
|
+
|
|
109
|
+
const OPTION_CLASSES = cn(
|
|
110
|
+
'flex min-h-[var(--mimi-combobox-option-height,34px)] shrink-0 cursor-pointer items-center gap-2 px-2 text-sm select-none',
|
|
111
|
+
'rounded-[var(--mimi-combobox-option-radius,var(--mimi-radius-sm))]',
|
|
112
|
+
'transition-[background-color] duration-120 ease-[ease] active:scale-[0.985]',
|
|
113
|
+
'data-[active=true]:bg-[color:var(--mimi-combobox-option-hover,var(--mimi-accent))]',
|
|
114
|
+
'aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:active:scale-100',
|
|
115
|
+
);
|
|
116
|
+
/** En el múltiple, la casilla va a 10px del texto (diseño). */
|
|
117
|
+
const OPTION_MULTIPLE_CLASSES = cn(OPTION_CLASSES, 'gap-2.5');
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ícono o texto al principio del campo (el pin del diseño):
|
|
121
|
+
* `<svg mimiComboboxStart lucideMapPin />`.
|
|
122
|
+
*/
|
|
123
|
+
@Directive({
|
|
124
|
+
selector: '[mimiComboboxStart]',
|
|
125
|
+
host: { 'data-slot': 'start', '[class]': 'classes()', 'aria-hidden': 'true' },
|
|
126
|
+
})
|
|
127
|
+
export class MimiComboboxStart {
|
|
128
|
+
readonly userClass = input('', { alias: 'class' });
|
|
129
|
+
protected readonly classes = computed(() =>
|
|
130
|
+
cn('inline-flex size-4 shrink-0 items-center text-muted-foreground', this.userClass()),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Contenido propio de cada opción de la lista:
|
|
136
|
+
*
|
|
137
|
+
* ```html
|
|
138
|
+
* <ng-template mimiComboboxOption let-option let-query="query">
|
|
139
|
+
* <mimi-highlight [text]="option.label" [query]="query" />
|
|
140
|
+
* </ng-template>
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
@Directive({ selector: 'ng-template[mimiComboboxOption]' })
|
|
144
|
+
export class MimiComboboxOption {
|
|
145
|
+
readonly templateRef = inject<TemplateRef<MimiListOptionContext>>(TemplateRef);
|
|
146
|
+
|
|
147
|
+
static ngTemplateContextGuard(
|
|
148
|
+
_directive: MimiComboboxOption,
|
|
149
|
+
context: unknown,
|
|
150
|
+
): context is MimiListOptionContext {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Combobox: se escribe para filtrar y se elige una opción.
|
|
157
|
+
*
|
|
158
|
+
* ```html
|
|
159
|
+
* <mimi-combobox aria-label="Ciudad" [(value)]="ciudad" [options]="ciudades" creatable
|
|
160
|
+
* (create)="agregar($event)" />
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* Implementa `FormValueControl`: funciona con `[formField]`, `formControlName` y `[(ngModel)]`
|
|
164
|
+
* sin adaptador (spec 8). Lo escrito no es el valor hasta que se elige o se crea una opción.
|
|
165
|
+
* `class` va a la caja del campo y `panelClass` al panel.
|
|
166
|
+
*/
|
|
167
|
+
@Component({
|
|
168
|
+
selector: 'mimi-combobox',
|
|
169
|
+
imports: [Combobox, ComboboxPopup, CdkConnectedOverlay, MimiOptionList, MimiChip],
|
|
170
|
+
providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiCombobox }],
|
|
171
|
+
template: `
|
|
172
|
+
<div
|
|
173
|
+
#box
|
|
174
|
+
data-slot="box"
|
|
175
|
+
[class]="classes()"
|
|
176
|
+
[attr.data-state]="open() ? 'open' : 'closed'"
|
|
177
|
+
[attr.data-invalid]="showInvalid() ? '' : null"
|
|
178
|
+
[attr.data-disabled]="disabled() ? '' : null"
|
|
179
|
+
(mousedown)="onBoxMousedown($event)"
|
|
180
|
+
>
|
|
181
|
+
<ng-content select="[mimiComboboxStart]" />
|
|
182
|
+
@if (multiple()) {
|
|
183
|
+
@for (chip of shownChips(); track $index) {
|
|
184
|
+
<mimi-chip [label]="chip.label" [removable]="!disabled()" (remove)="removeAt($index)">{{
|
|
185
|
+
chip.label
|
|
186
|
+
}}</mimi-chip>
|
|
187
|
+
}
|
|
188
|
+
@if (hiddenCount(); as count) {
|
|
189
|
+
<span data-slot="more" class="px-0.5 text-xs text-muted-foreground">{{
|
|
190
|
+
texts.more(count)
|
|
191
|
+
}}</span>
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
<!-- preserveContent: el panel sigue montado mientras dura la animación de salida. -->
|
|
195
|
+
<input
|
|
196
|
+
#field
|
|
197
|
+
#combobox="ngCombobox"
|
|
198
|
+
ngCombobox
|
|
199
|
+
type="text"
|
|
200
|
+
autocomplete="off"
|
|
201
|
+
[id]="controlId()"
|
|
202
|
+
[class]="inputClasses()"
|
|
203
|
+
[value]="draft()"
|
|
204
|
+
[expanded]="open()"
|
|
205
|
+
[disabled]="disabled()"
|
|
206
|
+
[softDisabled]="false"
|
|
207
|
+
[preserveContent]="true"
|
|
208
|
+
[attr.placeholder]="(chips().length ? '' : placeholder()) || null"
|
|
209
|
+
[attr.aria-invalid]="showInvalid() || null"
|
|
210
|
+
[attr.aria-required]="required() || null"
|
|
211
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
212
|
+
[attr.aria-labelledby]="ariaLabelledby() || null"
|
|
213
|
+
[attr.aria-describedby]="help() ? helpId : null"
|
|
214
|
+
(valueChange)="onText($event)"
|
|
215
|
+
(expandedChange)="setOpen($event)"
|
|
216
|
+
(keydown)="onKeydown($event)"
|
|
217
|
+
(click)="setOpen(true)"
|
|
218
|
+
/>
|
|
219
|
+
@if (loading()) {
|
|
220
|
+
<svg
|
|
221
|
+
aria-hidden="true"
|
|
222
|
+
data-slot="spinner"
|
|
223
|
+
class="size-4 shrink-0 animate-[mimi-spin_0.8s_linear_infinite] text-muted-foreground"
|
|
224
|
+
fill="none"
|
|
225
|
+
stroke="currentColor"
|
|
226
|
+
stroke-width="2"
|
|
227
|
+
stroke-linecap="round"
|
|
228
|
+
stroke-linejoin="round"
|
|
229
|
+
[attr.viewBox]="icon.viewBox"
|
|
230
|
+
>
|
|
231
|
+
<path [attr.d]="icon.spinner" />
|
|
232
|
+
</svg>
|
|
233
|
+
}
|
|
234
|
+
<svg
|
|
235
|
+
aria-hidden="true"
|
|
236
|
+
data-slot="chevron"
|
|
237
|
+
class="size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-[cubic-bezier(.2,.8,.2,1)] group-data-[state=open]/combobox:rotate-180"
|
|
238
|
+
fill="none"
|
|
239
|
+
stroke="currentColor"
|
|
240
|
+
stroke-width="2"
|
|
241
|
+
stroke-linecap="round"
|
|
242
|
+
stroke-linejoin="round"
|
|
243
|
+
[attr.viewBox]="icon.viewBox"
|
|
244
|
+
>
|
|
245
|
+
<path [attr.d]="icon.chevron" />
|
|
246
|
+
</svg>
|
|
247
|
+
</div>
|
|
248
|
+
@if (help(); as help) {
|
|
249
|
+
<span data-slot="help" class="text-xs text-muted-foreground" [id]="helpId">{{ help }}</span>
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
<!-- El popup de aria va por fuera del overlay: así el campo tiene aria-haspopup cerrado. -->
|
|
253
|
+
<ng-template ngComboboxPopup [combobox]="combobox" popupType="listbox">
|
|
254
|
+
<ng-template
|
|
255
|
+
cdkConnectedOverlay
|
|
256
|
+
[cdkConnectedOverlayOrigin]="box"
|
|
257
|
+
[cdkConnectedOverlayOpen]="rendered()"
|
|
258
|
+
[cdkConnectedOverlayPositions]="positions()"
|
|
259
|
+
[cdkConnectedOverlayViewportMargin]="viewportMargin"
|
|
260
|
+
[cdkConnectedOverlayDisableClose]="true"
|
|
261
|
+
[cdkConnectedOverlayMatchWidth]="true"
|
|
262
|
+
cdkConnectedOverlayUsePopover="inline"
|
|
263
|
+
cdkConnectedOverlayTransformOriginOn="[data-mimi-overlay-panel]"
|
|
264
|
+
(detach)="onDetach()"
|
|
265
|
+
(overlayOutsideClick)="onOutsideClick($event)"
|
|
266
|
+
(positionChange)="onPositionChange($event)"
|
|
267
|
+
>
|
|
268
|
+
<div
|
|
269
|
+
#panel
|
|
270
|
+
data-mimi-overlay-panel
|
|
271
|
+
[class]="panelClasses()"
|
|
272
|
+
[attr.data-state]="state()"
|
|
273
|
+
[attr.data-side]="placedSide()"
|
|
274
|
+
[attr.inert]="state() === 'closed' ? '' : null"
|
|
275
|
+
(mousedown)="$event.preventDefault()"
|
|
276
|
+
>
|
|
277
|
+
<mimi-option-list
|
|
278
|
+
[sections]="visibleSections()"
|
|
279
|
+
[selectedKeys]="selectedKeys()"
|
|
280
|
+
[query]="query()"
|
|
281
|
+
[loading]="loading()"
|
|
282
|
+
[loadingText]="texts.loading"
|
|
283
|
+
[emptyTitle]="emptyTitle()"
|
|
284
|
+
[emptyText]="query() ? texts.noResultsHint : ''"
|
|
285
|
+
[status]="status()"
|
|
286
|
+
[createLabel]="createLabel()"
|
|
287
|
+
[optionTemplate]="optionTemplate()?.templateRef ?? null"
|
|
288
|
+
[listId]="listId"
|
|
289
|
+
[label]="panelLabel()"
|
|
290
|
+
[listClass]="listClasses"
|
|
291
|
+
[optionClass]="multiple() ? optionMultipleClasses : optionClasses"
|
|
292
|
+
[multi]="multiple()"
|
|
293
|
+
(pick)="pick($event)"
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
</ng-template>
|
|
297
|
+
</ng-template>
|
|
298
|
+
`,
|
|
299
|
+
styles: MIMI_OVERLAY_ANIMATION,
|
|
300
|
+
host: {
|
|
301
|
+
class: 'flex w-full min-w-0 flex-col gap-1.5',
|
|
302
|
+
// El class es de la caja; id y aria-* pasan al <input>: fuera del anfitrión.
|
|
303
|
+
'[attr.class]': 'null',
|
|
304
|
+
'[attr.id]': 'null',
|
|
305
|
+
'[attr.aria-label]': 'null',
|
|
306
|
+
'[attr.aria-labelledby]': 'null',
|
|
307
|
+
// `align` es un atributo HTML antiguo: el navegador lo convierte en text-align (se heredaría).
|
|
308
|
+
'[attr.align]': 'null',
|
|
309
|
+
'(focusin)': 'focused.set(true)',
|
|
310
|
+
'(focusout)': 'onFocusOut($event)',
|
|
311
|
+
},
|
|
312
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
313
|
+
})
|
|
314
|
+
export class MimiCombobox<T = unknown>
|
|
315
|
+
implements FormValueControl<T | null | T[]>, MimiFieldControl
|
|
316
|
+
{
|
|
317
|
+
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
318
|
+
private readonly injector = inject(Injector);
|
|
319
|
+
protected readonly texts = inject(MIMI_COMBOBOX_TEXT);
|
|
320
|
+
private readonly announce = injectMimiChipAnnouncer();
|
|
321
|
+
|
|
322
|
+
/** Opciones sueltas o en grupos (`{ label, options }`), con la forma de las de Select. */
|
|
323
|
+
readonly options = input<readonly (MimiListOption<T> | MimiListOptionGroup<T>)[]>([]);
|
|
324
|
+
/**
|
|
325
|
+
* La opción elegida (`T | null`) o, con `multiple`, las elegidas (`T[]`), en el orden en que se
|
|
326
|
+
* eligieron. Con `[(value)]` o un formulario.
|
|
327
|
+
*/
|
|
328
|
+
readonly value = model<T | null | T[]>(null);
|
|
329
|
+
/** Varias opciones, como chips en el campo (spec 8, «Valor simple o múltiple»). */
|
|
330
|
+
readonly multiple = input(false, { transform: booleanAttribute });
|
|
331
|
+
/**
|
|
332
|
+
* Ayuda debajo del campo, en el múltiple. Por defecto, la de los textos («Enter agrega ·
|
|
333
|
+
* Backspace quita el último»); vacía, sin ayuda.
|
|
334
|
+
*/
|
|
335
|
+
readonly hint = input<string | undefined>(undefined);
|
|
336
|
+
/** Compara el valor con el de cada opción (para objetos). Por defecto, `Object.is`. */
|
|
337
|
+
readonly compareWith = input<(a: T, b: T) => boolean>(Object.is);
|
|
338
|
+
/** Filtro propio. Por defecto: la etiqueta contiene lo escrito, sin tildes ni mayúsculas. */
|
|
339
|
+
readonly filterWith = input<MimiListFilter<T>>();
|
|
340
|
+
/**
|
|
341
|
+
* Ofrece «Crear "<texto>"» cuando lo escrito no coincide con ninguna opción. Al elegirla se
|
|
342
|
+
* emite `(create)`: la opción la agrega la app a `options`, y el Combobox la elige en cuanto
|
|
343
|
+
* aparece.
|
|
344
|
+
*/
|
|
345
|
+
readonly creatable = input(false, { transform: booleanAttribute });
|
|
346
|
+
/** Cargando: spinner en el campo y, en el panel, el estado de carga en vez de la lista. */
|
|
347
|
+
readonly loading = input(false, { transform: booleanAttribute });
|
|
348
|
+
readonly placeholder = input('');
|
|
349
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
350
|
+
/** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
|
|
351
|
+
readonly invalid = input(false, { transform: booleanAttribute });
|
|
352
|
+
readonly touched = input(false, { transform: booleanAttribute });
|
|
353
|
+
readonly dirty = input(false, { transform: booleanAttribute });
|
|
354
|
+
readonly required = input(false, { transform: booleanAttribute });
|
|
355
|
+
/** id del <input>, para una etiqueta externa (`<label for>`). */
|
|
356
|
+
readonly id = input<string>();
|
|
357
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
358
|
+
readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
|
|
359
|
+
/** Clases de la caja del campo, mezcladas con `cn()` (gana la del usuario). */
|
|
360
|
+
readonly userClass = input('', { alias: 'class' });
|
|
361
|
+
/** Clases del panel. */
|
|
362
|
+
readonly panelClass = input('');
|
|
363
|
+
/** Lado preferido del panel (como Popover). Si no cabe, pasa al opuesto. */
|
|
364
|
+
readonly side = input<MimiOverlaySide>('bottom');
|
|
365
|
+
/** Alineación del panel con el campo (como Popover). */
|
|
366
|
+
readonly align = input<MimiOverlayAlign>('start');
|
|
367
|
+
|
|
368
|
+
/** Se emite cuando el foco sale del Combobox (campo y panel): marca el control como tocado. */
|
|
369
|
+
readonly touch = output<void>();
|
|
370
|
+
/** Lo que se escribe, para buscar en el servidor. Al vaciar el campo se emite vacío. */
|
|
371
|
+
readonly searchChange = output<string>();
|
|
372
|
+
/** Se eligió «Crear "<texto>"»: el texto (sin espacios a los lados). */
|
|
373
|
+
readonly create = output<string>();
|
|
374
|
+
|
|
375
|
+
protected readonly optionTemplate = contentChild(MimiComboboxOption);
|
|
376
|
+
private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
|
|
377
|
+
private readonly panel = viewChild<ElementRef<HTMLElement>>('panel');
|
|
378
|
+
private readonly list = viewChild(MimiOptionList);
|
|
379
|
+
|
|
380
|
+
/** Estado del formulario, con los errores, para el mensaje de mimi-form-field. */
|
|
381
|
+
readonly fieldState = injectFieldState();
|
|
382
|
+
|
|
383
|
+
/** El <input>: mimi-form-field le agrega el aria-describedby del mensaje. */
|
|
384
|
+
get controlElement(): HTMLElement {
|
|
385
|
+
return this.field().nativeElement;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private readonly baseId = `mimi-combobox-${nextId++}`;
|
|
389
|
+
protected readonly listId = `${this.baseId}-list`;
|
|
390
|
+
protected readonly helpId = `${this.baseId}-help`;
|
|
391
|
+
protected readonly controlId = computed(() => this.id() || this.baseId);
|
|
392
|
+
/** Error visible (inválido y tocado o modificado). Lo lee también mimi-form-field. */
|
|
393
|
+
readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
|
|
394
|
+
|
|
395
|
+
protected readonly icon = ICON;
|
|
396
|
+
protected readonly listClasses = LIST_CLASSES;
|
|
397
|
+
protected readonly optionClasses = OPTION_CLASSES;
|
|
398
|
+
protected readonly optionMultipleClasses = OPTION_MULTIPLE_CLASSES;
|
|
399
|
+
protected readonly viewportMargin = MIMI_VIEWPORT_MARGIN;
|
|
400
|
+
protected readonly positions = computed(() =>
|
|
401
|
+
mimiConnectedPositions(this.side(), this.align(), OFFSET).slice(0, 6),
|
|
402
|
+
);
|
|
403
|
+
private readonly mode = computed(() => (this.multiple() ? 'multiple' : 'single'));
|
|
404
|
+
protected readonly classes = computed(() =>
|
|
405
|
+
cn(BOX_CLASSES, BOX_MODE_CLASSES[this.mode()], this.userClass()),
|
|
406
|
+
);
|
|
407
|
+
protected readonly inputClasses = computed(() => INPUT_MODE_CLASSES[this.mode()]);
|
|
408
|
+
/** Ayuda debajo del campo (solo en el múltiple). */
|
|
409
|
+
protected readonly help = computed(() =>
|
|
410
|
+
this.multiple() ? (this.hint() ?? this.texts.help) : '',
|
|
411
|
+
);
|
|
412
|
+
protected readonly panelClasses = computed(() => cn(PANEL_CLASSES, this.panelClass()));
|
|
413
|
+
|
|
414
|
+
private readonly sections = computed(() => mimiListSections<T>(this.options(), this.listId));
|
|
415
|
+
private readonly rows = computed(() => this.sections().flatMap((section) => section.rows));
|
|
416
|
+
|
|
417
|
+
/** El texto del campo. */
|
|
418
|
+
protected readonly draft = signal('');
|
|
419
|
+
/** Se escribió desde que se eligió por última vez: solo entonces se filtra. */
|
|
420
|
+
private readonly typed = signal(false);
|
|
421
|
+
/** Lo escrito que filtra la lista (vacío si el texto es el de la opción elegida). */
|
|
422
|
+
protected readonly query = computed(() => (this.typed() ? this.draft() : ''));
|
|
423
|
+
/** Texto de una opción creada que todavía no llegó a `options`. */
|
|
424
|
+
private readonly pendingCreate = signal<string | null>(null);
|
|
425
|
+
|
|
426
|
+
protected readonly visibleSections = computed(() => {
|
|
427
|
+
if (this.loading()) return [];
|
|
428
|
+
return mimiFilterSections(this.sections(), this.query(), this.filterWith());
|
|
429
|
+
});
|
|
430
|
+
private readonly count = computed(() =>
|
|
431
|
+
this.visibleSections().reduce((count, section) => count + section.rows.length, 0),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
private matches(option: MimiListOption<T>, value: T | null | undefined): boolean {
|
|
435
|
+
return value != null && this.compareWith()(option.value, value);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Los valores elegidos como arreglo: uno (o ninguno) en simple, todos en el múltiple. */
|
|
439
|
+
private readonly values = computed<T[]>(() => {
|
|
440
|
+
const value = this.value();
|
|
441
|
+
if (this.multiple()) return Array.isArray(value) ? value : [];
|
|
442
|
+
return value == null || Array.isArray(value) ? [] : [value];
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
/** Rodeo: aria compara por identidad, así que cada opción lleva una clave interna. */
|
|
446
|
+
protected readonly selectedKeys = computed(() => {
|
|
447
|
+
const rows = this.rows();
|
|
448
|
+
return this.values().flatMap((value) => {
|
|
449
|
+
const key = rows.find((row) => this.matches(row.option, value))?.key;
|
|
450
|
+
return key === undefined ? [] : [key];
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Las opciones elegidas, en el orden del valor. Si una deja de estar en `options` (búsqueda en
|
|
456
|
+
* el servidor), se conserva la última conocida para seguir mostrando su texto.
|
|
457
|
+
*/
|
|
458
|
+
protected readonly chips = linkedSignal<
|
|
459
|
+
{ values: T[]; rows: MimiListRow<T>[] },
|
|
460
|
+
MimiListOption<T>[]
|
|
461
|
+
>({
|
|
462
|
+
source: () => ({ values: this.values(), rows: this.rows() }),
|
|
463
|
+
computation: ({ values, rows }, previous) =>
|
|
464
|
+
values.flatMap((value) => {
|
|
465
|
+
const found =
|
|
466
|
+
rows.find((row) => this.matches(row.option, value))?.option ??
|
|
467
|
+
previous?.value.find((option) => this.matches(option, value));
|
|
468
|
+
return found ? [found] : [];
|
|
469
|
+
}),
|
|
470
|
+
});
|
|
471
|
+
/** La opción elegida en simple. */
|
|
472
|
+
private readonly selectedOption = computed(() =>
|
|
473
|
+
this.multiple() ? null : (this.chips()[0] ?? null),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
/** El foco está dentro del Combobox (campo, chips o panel). */
|
|
477
|
+
protected readonly focused = signal(false);
|
|
478
|
+
/** Cerrado y sin foco, el múltiple muestra el primer chip y «+N» (diseño). */
|
|
479
|
+
private readonly collapsed = computed(
|
|
480
|
+
() => this.multiple() && !this.focused() && !this.open() && this.chips().length > 1,
|
|
481
|
+
);
|
|
482
|
+
protected readonly shownChips = computed(() =>
|
|
483
|
+
this.collapsed() ? this.chips().slice(0, 1) : this.chips(),
|
|
484
|
+
);
|
|
485
|
+
protected readonly hiddenCount = computed(() => (this.collapsed() ? this.chips().length - 1 : 0));
|
|
486
|
+
|
|
487
|
+
/** Texto de la fila «Crear», o null. */
|
|
488
|
+
protected readonly createLabel = computed(() => {
|
|
489
|
+
if (!this.creatable() || this.loading()) return null;
|
|
490
|
+
const typed = this.query().trim();
|
|
491
|
+
if (!typed) return null;
|
|
492
|
+
const normalized = mimiNormalizeText(typed);
|
|
493
|
+
const exists = this.rows().some((row) => mimiNormalizeText(row.option.label) === normalized);
|
|
494
|
+
return exists ? null : this.texts.create(typed);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
protected readonly emptyTitle = computed(() => {
|
|
498
|
+
const query = this.query().trim();
|
|
499
|
+
return query ? this.texts.noResults(query) : this.texts.noOptions;
|
|
500
|
+
});
|
|
501
|
+
/** Anuncio de la cantidad de resultados mientras se escribe. */
|
|
502
|
+
protected readonly status = computed(() =>
|
|
503
|
+
this.open() && this.query() && !this.loading() && this.count() > 0
|
|
504
|
+
? this.texts.results(this.count())
|
|
505
|
+
: '',
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
/** Abierto (aria-expanded). */
|
|
509
|
+
protected readonly open = signal(false);
|
|
510
|
+
/** El panel está en el DOM (también mientras dura la animación de salida). */
|
|
511
|
+
protected readonly rendered = signal(false);
|
|
512
|
+
/** Estado ya pintado: cambia después de `open`, en el siguiente ciclo. */
|
|
513
|
+
protected readonly state = signal<'open' | 'closed'>('closed');
|
|
514
|
+
protected readonly placedSide = linkedSignal<MimiOverlaySide>(() => this.side());
|
|
515
|
+
/** Nombre de la lista: el del campo. */
|
|
516
|
+
protected readonly panelLabel = signal('');
|
|
517
|
+
|
|
518
|
+
/** Cuenta los cierres: si se vuelve a abrir durante la animación de salida, no se desmonta. */
|
|
519
|
+
private closing = 0;
|
|
520
|
+
|
|
521
|
+
private readonly syncOpen = effect(() => {
|
|
522
|
+
const open = this.open();
|
|
523
|
+
untracked(() => (open ? this.show() : this.hide()));
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
private readonly closeWhenDisabled = effect(() => {
|
|
527
|
+
if (this.disabled()) untracked(() => this.open.set(false));
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
/** En simple, el texto del campo sigue a la opción elegida, salvo mientras se escribe. */
|
|
531
|
+
private readonly syncText = effect(() => {
|
|
532
|
+
if (this.multiple()) return;
|
|
533
|
+
const label = this.selectedOption()?.label ?? '';
|
|
534
|
+
untracked(() => {
|
|
535
|
+
if (!this.typed()) this.draft.set(label);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
/** Elige (o, en el múltiple, agrega) la opción creada en cuanto la app la agrega a `options`. */
|
|
540
|
+
private readonly pickCreated = effect(() => {
|
|
541
|
+
const created = this.pendingCreate();
|
|
542
|
+
if (created === null) return;
|
|
543
|
+
const normalized = mimiNormalizeText(created);
|
|
544
|
+
const row = this.rows().find((r) => mimiNormalizeText(r.option.label) === normalized);
|
|
545
|
+
if (!row) return;
|
|
546
|
+
untracked(() => {
|
|
547
|
+
this.pendingCreate.set(null);
|
|
548
|
+
if (!this.multiple()) this.choose(row.option);
|
|
549
|
+
else if (!this.values().some((v) => this.matches(row.option, v))) this.toggle(row.option);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
/** En desarrollo, avisa si `value` no tiene la forma del modo (spec 8). */
|
|
554
|
+
private warned = false;
|
|
555
|
+
private readonly warnShape = isDevMode()
|
|
556
|
+
? afterRenderEffect({
|
|
557
|
+
read: () => {
|
|
558
|
+
const value = this.value();
|
|
559
|
+
const multiple = this.multiple();
|
|
560
|
+
if (this.warned) return;
|
|
561
|
+
const problem =
|
|
562
|
+
multiple && value != null && !Array.isArray(value)
|
|
563
|
+
? 'con multiple, value tiene que ser un arreglo'
|
|
564
|
+
: !multiple && Array.isArray(value)
|
|
565
|
+
? 'sin multiple, value no puede ser un arreglo'
|
|
566
|
+
: null;
|
|
567
|
+
if (!problem) return;
|
|
568
|
+
this.warned = true;
|
|
569
|
+
console.warn(`[Mimi] Combobox: ${problem}.`);
|
|
570
|
+
},
|
|
571
|
+
})
|
|
572
|
+
: null;
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* La lista se crea al abrir, después de la primera letra: si se abrió escribiendo, activa la
|
|
576
|
+
* primera coincidencia en cuanto existe (al escribir con la lista ya abierta, lo hace `onText`).
|
|
577
|
+
*/
|
|
578
|
+
private readonly firstOnOpen = effect(() => {
|
|
579
|
+
const list = this.list();
|
|
580
|
+
if (list && untracked(this.typed)) list.gotoFirst();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
/** Enfoca el campo. Lo usa Signal Forms para llevar el foco al campo con error. */
|
|
584
|
+
focus(options?: FocusOptions): void {
|
|
585
|
+
this.field().nativeElement.focus(options);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Abre o cierra. Lo llama aria (↓, escribir, Escape, foco fuera) y Mimi (clic, Alt+↓/↑). */
|
|
589
|
+
protected setOpen(open: boolean): void {
|
|
590
|
+
if (open && this.disabled()) return;
|
|
591
|
+
this.open.set(open);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private show(): void {
|
|
595
|
+
this.closing++;
|
|
596
|
+
const field = this.field().nativeElement;
|
|
597
|
+
this.panelLabel.set(
|
|
598
|
+
this.ariaLabel() || field.labels?.[0]?.textContent?.trim() || this.placeholder(),
|
|
599
|
+
);
|
|
600
|
+
this.state.set('open');
|
|
601
|
+
this.rendered.set(true);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private hide(): void {
|
|
605
|
+
if (!this.rendered()) return;
|
|
606
|
+
this.state.set('closed');
|
|
607
|
+
const panel = this.panel()?.nativeElement;
|
|
608
|
+
const closing = ++this.closing;
|
|
609
|
+
afterNextRender(
|
|
610
|
+
{
|
|
611
|
+
read: () => {
|
|
612
|
+
void mimiWaitForExit(panel).then(() => {
|
|
613
|
+
if (closing === this.closing) this.rendered.set(false);
|
|
614
|
+
});
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
{ injector: this.injector },
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
protected onDetach(): void {
|
|
622
|
+
this.rendered.set(false);
|
|
623
|
+
if (this.open()) this.open.set(false);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Un clic fuera cierra; en el propio campo no (lo maneja el campo). */
|
|
627
|
+
protected onOutsideClick(event: MouseEvent): void {
|
|
628
|
+
if (this.host.contains(event.target as Node)) return;
|
|
629
|
+
this.setOpen(false);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
protected onPositionChange({ connectionPair }: ConnectedOverlayPositionChange): void {
|
|
633
|
+
this.placedSide.set(mimiPlacedSide(connectionPair));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Un clic en la caja (fuera del <input>) enfoca el campo y abre o cierra. */
|
|
637
|
+
protected onBoxMousedown(event: MouseEvent): void {
|
|
638
|
+
const field = this.field().nativeElement;
|
|
639
|
+
// El botón de quitar de un chip hace lo suyo (y lleva el foco al campo).
|
|
640
|
+
const button = (event.target as Element).closest('button');
|
|
641
|
+
if (event.target === field || button || this.disabled()) return;
|
|
642
|
+
event.preventDefault();
|
|
643
|
+
field.focus();
|
|
644
|
+
this.setOpen(!this.open());
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
protected onText(text: string): void {
|
|
648
|
+
if (text === this.draft()) return;
|
|
649
|
+
this.draft.set(text);
|
|
650
|
+
this.typed.set(true);
|
|
651
|
+
this.pendingCreate.set(null);
|
|
652
|
+
this.searchChange.emit(text);
|
|
653
|
+
// Como en el diseño: al escribir, la primera coincidencia habilitada queda activa.
|
|
654
|
+
this.list()?.gotoFirst();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Elige la opción de esa clave (o «Crear») y cierra. */
|
|
658
|
+
protected pick(key: string): void {
|
|
659
|
+
if (key === MIMI_LIST_CREATE_KEY) {
|
|
660
|
+
this.createFromText();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const option = this.rows().find((row) => row.key === key)?.option;
|
|
664
|
+
if (!option || option.disabled) return;
|
|
665
|
+
if (this.multiple()) this.toggle(option);
|
|
666
|
+
else this.choose(option);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Simple: elige, pone su texto y cierra. */
|
|
670
|
+
private choose(option: MimiListOption<T>): void {
|
|
671
|
+
this.pendingCreate.set(null);
|
|
672
|
+
this.typed.set(false);
|
|
673
|
+
this.draft.set(option.label);
|
|
674
|
+
this.value.set(option.value);
|
|
675
|
+
this.setOpen(false);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Múltiple: agrega o quita la opción, sin cerrar; lo escrito se vacía para seguir buscando. */
|
|
679
|
+
private toggle(option: MimiListOption<T>): void {
|
|
680
|
+
const values = this.values();
|
|
681
|
+
const selected = values.some((value) => this.matches(option, value));
|
|
682
|
+
this.value.set(
|
|
683
|
+
selected ? values.filter((value) => !this.matches(option, value)) : [...values, option.value],
|
|
684
|
+
);
|
|
685
|
+
if (selected) this.announce.removed(option.label);
|
|
686
|
+
else this.announce.added(option.label);
|
|
687
|
+
this.resetDraft();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Vacía lo escrito (y avisa a la búsqueda en el servidor). */
|
|
691
|
+
private resetDraft(): void {
|
|
692
|
+
this.typed.set(false);
|
|
693
|
+
if (this.draft()) {
|
|
694
|
+
this.draft.set('');
|
|
695
|
+
this.searchChange.emit('');
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Quita el chip de esa posición. El foco va al campo antes (si no, caería a `body`). */
|
|
700
|
+
removeAt(index: number): void {
|
|
701
|
+
if (this.disabled()) return;
|
|
702
|
+
const chip = this.chips()[index];
|
|
703
|
+
if (!chip) return;
|
|
704
|
+
this.field().nativeElement.focus();
|
|
705
|
+
this.value.set(this.values().filter((value) => !this.matches(chip, value)));
|
|
706
|
+
this.announce.removed(chip.label);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private createFromText(): void {
|
|
710
|
+
const created = this.query().trim();
|
|
711
|
+
if (!created) return;
|
|
712
|
+
if (this.multiple()) this.resetDraft();
|
|
713
|
+
else this.setOpen(false);
|
|
714
|
+
this.pendingCreate.set(created);
|
|
715
|
+
this.create.emit(created);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Elige la opción activa de la lista (Enter). */
|
|
719
|
+
private pickActive(): boolean {
|
|
720
|
+
const key = this.list()?.activeKey();
|
|
721
|
+
if (key) this.pick(key);
|
|
722
|
+
return !!key;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Escape con el panel cerrado: vacía el campo y, en simple, quita el valor. En el múltiple los
|
|
727
|
+
* chips se quedan (se quitan uno a uno).
|
|
728
|
+
*/
|
|
729
|
+
private clear(): void {
|
|
730
|
+
this.pendingCreate.set(null);
|
|
731
|
+
this.resetDraft();
|
|
732
|
+
if (!this.multiple()) this.value.set(null);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Teclas que aria no cubre. aria ya atendió la tecla cuando esto corre (abrir con ↓, cerrar
|
|
737
|
+
* con Escape, reenviar las flechas y Enter a la lista).
|
|
738
|
+
*/
|
|
739
|
+
protected onKeydown(event: KeyboardEvent): void {
|
|
740
|
+
if (this.disabled() || event.isComposing || event.ctrlKey || event.metaKey) return;
|
|
741
|
+
const { key, altKey } = event;
|
|
742
|
+
// Múltiple: Backspace con el campo vacío quita el último chip (diseño).
|
|
743
|
+
if (key === 'Backspace' && this.multiple() && !this.draft() && this.chips().length) {
|
|
744
|
+
event.preventDefault();
|
|
745
|
+
this.removeAt(this.chips().length - 1);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// `state` todavía es «open» si esta misma tecla acaba de cerrar (Escape), y «closed» si
|
|
749
|
+
// acaba de abrir (↓): es el estado de antes de la tecla.
|
|
750
|
+
if (this.state() !== 'open') {
|
|
751
|
+
if (key === 'ArrowUp' || (key === 'ArrowDown' && altKey)) {
|
|
752
|
+
// Rodeo: aria solo abre con ↓ sin modificadores.
|
|
753
|
+
event.preventDefault();
|
|
754
|
+
this.setOpen(true);
|
|
755
|
+
} else if (key === 'Escape' && (this.draft() || this.selectedOption())) {
|
|
756
|
+
// Como la APG: Escape con el panel cerrado vacía el campo.
|
|
757
|
+
event.preventDefault();
|
|
758
|
+
this.clear();
|
|
759
|
+
}
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (key === 'ArrowUp' && altKey) {
|
|
763
|
+
event.preventDefault();
|
|
764
|
+
this.setOpen(false);
|
|
765
|
+
} else if (key === 'Enter') {
|
|
766
|
+
// Con el panel abierto, Enter elige (no envía el formulario).
|
|
767
|
+
event.preventDefault();
|
|
768
|
+
this.pickActive();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** El foco salió del Combobox entero (campo y panel). */
|
|
773
|
+
protected onFocusOut(event: FocusEvent): void {
|
|
774
|
+
const next = event.relatedTarget as Node | null;
|
|
775
|
+
if (next && (this.host.contains(next) || this.panel()?.nativeElement.contains(next))) return;
|
|
776
|
+
this.focused.set(false);
|
|
777
|
+
this.commit();
|
|
778
|
+
this.touch.emit();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Al salir: lo escrito sin elegir se descarta. En simple, el campo vuelve al texto de la opción
|
|
783
|
+
* elegida y, con el campo vacío, se quita el valor; en el múltiple, el campo queda vacío.
|
|
784
|
+
*/
|
|
785
|
+
private commit(): void {
|
|
786
|
+
if (!this.typed()) return;
|
|
787
|
+
if (this.multiple()) {
|
|
788
|
+
this.resetDraft();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (!this.draft().trim()) this.value.set(null);
|
|
792
|
+
this.typed.set(false);
|
|
793
|
+
this.draft.set(this.selectedOption()?.label ?? '');
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Todas las piezas del Combobox, para importarlas juntas. */
|
|
798
|
+
export const MimiComboboxImports = [MimiCombobox, MimiComboboxStart, MimiComboboxOption] as const;
|