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