@mimi-ng/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +42 -1
  2. package/dist/init/css.js +1 -1
  3. package/dist/init/index.js +33 -0
  4. package/dist/init/paths.js +20 -0
  5. package/dist/registry.js +4 -1
  6. package/dist/registry.json +198 -1
  7. package/dist/templates/blocks/login-01/index.ts +1 -0
  8. package/dist/templates/blocks/login-01/login-01.html +174 -0
  9. package/dist/templates/blocks/login-01/login-01.ts +114 -0
  10. package/dist/templates/blocks/login-02/index.ts +1 -0
  11. package/dist/templates/blocks/login-02/login-02.html +186 -0
  12. package/dist/templates/blocks/login-02/login-02.ts +101 -0
  13. package/dist/templates/blocks/otp-01/index.ts +1 -0
  14. package/dist/templates/blocks/otp-01/otp-01.html +166 -0
  15. package/dist/templates/blocks/otp-01/otp-01.ts +221 -0
  16. package/dist/templates/blocks/pricing-01/index.ts +1 -0
  17. package/dist/templates/blocks/pricing-01/pricing-01.html +173 -0
  18. package/dist/templates/blocks/pricing-01/pricing-01.ts +178 -0
  19. package/dist/templates/blocks/settings-01/index.ts +1 -0
  20. package/dist/templates/blocks/settings-01/settings-01.html +424 -0
  21. package/dist/templates/blocks/settings-01/settings-01.ts +392 -0
  22. package/dist/templates/blocks/signup-01/index.ts +1 -0
  23. package/dist/templates/blocks/signup-01/signup-01.html +332 -0
  24. package/dist/templates/blocks/signup-01/signup-01.ts +186 -0
  25. package/dist/templates/blocks/table-01/index.ts +1 -0
  26. package/dist/templates/blocks/table-01/table-01.html +214 -0
  27. package/dist/templates/blocks/table-01/table-01.ts +266 -0
  28. package/dist/templates/components/checkbox/checkbox.ts +14 -5
  29. package/dist/templates/components/command/command-texts.ts +65 -0
  30. package/dist/templates/components/command/command.ts +553 -0
  31. package/dist/templates/components/command/index.ts +13 -0
  32. package/dist/templates/components/data-table/data-table-texts.ts +110 -0
  33. package/dist/templates/components/data-table/data-table.ts +1153 -0
  34. package/dist/templates/components/data-table/index.ts +18 -0
  35. package/dist/templates/components/sortable-list/index.ts +9 -0
  36. package/dist/templates/components/sortable-list/sortable-list-texts.ts +71 -0
  37. package/dist/templates/components/sortable-list/sortable-list.ts +510 -0
  38. package/dist/templates/components/tabs/tabs.ts +88 -23
  39. package/dist/templates/components/tree/index.ts +9 -0
  40. package/dist/templates/components/tree/tree-texts.ts +45 -0
  41. package/dist/templates/components/tree/tree.ts +730 -0
  42. package/dist/templates/theme/provider.ts +4 -0
  43. package/dist/templates/theme/types.ts +84 -0
  44. package/dist/ui/index.js +323 -13
  45. package/dist/ui/schema.json +50 -2
  46. package/dist/utils/pages.js +138 -0
  47. package/dist/utils/project.js +59 -0
  48. package/dist/utils/tailwind.js +84 -0
  49. package/dist/utils/write-files.js +63 -8
  50. package/package.json +1 -1
@@ -0,0 +1,553 @@
1
+ import { Combobox, ComboboxPopup } from '@angular/aria/combobox';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ Directive,
6
+ type EmbeddedViewRef,
7
+ InjectionToken,
8
+ type Signal,
9
+ TemplateRef,
10
+ ViewContainerRef,
11
+ computed,
12
+ contentChild,
13
+ effect,
14
+ forwardRef,
15
+ inject,
16
+ input,
17
+ model,
18
+ output,
19
+ untracked,
20
+ viewChild,
21
+ } from '@angular/core';
22
+ import { MimiDialog, MimiDialogContent } from '@/components/ui/dialog';
23
+ import {
24
+ type MimiListFilter,
25
+ type MimiListOption,
26
+ type MimiListOptionGroup,
27
+ MimiHighlight,
28
+ MimiOptionList,
29
+ mimiFilterSections,
30
+ mimiListSections,
31
+ mimiNormalizeText,
32
+ } from '@/components/ui/option-list/option-list';
33
+ import { cn } from '@/components/ui/utils/cn';
34
+ import { MIMI_COMMAND_TEXT } from './command-texts';
35
+
36
+ /*
37
+ * Command ⌘K (docs/design/Mimi F4 Command Tree Sortable.dc.html, «1 · Command ⌘K»; prefijo de
38
+ * tokens `command`, spec 13). Una paleta de comandos: se escribe para filtrar y Enter o un clic
39
+ * ejecutan el comando activo.
40
+ *
41
+ * - `mimi-command`: la paleta (buscador, lista y pie), que también se puede poner en la página.
42
+ * - `mimi-command-dialog`: la paleta en un diálogo, que se abre con ⌘K (Mac) o Ctrl+K.
43
+ *
44
+ * Por dentro: @angular/aria (Combobox siempre abierto sobre el <input> y el Listbox del ítem
45
+ * `option-list`, la lógica de lista de Combobox) da los roles, aria-activedescendant y las
46
+ * flechas; el diálogo es el de Mimi (cdk). Nada de aria ni del cdk sale en la API pública.
47
+ */
48
+
49
+ /** Tono del ícono de un comando (como Badge y Progress). */
50
+ export type MimiCommandTone = 'info' | 'success' | 'warning' | 'danger';
51
+
52
+ /** Un comando. */
53
+ export interface MimiCommandItem<T = unknown> {
54
+ /** Identificador estable. */
55
+ id: string;
56
+ /** Texto del comando: se busca y se resalta. */
57
+ label: string;
58
+ /** Otras palabras con las que se encuentra (no se muestran). */
59
+ keywords?: readonly string[];
60
+ /** Atajo que se muestra a la derecha (`['⌘', 'N']`). Mimi solo lo muestra: no lo registra. */
61
+ shortcut?: readonly string[];
62
+ /** Color de la caja del ícono. */
63
+ tone?: MimiCommandTone;
64
+ /** Se ve pero no se puede ejecutar. */
65
+ disabled?: boolean;
66
+ /** Datos propios, para `(run)`. */
67
+ data?: T;
68
+ }
69
+
70
+ /** Un grupo de comandos con su título. */
71
+ export interface MimiCommandGroup<T = unknown> {
72
+ label: string;
73
+ items: readonly MimiCommandItem<T>[];
74
+ }
75
+
76
+ /** Contexto de la plantilla del ícono: `let-item`. */
77
+ export interface MimiCommandIconContext<T = unknown> {
78
+ $implicit: MimiCommandItem<T>;
79
+ }
80
+
81
+ /**
82
+ * Ícono de cada comando, dentro de la caja de 26px del diseño:
83
+ * `<ng-template mimiCommandIcon let-item><svg … /></ng-template>`. Sin plantilla, no hay caja.
84
+ */
85
+ @Directive({ selector: 'ng-template[mimiCommandIcon]' })
86
+ export class MimiCommandIcon<T = any> {
87
+ readonly templateRef = inject<TemplateRef<MimiCommandIconContext<T>>>(TemplateRef);
88
+
89
+ static ngTemplateContextGuard<T>(
90
+ _dir: MimiCommandIcon<T>,
91
+ ctx: unknown,
92
+ ): ctx is MimiCommandIconContext<T> {
93
+ return true;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Lo que va debajo de «sin resultados» (el «Buscar en la documentación» del diseño):
99
+ * `<button mimiBtn mimiCommandEmpty>…</button>`.
100
+ */
101
+ @Directive({ selector: '[mimiCommandEmpty]', host: { 'data-slot': 'empty-action' } })
102
+ export class MimiCommandEmpty {}
103
+
104
+ /**
105
+ * Uso interno: pinta la plantilla del ícono. Recibe el comando (siempre el mismo objeto de
106
+ * `items`), no un contexto nuevo en cada detección de cambios (NG0103, G3.2); sin
107
+ * `NgTemplateOutlet`, que lleva `@angular/common` al JS inicial del sitio (G3.1).
108
+ * @internal No forma parte de la API de Mimi.
109
+ */
110
+ @Directive({ selector: '[mimiCommandOutlet]' })
111
+ export class MimiCommandOutlet {
112
+ readonly template = input.required<TemplateRef<MimiCommandIconContext>>({
113
+ alias: 'mimiCommandOutlet',
114
+ });
115
+ readonly item = input.required<MimiCommandItem>({ alias: 'mimiCommandOutletItem' });
116
+
117
+ private readonly viewContainer = inject(ViewContainerRef);
118
+ private view: EmbeddedViewRef<MimiCommandIconContext> | null = null;
119
+ private rendered: TemplateRef<MimiCommandIconContext> | null = null;
120
+
121
+ private readonly render = effect(() => {
122
+ const template = this.template();
123
+ const item = this.item();
124
+ untracked(() => {
125
+ if (this.view && this.rendered === template) {
126
+ this.view.context.$implicit = item;
127
+ this.view.markForCheck();
128
+ return;
129
+ }
130
+ this.viewContainer.clear();
131
+ this.rendered = template;
132
+ this.view = this.viewContainer.createEmbeddedView(template, { $implicit: item });
133
+ });
134
+ });
135
+ }
136
+
137
+ /** Uso interno: lo que `mimi-command-dialog` pasa a la paleta de dentro. */
138
+ interface MimiCommandHost {
139
+ readonly iconTemplate: Signal<TemplateRef<MimiCommandIconContext> | undefined>;
140
+ /** Cierra el diálogo (Escape). */
141
+ close(): void;
142
+ }
143
+
144
+ const MIMI_COMMAND_HOST = new InjectionToken<MimiCommandHost>('MIMI_COMMAND_HOST');
145
+
146
+ let nextId = 0;
147
+
148
+ const ICON = {
149
+ viewBox: '0 0 24 24',
150
+ search: ['m21 21-4.34-4.34', 'M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16z'],
151
+ searchX: [
152
+ 'm13.5 8.5-5 5',
153
+ 'm8.5 8.5 5 5',
154
+ 'M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16z',
155
+ 'm21 21-4.3-4.3',
156
+ ],
157
+ } as const;
158
+
159
+ const HOST_CLASSES = cn(
160
+ 'flex w-full min-w-0 flex-col overflow-hidden font-sans text-popover-foreground',
161
+ 'rounded-[var(--mimi-radius-card)] border border-border bg-popover',
162
+ 'shadow-[shadow:var(--mimi-shadow-popover)]',
163
+ );
164
+
165
+ const SEARCH_CLASSES =
166
+ 'flex h-[var(--mimi-command-input-height,52px)] shrink-0 items-center gap-2.5 border-b border-border pr-3.5 pl-4';
167
+
168
+ const INPUT_CLASSES =
169
+ 'h-full min-w-0 flex-1 bg-transparent text-[length:var(--mimi-command-input-font-size,15px)] text-foreground outline-none placeholder:text-muted-foreground';
170
+
171
+ const KBD_CLASSES =
172
+ 'inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-[5px] border border-border bg-background px-[5px] font-mono text-[11px] text-muted-foreground';
173
+
174
+ const ESC_CLASSES =
175
+ 'inline-flex h-[22px] shrink-0 items-center rounded-[5px] border border-border bg-background px-[7px] font-mono text-[11px] text-muted-foreground';
176
+
177
+ const FOOTER_KBD_CLASSES =
178
+ 'inline-flex h-[18px] items-center rounded-[4px] border border-border px-[5px] font-mono text-[10.5px]';
179
+
180
+ const LIST_CLASSES = 'max-h-[var(--mimi-command-list-height,332px)] p-1.5';
181
+
182
+ const OPTION_CLASSES = cn(
183
+ 'group/command-item flex h-[var(--mimi-command-item-height,40px)] shrink-0 cursor-pointer items-center gap-2.5 px-2.5 text-sm select-none',
184
+ 'rounded-[var(--mimi-command-item-radius,var(--mimi-radius-sm))]',
185
+ 'transition-[background-color] duration-100 ease-[ease] active:scale-[0.99]',
186
+ 'data-[active=true]:bg-[color:var(--mimi-command-active,var(--mimi-accent))]',
187
+ 'aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
188
+ );
189
+
190
+ const ICON_BOX_CLASSES =
191
+ 'mr-0.5 inline-flex size-[var(--mimi-command-icon-size,26px)] shrink-0 items-center justify-center rounded-[7px] mimi-transition [&_svg]:size-[15px] [&_svg]:shrink-0';
192
+
193
+ /** Caja del ícono: suave sin activar, sólida activada (clases literales para Tailwind). */
194
+ const ICON_TONE_CLASSES: Record<MimiCommandTone | 'none', string> = {
195
+ none: 'bg-muted text-muted-foreground group-data-[active=true]/command-item:bg-primary group-data-[active=true]/command-item:text-primary-foreground',
196
+ info: 'bg-info-soft text-info-soft-foreground group-data-[active=true]/command-item:bg-info group-data-[active=true]/command-item:text-info-foreground',
197
+ success:
198
+ 'bg-success-soft text-success-soft-foreground group-data-[active=true]/command-item:bg-success group-data-[active=true]/command-item:text-success-foreground',
199
+ warning:
200
+ 'bg-warning-soft text-warning-soft-foreground group-data-[active=true]/command-item:bg-warning group-data-[active=true]/command-item:text-warning-foreground',
201
+ danger:
202
+ 'bg-destructive-soft-bg text-destructive-soft-foreground group-data-[active=true]/command-item:bg-destructive group-data-[active=true]/command-item:text-destructive-foreground',
203
+ };
204
+
205
+ /** Busca en el texto y en las `keywords`, sin tildes ni mayúsculas. */
206
+ const commandFilter: MimiListFilter<MimiCommandItem> = (option, query) => {
207
+ const q = mimiNormalizeText(query.trim());
208
+ if (!q) return true;
209
+ const item = option.value;
210
+ return (
211
+ mimiNormalizeText(item.label).includes(q) ||
212
+ (item.keywords ?? []).some((word) => mimiNormalizeText(word).includes(q))
213
+ );
214
+ };
215
+
216
+ const toOption = (item: MimiCommandItem): MimiListOption<MimiCommandItem> => ({
217
+ label: item.label,
218
+ value: item,
219
+ disabled: item.disabled,
220
+ });
221
+
222
+ /**
223
+ * Paleta de comandos: `<mimi-command [items]="comandos" (run)="ejecutar($event)" />`.
224
+ *
225
+ * - `items`: comandos sueltos o en grupos (`{ label, items }`).
226
+ * - `run`: el comando que se ejecutó (Enter o clic).
227
+ * - `<ng-template mimiCommandIcon let-item>`: el ícono de cada comando.
228
+ *
229
+ * Dentro de la página, Escape vacía lo escrito; en `mimi-command-dialog`, cierra.
230
+ */
231
+ @Component({
232
+ selector: 'mimi-command',
233
+ imports: [Combobox, ComboboxPopup, MimiOptionList, MimiHighlight, MimiCommandOutlet],
234
+ template: `
235
+ <div data-slot="search" [class]="searchClasses">
236
+ <svg
237
+ aria-hidden="true"
238
+ class="size-[18px] shrink-0 text-muted-foreground"
239
+ fill="none"
240
+ stroke="currentColor"
241
+ stroke-width="2"
242
+ stroke-linecap="round"
243
+ stroke-linejoin="round"
244
+ [attr.viewBox]="icon.viewBox"
245
+ >
246
+ @for (d of icon.search; track d) {
247
+ <path [attr.d]="d" />
248
+ }
249
+ </svg>
250
+ <input
251
+ #field
252
+ #combobox="ngCombobox"
253
+ ngCombobox
254
+ alwaysExpanded
255
+ type="text"
256
+ autocomplete="off"
257
+ spellcheck="false"
258
+ [class]="inputClasses"
259
+ [value]="query()"
260
+ [softDisabled]="false"
261
+ [attr.placeholder]="placeholder() || texts.placeholder"
262
+ [attr.aria-label]="label()"
263
+ (valueChange)="onText($event)"
264
+ (keydown)="onKeydown($event)"
265
+ />
266
+ @if (inDialog) {
267
+ <kbd aria-hidden="true" data-slot="esc" [class]="escClasses">esc</kbd>
268
+ }
269
+ </div>
270
+
271
+ <ng-template ngComboboxPopup [combobox]="combobox" popupType="listbox">
272
+ <mimi-option-list
273
+ [sections]="visibleSections()"
274
+ [query]="query().trim()"
275
+ [status]="status()"
276
+ [optionTemplate]="itemTemplate()"
277
+ [listId]="listId"
278
+ [label]="label()"
279
+ [listClass]="listClasses"
280
+ [optionClass]="optionClasses"
281
+ (pick)="pick($event)"
282
+ />
283
+ </ng-template>
284
+
285
+ @if (empty()) {
286
+ <div data-slot="empty" class="flex flex-col items-center gap-2 px-5 py-10 text-center">
287
+ <span
288
+ class="inline-flex size-11 items-center justify-center rounded-full bg-muted text-muted-foreground"
289
+ >
290
+ <svg
291
+ aria-hidden="true"
292
+ class="size-5"
293
+ fill="none"
294
+ stroke="currentColor"
295
+ stroke-width="2"
296
+ stroke-linecap="round"
297
+ stroke-linejoin="round"
298
+ [attr.viewBox]="icon.viewBox"
299
+ >
300
+ @for (d of icon.searchX; track d) {
301
+ <path [attr.d]="d" />
302
+ }
303
+ </svg>
304
+ </span>
305
+ <span class="text-[15px] font-bold">{{ texts.noResults(query().trim()) }}</span>
306
+ <span class="text-[13px] text-muted-foreground">{{ texts.noResultsHint }}</span>
307
+ <span class="mt-1 empty:hidden"><ng-content select="[mimiCommandEmpty]" /></span>
308
+ </div>
309
+ }
310
+
311
+ <div data-slot="footer" aria-hidden="true" [class]="footerClasses">
312
+ <span class="flex items-center gap-1.5"
313
+ ><kbd [class]="footerKbdClasses">↑↓</kbd>{{ texts.navigate }}</span
314
+ >
315
+ <span class="flex items-center gap-1.5"
316
+ ><kbd [class]="footerKbdClasses">↵</kbd>{{ texts.run }}</span
317
+ >
318
+ <span class="flex-1"></span>
319
+ <span data-slot="count">{{ texts.results(count()) }}</span>
320
+ </div>
321
+
322
+ <ng-template #item let-option let-query="query">
323
+ @if (iconTemplate(); as iconTemplate) {
324
+ <span data-slot="icon" [class]="iconBoxClasses(option.value)">
325
+ <ng-container [mimiCommandOutlet]="iconTemplate" [mimiCommandOutletItem]="option.value" />
326
+ </span>
327
+ }
328
+ <mimi-highlight class="flex-1" [text]="option.label" [query]="query" />
329
+ @if (option.value.shortcut?.length) {
330
+ <span aria-hidden="true" data-slot="shortcut" class="flex shrink-0 gap-[3px]">
331
+ @for (key of option.value.shortcut; track $index) {
332
+ <kbd [class]="kbdClasses">{{ key }}</kbd>
333
+ }
334
+ </span>
335
+ }
336
+ </ng-template>
337
+ `,
338
+ host: { '[class]': 'classes()' },
339
+ changeDetection: ChangeDetectionStrategy.OnPush,
340
+ })
341
+ export class MimiCommand<T = unknown> {
342
+ protected readonly texts = inject(MIMI_COMMAND_TEXT);
343
+ private readonly host = inject(MIMI_COMMAND_HOST, { optional: true });
344
+ /** Está dentro de `mimi-command-dialog`: Escape cierra y se ve la tecla `esc`. */
345
+ protected readonly inDialog = !!this.host;
346
+
347
+ /** Los comandos, sueltos o en grupos. */
348
+ readonly items = input<readonly (MimiCommandItem<T> | MimiCommandGroup<T>)[]>([]);
349
+ /** Lo escrito. Admite `[(query)]`. */
350
+ readonly query = model('');
351
+ readonly placeholder = input('');
352
+ /** Nombre de la lista y del buscador (por defecto, «Command palette»). */
353
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
354
+ readonly userClass = input('', { alias: 'class' });
355
+
356
+ /** Se ejecutó un comando (Enter o clic). */
357
+ readonly run = output<MimiCommandItem<T>>();
358
+
359
+ private readonly ownIcon = contentChild(MimiCommandIcon);
360
+ protected readonly iconTemplate = computed(
361
+ () => this.ownIcon()?.templateRef ?? this.host?.iconTemplate(),
362
+ );
363
+ protected readonly itemTemplate = viewChild.required<TemplateRef<never>>('item');
364
+ private readonly list = viewChild(MimiOptionList);
365
+
366
+ protected readonly icon = ICON;
367
+ protected readonly searchClasses = SEARCH_CLASSES;
368
+ protected readonly inputClasses = INPUT_CLASSES;
369
+ protected readonly escClasses = ESC_CLASSES;
370
+ protected readonly kbdClasses = KBD_CLASSES;
371
+ protected readonly footerKbdClasses = FOOTER_KBD_CLASSES;
372
+ protected readonly footerClasses =
373
+ 'flex h-10 shrink-0 items-center gap-4 border-t border-border px-4 text-xs text-muted-foreground';
374
+ protected readonly listClasses = LIST_CLASSES;
375
+ protected readonly optionClasses = OPTION_CLASSES;
376
+ protected readonly classes = computed(() => cn(HOST_CLASSES, this.userClass()));
377
+
378
+ protected readonly listId = `mimi-command-${nextId++}-list`;
379
+ protected readonly label = computed(() => this.ariaLabel() || this.texts.label);
380
+
381
+ private readonly sections = computed(() =>
382
+ mimiListSections<MimiCommandItem<T>>(
383
+ this.items().map((entry) =>
384
+ 'items' in entry
385
+ ? ({
386
+ label: entry.label,
387
+ options: entry.items.map(toOption),
388
+ } as MimiListOptionGroup<MimiCommandItem<T>>)
389
+ : (toOption(entry) as MimiListOption<MimiCommandItem<T>>),
390
+ ),
391
+ this.listId,
392
+ ),
393
+ );
394
+ protected readonly visibleSections = computed(() =>
395
+ mimiFilterSections(
396
+ this.sections(),
397
+ this.query().trim(),
398
+ commandFilter as MimiListFilter<MimiCommandItem<T>>,
399
+ ),
400
+ );
401
+ protected readonly count = computed(() =>
402
+ this.visibleSections().reduce((count, section) => count + section.rows.length, 0),
403
+ );
404
+ protected readonly empty = computed(() => this.count() === 0 && !!this.query().trim());
405
+ /** Lo que oye el lector al escribir: el número de resultados, o «sin resultados». */
406
+ protected readonly status = computed(() => {
407
+ const query = this.query().trim();
408
+ if (!query) return '';
409
+ return this.count() ? this.texts.results(this.count()) : this.texts.noResults(query);
410
+ });
411
+
412
+ /** Como el diseño: el primer comando está activo al abrir y mientras se escribe. */
413
+ private readonly firstActive = effect(() => {
414
+ this.list()?.gotoFirst();
415
+ });
416
+
417
+ protected iconBoxClasses(item: MimiCommandItem): string {
418
+ return cn(ICON_BOX_CLASSES, ICON_TONE_CLASSES[item.tone ?? 'none']);
419
+ }
420
+
421
+ protected onText(text: string): void {
422
+ if (text === this.query()) return;
423
+ this.query.set(text);
424
+ this.list()?.gotoFirst();
425
+ }
426
+
427
+ /** Ejecuta el comando de esa clave (solo si está habilitado). */
428
+ protected pick(key: string): void {
429
+ const row = this.sections()
430
+ .flatMap((section) => section.rows)
431
+ .find((r) => r.key === key);
432
+ if (!row || row.option.disabled) return;
433
+ this.run.emit(row.option.value);
434
+ }
435
+
436
+ protected onKeydown(event: KeyboardEvent): void {
437
+ if (event.isComposing) return;
438
+ if (event.key === 'Enter') {
439
+ // aria ya movió la elección en la lista; Mimi ejecuta el activo.
440
+ event.preventDefault();
441
+ const key = this.list()?.activeKey();
442
+ if (key) this.pick(key);
443
+ } else if (event.key === 'Escape' && this.host) {
444
+ // aria corta la propagación de Escape: el diálogo no lo ve, así que se cierra desde aquí.
445
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
446
+ event.preventDefault();
447
+ this.host.close();
448
+ } else if (event.key === 'Escape' && this.query()) {
449
+ event.preventDefault();
450
+ this.query.set('');
451
+ this.list()?.gotoFirst();
452
+ }
453
+ }
454
+ }
455
+
456
+ /** Clases del panel del diálogo: arriba (`--mimi-command-top`) y centrado a lo ancho. */
457
+ const DIALOG_PANEL_CLASSES = cn(
458
+ 'fixed top-[var(--mimi-command-top,36px)] left-1/2 -translate-x-1/2 overflow-hidden',
459
+ 'max-h-[calc(100dvh_-_var(--mimi-command-top,36px)_-_16px)]',
460
+ );
461
+
462
+ /**
463
+ * La paleta en un diálogo: `<mimi-command-dialog [(open)]="abierta" [items]="comandos"
464
+ * (run)="ejecutar($event)" />`. ⌘K (Mac) o Ctrl+K la abren y la cierran (`shortcut=""` lo
465
+ * apaga). Al ejecutar un comando, se cierra y emite `run`.
466
+ */
467
+ @Component({
468
+ selector: 'mimi-command-dialog',
469
+ imports: [MimiDialog, MimiDialogContent, MimiCommand],
470
+ providers: [{ provide: MIMI_COMMAND_HOST, useExisting: forwardRef(() => MimiCommandDialog) }],
471
+ template: `
472
+ <mimi-dialog
473
+ width="var(--mimi-command-width, 560px)"
474
+ autoFocus="input"
475
+ [(open)]="open"
476
+ [class]="panelClasses()"
477
+ [closeButton]="false"
478
+ [aria-label]="label()"
479
+ >
480
+ <ng-template mimiDialogContent>
481
+ <mimi-command
482
+ class="max-h-[inherit] rounded-none border-0 shadow-none"
483
+ [items]="items()"
484
+ [placeholder]="placeholder()"
485
+ [aria-label]="label()"
486
+ (run)="onRun($event)"
487
+ >
488
+ <ng-content select="[mimiCommandEmpty]" ngProjectAs="[mimiCommandEmpty]" />
489
+ </mimi-command>
490
+ </ng-template>
491
+ </mimi-dialog>
492
+ `,
493
+ host: {
494
+ style: 'display: contents',
495
+ // El class es del panel; aria-label, del diálogo: fuera del anfitrión.
496
+ '[attr.class]': 'null',
497
+ '[attr.aria-label]': 'null',
498
+ '[attr.data-state]': 'open() ? "open" : "closed"',
499
+ '(document:keydown)': 'onShortcut($event)',
500
+ },
501
+ changeDetection: ChangeDetectionStrategy.OnPush,
502
+ })
503
+ export class MimiCommandDialog<T = unknown> implements MimiCommandHost {
504
+ protected readonly texts = inject(MIMI_COMMAND_TEXT);
505
+
506
+ /** Abierta o cerrada. Admite `[(open)]`. */
507
+ readonly open = model(false);
508
+ readonly items = input<readonly (MimiCommandItem<T> | MimiCommandGroup<T>)[]>([]);
509
+ readonly placeholder = input('');
510
+ /** Letra del atajo con ⌘ (Mac) o Ctrl. Vacío: sin atajo. */
511
+ readonly shortcut = input('k');
512
+ /** Nombre del diálogo y de la lista (por defecto, «Command palette»). */
513
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
514
+ /** Clases del panel, mezcladas con `cn()`. */
515
+ readonly userClass = input('', { alias: 'class' });
516
+
517
+ /** Se ejecutó un comando; el diálogo ya se está cerrando. */
518
+ readonly run = output<MimiCommandItem<T>>();
519
+
520
+ private readonly icon = contentChild(MimiCommandIcon);
521
+ /** @internal La plantilla del ícono, para la paleta de dentro del diálogo. */
522
+ readonly iconTemplate = computed(() => this.icon()?.templateRef);
523
+
524
+ protected readonly label = computed(() => this.ariaLabel() || this.texts.label);
525
+ protected readonly panelClasses = computed(() => cn(DIALOG_PANEL_CLASSES, this.userClass()));
526
+
527
+ protected onShortcut(event: KeyboardEvent): void {
528
+ const key = this.shortcut().toLowerCase();
529
+ if (!key || event.defaultPrevented || event.repeat) return;
530
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
531
+ if (event.key.toLowerCase() !== key) return;
532
+ event.preventDefault();
533
+ this.open.update((open) => !open);
534
+ }
535
+
536
+ /** @internal Lo usa la paleta de dentro (Escape). */
537
+ close(): void {
538
+ this.open.set(false);
539
+ }
540
+
541
+ protected onRun(item: MimiCommandItem<T>): void {
542
+ this.open.set(false);
543
+ this.run.emit(item);
544
+ }
545
+ }
546
+
547
+ /** Todas las piezas de Command, para importarlas juntas. */
548
+ export const MimiCommandImports = [
549
+ MimiCommand,
550
+ MimiCommandDialog,
551
+ MimiCommandIcon,
552
+ MimiCommandEmpty,
553
+ ] as const;
@@ -0,0 +1,13 @@
1
+ // Solo la API pública: MimiCommandOutlet es interno (se queda en './command').
2
+ export {
3
+ MimiCommand,
4
+ MimiCommandDialog,
5
+ MimiCommandEmpty,
6
+ type MimiCommandGroup,
7
+ MimiCommandIcon,
8
+ type MimiCommandIconContext,
9
+ MimiCommandImports,
10
+ type MimiCommandItem,
11
+ type MimiCommandTone,
12
+ } from './command';
13
+ export * from './command-texts';
@@ -0,0 +1,110 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /*
4
+ * Textos de Data Table, en archivo propio: así se pueden proveer en `app.config.ts` sin llevar la
5
+ * tabla (ni sus componentes) al chunk inicial.
6
+ */
7
+
8
+ /** Textos que la tabla muestra o anuncia. */
9
+ export interface MimiDataTableText {
10
+ /** Texto de la búsqueda vacía (y su nombre). */
11
+ search: string;
12
+ clearSearch: string;
13
+ /** Casilla del encabezado. */
14
+ selectAll: string;
15
+ /** Casilla de una fila; `label` es el valor de la primera columna. */
16
+ selectRow: (label: string) => string;
17
+ /** Contador de la barra de selección. */
18
+ selected: (count: number) => string;
19
+ clearSelection: string;
20
+ /** Botón del filtro de una columna. */
21
+ filter: (column: string) => string;
22
+ clearFilters: string;
23
+ density: string;
24
+ compact: string;
25
+ comfortable: string;
26
+ /** Botón de expandir de una fila. */
27
+ expand: (label: string) => string;
28
+ /** Encabezados sin texto (para los lectores). */
29
+ select: string;
30
+ details: string;
31
+ actions: string;
32
+ loading: string;
33
+ /** Sin datos. */
34
+ empty: string;
35
+ emptyHint: string;
36
+ /** Sin resultados con la búsqueda o los filtros. */
37
+ noResults: string;
38
+ noResultsHint: string;
39
+ /** Rango del pie: «1–10 of 48». */
40
+ range: (from: number, to: number, total: number) => string;
41
+ }
42
+
43
+ /** Textos predeterminados (inglés). */
44
+ export const MIMI_DATA_TABLE_TEXT_EN: MimiDataTableText = {
45
+ search: 'Search…',
46
+ clearSearch: 'Clear search',
47
+ selectAll: 'Select all rows on this page',
48
+ selectRow: (label) => `Select ${label}`,
49
+ selected: (count) => (count === 1 ? '1 selected' : `${count} selected`),
50
+ clearSelection: 'Clear selection',
51
+ filter: (column) => `Filter ${column}`,
52
+ clearFilters: 'Clear filters',
53
+ density: 'Density',
54
+ compact: 'Compact',
55
+ comfortable: 'Comfortable',
56
+ expand: (label) => `Details of ${label}`,
57
+ select: 'Select',
58
+ details: 'Details',
59
+ actions: 'Actions',
60
+ loading: 'Loading…',
61
+ empty: 'No data yet',
62
+ emptyHint: 'When there are rows, they will appear here.',
63
+ noResults: 'No results',
64
+ noResultsHint: 'Try another search or clear the filters.',
65
+ range: (from, to, total) => `${from}–${to} of ${total}`,
66
+ };
67
+
68
+ /** Textos en español: `provideMimiDataTableText(MIMI_DATA_TABLE_TEXT_ES)`. */
69
+ export const MIMI_DATA_TABLE_TEXT_ES: MimiDataTableText = {
70
+ search: 'Buscar…',
71
+ clearSearch: 'Borrar búsqueda',
72
+ selectAll: 'Seleccionar todas las filas de la página',
73
+ selectRow: (label) => `Seleccionar ${label}`,
74
+ selected: (count) => (count === 1 ? '1 seleccionada' : `${count} seleccionadas`),
75
+ clearSelection: 'Quitar selección',
76
+ filter: (column) => `Filtrar ${column}`,
77
+ clearFilters: 'Limpiar filtros',
78
+ density: 'Densidad',
79
+ compact: 'Compacta',
80
+ comfortable: 'Cómoda',
81
+ expand: (label) => `Detalle de ${label}`,
82
+ select: 'Seleccionar',
83
+ details: 'Detalle',
84
+ actions: 'Acciones',
85
+ loading: 'Cargando…',
86
+ empty: 'Todavía no hay datos',
87
+ emptyHint: 'Cuando haya filas, aparecerán aquí.',
88
+ noResults: 'Sin resultados',
89
+ noResultsHint: 'Prueba con otra búsqueda o limpia los filtros.',
90
+ range: (from, to, total) => `${from}–${to} de ${total}`,
91
+ };
92
+
93
+ /** Textos activos. Sin `provideMimiDataTableText`, los predeterminados en inglés. */
94
+ export const MIMI_DATA_TABLE_TEXT = new InjectionToken<MimiDataTableText>('MIMI_DATA_TABLE_TEXT', {
95
+ providedIn: 'root',
96
+ factory: () => MIMI_DATA_TABLE_TEXT_EN,
97
+ });
98
+
99
+ /**
100
+ * Cambia los textos de Data Table. Lo que no definas sigue en inglés:
101
+ *
102
+ * ```ts
103
+ * provideMimiDataTableText(MIMI_DATA_TABLE_TEXT_ES)
104
+ * ```
105
+ *
106
+ * Va en `app.config.ts` o en los `providers` de un componente o ruta.
107
+ */
108
+ export function provideMimiDataTableText(text: Partial<MimiDataTableText>): Provider {
109
+ return { provide: MIMI_DATA_TABLE_TEXT, useValue: { ...MIMI_DATA_TABLE_TEXT_EN, ...text } };
110
+ }