@mimi-ng/cli 0.2.0 → 0.4.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 (79) hide show
  1. package/README.md +35 -2
  2. package/dist/init/css.js +1 -1
  3. package/dist/init/index.js +25 -0
  4. package/dist/init/paths.js +20 -0
  5. package/dist/registry.json +385 -14
  6. package/dist/templates/blocks/login-01/index.ts +1 -0
  7. package/dist/templates/blocks/login-01/login-01.html +174 -0
  8. package/dist/templates/blocks/login-01/login-01.ts +105 -0
  9. package/dist/templates/blocks/login-02/index.ts +1 -0
  10. package/dist/templates/blocks/login-02/login-02.html +186 -0
  11. package/dist/templates/blocks/login-02/login-02.ts +92 -0
  12. package/dist/templates/blocks/otp-01/index.ts +1 -0
  13. package/dist/templates/blocks/otp-01/otp-01.html +166 -0
  14. package/dist/templates/blocks/otp-01/otp-01.ts +212 -0
  15. package/dist/templates/blocks/pricing-01/index.ts +1 -0
  16. package/dist/templates/blocks/pricing-01/pricing-01.html +173 -0
  17. package/dist/templates/blocks/pricing-01/pricing-01.ts +178 -0
  18. package/dist/templates/blocks/settings-01/index.ts +1 -0
  19. package/dist/templates/blocks/settings-01/settings-01.html +424 -0
  20. package/dist/templates/blocks/settings-01/settings-01.ts +382 -0
  21. package/dist/templates/blocks/signup-01/index.ts +1 -0
  22. package/dist/templates/blocks/signup-01/signup-01.html +332 -0
  23. package/dist/templates/blocks/signup-01/signup-01.ts +176 -0
  24. package/dist/templates/components/calendar/calendar-texts.ts +72 -0
  25. package/dist/templates/components/calendar/calendar.ts +588 -0
  26. package/dist/templates/components/calendar/dates.ts +181 -0
  27. package/dist/templates/components/calendar/index.ts +3 -0
  28. package/dist/templates/components/chip/chip-texts.ts +40 -0
  29. package/dist/templates/components/chip/chip.ts +116 -0
  30. package/dist/templates/components/chip/index.ts +2 -0
  31. package/dist/templates/components/color-picker/color-picker-texts.ts +57 -0
  32. package/dist/templates/components/color-picker/color-picker.ts +718 -0
  33. package/dist/templates/components/color-picker/color.ts +157 -0
  34. package/dist/templates/components/color-picker/index.ts +3 -0
  35. package/dist/templates/components/combobox/combobox-texts.ts +70 -0
  36. package/dist/templates/components/combobox/combobox.ts +798 -0
  37. package/dist/templates/components/combobox/index.ts +2 -0
  38. package/dist/templates/components/date-picker/date-picker.ts +240 -0
  39. package/dist/templates/components/date-picker/index.ts +3 -0
  40. package/dist/templates/components/date-range/date-range.ts +308 -0
  41. package/dist/templates/components/date-range/index.ts +1 -0
  42. package/dist/templates/components/file-upload/file-upload-texts.ts +77 -0
  43. package/dist/templates/components/file-upload/file-upload.ts +698 -0
  44. package/dist/templates/components/file-upload/index.ts +2 -0
  45. package/dist/templates/components/input-mask/index.ts +1 -0
  46. package/dist/templates/components/input-mask/input-mask.ts +319 -0
  47. package/dist/templates/components/input-otp/index.ts +1 -0
  48. package/dist/templates/components/input-otp/input-otp.ts +212 -0
  49. package/dist/templates/components/number-input/index.ts +2 -0
  50. package/dist/templates/components/number-input/number-input-texts.ts +46 -0
  51. package/dist/templates/components/number-input/number-input.ts +450 -0
  52. package/dist/templates/components/option-list/index.ts +11 -0
  53. package/dist/templates/components/option-list/option-list.ts +518 -0
  54. package/dist/templates/components/password/index.ts +2 -0
  55. package/dist/templates/components/password/password-texts.ts +68 -0
  56. package/dist/templates/components/password/password.ts +363 -0
  57. package/dist/templates/components/popover/popover.ts +12 -4
  58. package/dist/templates/components/progress/index.ts +1 -0
  59. package/dist/templates/components/progress/progress.ts +217 -0
  60. package/dist/templates/components/select/select.ts +3 -2
  61. package/dist/templates/components/slider/index.ts +2 -0
  62. package/dist/templates/components/slider/slider-texts.ts +26 -0
  63. package/dist/templates/components/slider/slider.ts +425 -0
  64. package/dist/templates/components/tabs/tabs.ts +88 -23
  65. package/dist/templates/components/tags-input/index.ts +2 -0
  66. package/dist/templates/components/tags-input/tags-input-texts.ts +44 -0
  67. package/dist/templates/components/tags-input/tags-input.ts +303 -0
  68. package/dist/templates/components/toggle-group/index.ts +1 -0
  69. package/dist/templates/components/toggle-group/toggle-group.ts +406 -0
  70. package/dist/templates/theme/provider.ts +13 -0
  71. package/dist/templates/theme/theme-base.css +103 -0
  72. package/dist/templates/theme/types.ts +216 -0
  73. package/dist/templates/utils/overlay.ts +17 -4
  74. package/dist/ui/index.js +232 -10
  75. package/dist/ui/schema.json +82 -2
  76. package/dist/utils/project.js +35 -0
  77. package/dist/utils/tailwind.js +80 -0
  78. package/dist/utils/write-files.js +49 -9
  79. package/package.json +1 -1
@@ -0,0 +1,698 @@
1
+ import { LiveAnnouncer } from '@angular/cdk/a11y';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ DestroyRef,
6
+ Directive,
7
+ ElementRef,
8
+ LOCALE_ID,
9
+ booleanAttribute,
10
+ computed,
11
+ contentChild,
12
+ effect,
13
+ inject,
14
+ input,
15
+ model,
16
+ numberAttribute,
17
+ output,
18
+ signal,
19
+ untracked,
20
+ viewChild,
21
+ } from '@angular/core';
22
+ import type { FormValueControl } from '@angular/forms/signals';
23
+ import { MimiButton } from '@/components/ui/button';
24
+ import { MimiProgress } from '@/components/ui/progress';
25
+ import { cn } from '@/components/ui/utils/cn';
26
+ import { buttonFocusStyles } from '@/components/ui/utils/control-styles';
27
+ import {
28
+ MIMI_FIELD_CONTROL,
29
+ type MimiFieldControl,
30
+ injectFieldState,
31
+ } from '@/components/ui/utils/field-state';
32
+ import { MIMI_FILE_UPLOAD_TEXT } from './file-upload-texts';
33
+
34
+ /*
35
+ * File Upload (docs/design/Mimi F3 Archivos Tags Toggle Color.dc.html, «9 · File Upload»;
36
+ * prefijo de tokens `upload`, spec 13). Dos piezas (decidido por defecto en la G2.11, opción
37
+ * (b)): `mimi-file-upload`, la zona de soltar, que es el control de formulario (`File[]`), y
38
+ * `mimi-file-list` con `mimi-file-item`, de presentación, que la app arma con el estado de su
39
+ * subida. Mimi no sube nada.
40
+ */
41
+
42
+ /** Por qué no se agregó un archivo. */
43
+ export interface MimiFileRejection {
44
+ file: File;
45
+ reason: 'type' | 'size' | 'count';
46
+ message: string;
47
+ }
48
+
49
+ /** Estado de un archivo de la lista, que da la app. */
50
+ export type MimiFileStatus = 'idle' | 'uploading' | 'done' | 'error';
51
+
52
+ /**
53
+ * `true` si el archivo cumple `accept` (como el del `<input type="file">`: extensiones,
54
+ * `image/*` o tipos exactos). Sin tipo del navegador, se mira la extensión.
55
+ */
56
+ export function mimiFileMatchesAccept(file: File, accept: string): boolean {
57
+ const rules = accept
58
+ .split(',')
59
+ .map((rule) => rule.trim().toLowerCase())
60
+ .filter(Boolean);
61
+ if (!rules.length) return true;
62
+ const name = file.name.toLowerCase();
63
+ const type = (file.type || '').toLowerCase();
64
+ return rules.some((rule) =>
65
+ rule.startsWith('.')
66
+ ? name.endsWith(rule)
67
+ : rule.endsWith('/*')
68
+ ? type.startsWith(rule.slice(0, -1))
69
+ : type === rule,
70
+ );
71
+ }
72
+
73
+ /** Tamaño legible con el idioma: «2,4 MB». */
74
+ export function mimiFormatFileSize(bytes: number, locale: string): string {
75
+ const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const;
76
+ let size = bytes;
77
+ let unit = 0;
78
+ while (size >= 1000 && unit < units.length - 1) {
79
+ size /= 1000;
80
+ unit++;
81
+ }
82
+ return new Intl.NumberFormat(locale, {
83
+ style: 'unit',
84
+ unit: units[unit],
85
+ unitDisplay: 'short',
86
+ maximumFractionDigits: unit === 0 ? 0 : 1,
87
+ }).format(size);
88
+ }
89
+
90
+ const ZONE_BASE = cn(
91
+ 'flex w-full cursor-pointer flex-col items-center justify-center gap-2 p-4 text-center font-sans text-foreground',
92
+ 'min-h-[var(--mimi-upload-height,176px)] rounded-[var(--mimi-upload-radius,var(--mimi-radius-card,1rem))] border-2',
93
+ 'mimi-transition active:scale-[0.985] disabled:cursor-not-allowed disabled:opacity-[var(--mimi-disabled-opacity,0.5)] disabled:active:scale-100',
94
+ buttonFocusStyles,
95
+ );
96
+ const ZONE = {
97
+ idle: cn(
98
+ ZONE_BASE,
99
+ 'border-dashed border-[color:var(--mimi-upload-border,var(--mimi-input))] bg-[color:var(--mimi-upload-bg,var(--mimi-dropzone,var(--mimi-muted)))] not-disabled:hover:border-input-hover',
100
+ ),
101
+ over: cn(
102
+ ZONE_BASE,
103
+ 'scale-[1.01] border-solid border-[color:var(--mimi-upload-active-border,var(--mimi-dropzone-active-border,var(--mimi-info)))] bg-[color:var(--mimi-upload-active-bg,var(--mimi-dropzone-active,var(--mimi-accent)))] shadow-[shadow:var(--mimi-glow-info)] motion-reduce:scale-100',
104
+ ),
105
+ invalid: cn(
106
+ ZONE_BASE,
107
+ 'border-dashed border-destructive bg-[color:var(--mimi-upload-bg,var(--mimi-dropzone,var(--mimi-muted)))]',
108
+ ),
109
+ } as const;
110
+ const ICON_BASE =
111
+ 'inline-flex size-[var(--mimi-upload-icon-size,44px)] items-center justify-center rounded-full [&_svg]:size-[22px]';
112
+ const ICON = {
113
+ idle: cn(ICON_BASE, 'bg-muted text-foreground'),
114
+ over: cn(
115
+ ICON_BASE,
116
+ 'bg-info text-info-foreground animate-[mimi-bob_1s_ease-in-out_infinite] motion-reduce:animate-none',
117
+ ),
118
+ } as const;
119
+
120
+ let nextId = 0;
121
+
122
+ /**
123
+ * La zona de soltar: abre el selector con clic, Enter o Espacio, y recibe archivos soltados.
124
+ *
125
+ * ```html
126
+ * <mimi-file-upload accept="image/*,.pdf" multiple [maxSize]="10_000_000" [(value)]="files"
127
+ * hint="PNG, JPG o PDF · hasta 10 MB" (rejected)="onRejected($event)" />
128
+ * ```
129
+ *
130
+ * Implementa `FormValueControl<File[]>`. Un archivo rechazado (tipo, tamaño o cantidad) no entra
131
+ * en el valor: sale por `(rejected)` y se anuncia.
132
+ */
133
+ @Component({
134
+ selector: 'mimi-file-upload',
135
+ providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiFileUpload }],
136
+ template: `
137
+ <button
138
+ #zone
139
+ type="button"
140
+ [id]="controlId()"
141
+ [class]="zoneClasses()"
142
+ [disabled]="disabled()"
143
+ [attr.aria-labelledby]="labelledBy()"
144
+ [attr.aria-describedby]="hint() ? hintId : null"
145
+ [attr.aria-invalid]="showInvalid() || null"
146
+ [attr.aria-required]="required() || null"
147
+ [attr.data-state]="over() ? 'over' : 'idle'"
148
+ (click)="openPicker()"
149
+ (dragenter)="onDragEnter($event)"
150
+ (dragover)="onDragOver($event)"
151
+ (dragleave)="onDragLeave()"
152
+ (drop)="onDrop($event)"
153
+ >
154
+ <span aria-hidden="true" [class]="over() ? iconOver : iconIdle">
155
+ <svg
156
+ viewBox="0 0 24 24"
157
+ fill="none"
158
+ stroke="currentColor"
159
+ stroke-width="2"
160
+ stroke-linecap="round"
161
+ stroke-linejoin="round"
162
+ >
163
+ <!-- Lucide cloud-upload (ISC) -->
164
+ <path d="M12 13v8" />
165
+ <path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
166
+ <path d="m8 17 4-4 4 4" />
167
+ </svg>
168
+ </span>
169
+ <span [id]="headingId" data-slot="title" class="text-sm font-semibold">{{
170
+ over() ? texts.drop(dragCount()) : heading() || texts.title
171
+ }}</span>
172
+ @if (hint()) {
173
+ <span [id]="hintId" data-slot="hint" class="text-[12.5px] text-muted-foreground">{{
174
+ hint()
175
+ }}</span>
176
+ }
177
+ </button>
178
+ @if (ariaLabel()) {
179
+ <span [id]="labelId" hidden>{{ ariaLabel() }}</span>
180
+ }
181
+ <input
182
+ #picker
183
+ type="file"
184
+ hidden
185
+ tabindex="-1"
186
+ aria-hidden="true"
187
+ [accept]="accept()"
188
+ [multiple]="multiple()"
189
+ (change)="onPicked($event)"
190
+ />
191
+ `,
192
+ host: {
193
+ class: 'block w-full',
194
+ '[attr.id]': 'null',
195
+ '[attr.aria-label]': 'null',
196
+ '[attr.aria-labelledby]': 'null',
197
+ '[attr.data-disabled]': 'disabled() ? "" : null',
198
+ '[attr.data-invalid]': 'showInvalid() ? "" : null',
199
+ },
200
+ changeDetection: ChangeDetectionStrategy.OnPush,
201
+ })
202
+ export class MimiFileUpload implements FormValueControl<File[]>, MimiFieldControl {
203
+ private readonly announcer = inject(LiveAnnouncer);
204
+ private readonly locale = inject(LOCALE_ID);
205
+ protected readonly texts = inject(MIMI_FILE_UPLOAD_TEXT);
206
+
207
+ /** Los archivos elegidos. Con `[(value)]` o un formulario. */
208
+ readonly value = model<File[]>([]);
209
+ /** Tipos aceptados, como en `<input type="file">` (`image/*,.pdf`). Vale también al soltar. */
210
+ readonly accept = input('');
211
+ /** Varios archivos. Sin `multiple`, uno nuevo reemplaza al anterior. */
212
+ readonly multiple = input(false, { transform: booleanAttribute });
213
+ /** Tamaño máximo de cada archivo, en bytes. */
214
+ readonly maxSize = input<number | undefined, unknown>(undefined, {
215
+ transform: (v: unknown) => (v == null || v === '' ? undefined : numberAttribute(v)),
216
+ });
217
+ /** Cantidad máxima de archivos (contando los que ya están). */
218
+ readonly maxFiles = input<number | undefined, unknown>(undefined, {
219
+ transform: (v: unknown) => (v == null || v === '' ? undefined : numberAttribute(v)),
220
+ });
221
+ /**
222
+ * Título visible de la zona. Por defecto, «Arrastra archivos o haz clic». Es también su nombre
223
+ * accesible (WCAG 2.5.3): `aria-label` y `aria-labelledby` se anteponen, no lo reemplazan. No se
224
+ * llama `title` para no tapar el atributo nativo (el globo del navegador).
225
+ */
226
+ readonly heading = input('');
227
+ /** Qué se acepta («PNG, JPG o PDF · hasta 10 MB»): describe la zona. */
228
+ readonly hint = input('');
229
+ readonly disabled = input(false, { transform: booleanAttribute });
230
+ readonly invalid = input(false, { transform: booleanAttribute });
231
+ readonly touched = input(false, { transform: booleanAttribute });
232
+ readonly dirty = input(false, { transform: booleanAttribute });
233
+ readonly required = input(false, { transform: booleanAttribute });
234
+ readonly id = input<string>();
235
+ /** Va delante del título en el nombre accesible («Avatar, Arrastra archivos o haz clic»). */
236
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
237
+ /** Ids de etiquetas propias; van delante del título en el nombre accesible. */
238
+ readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
239
+ readonly userClass = input('', { alias: 'class' });
240
+
241
+ /** Archivos que no se agregaron, con el motivo. */
242
+ readonly rejected = output<MimiFileRejection[]>();
243
+ /** Se cerró el selector o se soltó: marca el control como tocado. */
244
+ readonly touch = output<void>();
245
+
246
+ private readonly zone = viewChild.required<ElementRef<HTMLButtonElement>>('zone');
247
+ private readonly picker = viewChild.required<ElementRef<HTMLInputElement>>('picker');
248
+ readonly fieldState = injectFieldState();
249
+ get controlElement(): HTMLElement {
250
+ return this.zone().nativeElement;
251
+ }
252
+
253
+ private readonly baseId = `mimi-file-upload-${nextId++}`;
254
+ protected readonly controlId = computed(() => this.id() || this.baseId);
255
+ protected readonly hintId = `${this.baseId}-hint`;
256
+ protected readonly headingId = `${this.baseId}-heading`;
257
+ protected readonly labelId = `${this.baseId}-label`;
258
+ /** El nombre accesible contiene siempre el texto visible del título (WCAG 2.5.3). */
259
+ protected readonly labelledBy = computed(() =>
260
+ [this.ariaLabelledby(), this.ariaLabel() ? this.labelId : null, this.headingId]
261
+ .filter(Boolean)
262
+ .join(' '),
263
+ );
264
+ readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
265
+
266
+ protected readonly over = signal(false);
267
+ protected readonly dragCount = signal(0);
268
+ /** Profundidad de `dragenter`: `dragleave` salta también al pasar sobre un hijo. */
269
+ private depth = 0;
270
+
271
+ protected readonly iconIdle = ICON.idle;
272
+ protected readonly iconOver = ICON.over;
273
+ protected readonly zoneClasses = computed(() =>
274
+ cn(this.over() ? ZONE.over : this.showInvalid() ? ZONE.invalid : ZONE.idle, this.userClass()),
275
+ );
276
+
277
+ /** Enfoca la zona (Signal Forms la usa para llevar el foco al campo con error). */
278
+ focus(options?: FocusOptions): void {
279
+ this.zone().nativeElement.focus(options);
280
+ }
281
+
282
+ /** Abre el selector de archivos del sistema. */
283
+ openPicker(): void {
284
+ if (this.disabled()) return;
285
+ this.picker().nativeElement.click();
286
+ }
287
+
288
+ protected onPicked(event: Event): void {
289
+ const input = event.target as HTMLInputElement;
290
+ this.add(Array.from(input.files ?? []));
291
+ // Vacío: elegir el mismo archivo otra vez vuelve a disparar `change`.
292
+ input.value = '';
293
+ this.touch.emit();
294
+ }
295
+
296
+ protected onDragEnter(event: DragEvent): void {
297
+ if (this.disabled()) return;
298
+ event.preventDefault();
299
+ // Al entrar en la zona (no al pasar a un hijo) se cuentan los archivos que llegan.
300
+ if (this.depth === 0) this.dragCount.set(event.dataTransfer?.items?.length ?? 0);
301
+ this.depth++;
302
+ this.over.set(true);
303
+ }
304
+
305
+ protected onDragOver(event: DragEvent): void {
306
+ if (this.disabled()) return;
307
+ event.preventDefault();
308
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
309
+ }
310
+
311
+ protected onDragLeave(): void {
312
+ this.depth = Math.max(0, this.depth - 1);
313
+ if (this.depth === 0) this.over.set(false);
314
+ }
315
+
316
+ protected onDrop(event: DragEvent): void {
317
+ event.preventDefault();
318
+ this.depth = 0;
319
+ this.over.set(false);
320
+ if (this.disabled()) return;
321
+ this.add(Array.from(event.dataTransfer?.files ?? []));
322
+ this.touch.emit();
323
+ }
324
+
325
+ /** Valida y agrega archivos (también se puede llamar desde la app). */
326
+ add(files: File[]): void {
327
+ if (!files.length) return;
328
+ const accept = this.accept();
329
+ const maxSize = this.maxSize();
330
+ const multiple = this.multiple();
331
+ const current = multiple ? (this.value() ?? []) : [];
332
+ const limit = multiple ? (this.maxFiles() ?? Infinity) : 1;
333
+ const accepted: File[] = [];
334
+ const rejected: MimiFileRejection[] = [];
335
+ for (const file of multiple ? files : files.slice(0, 1)) {
336
+ if (!mimiFileMatchesAccept(file, accept)) {
337
+ rejected.push({ file, reason: 'type', message: this.texts.wrongType });
338
+ } else if (maxSize !== undefined && file.size > maxSize) {
339
+ rejected.push({
340
+ file,
341
+ reason: 'size',
342
+ message: this.texts.tooLarge(mimiFormatFileSize(maxSize, this.locale)),
343
+ });
344
+ } else if (current.length + accepted.length >= limit) {
345
+ rejected.push({ file, reason: 'count', message: this.texts.tooMany(limit) });
346
+ } else {
347
+ accepted.push(file);
348
+ }
349
+ }
350
+ if (!multiple && files.length > 1) {
351
+ for (const file of files.slice(1)) {
352
+ rejected.push({ file, reason: 'count', message: this.texts.tooMany(1) });
353
+ }
354
+ }
355
+ if (accepted.length) {
356
+ this.value.set([...current, ...accepted]);
357
+ void this.announcer.announce(this.texts.added(accepted.length), 'polite');
358
+ }
359
+ if (rejected.length) {
360
+ this.rejected.emit(rejected);
361
+ void this.announcer.announce(
362
+ rejected.map((r) => this.texts.rejected(r.file.name, r.message)).join(' '),
363
+ 'polite',
364
+ );
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Ícono propio de una fila: `<svg mimiFileIcon lucideFileSpreadsheet />` dentro de
371
+ * `mimi-file-item`. Sin él, el de su tipo (imagen, PDF, video u otro).
372
+ */
373
+ @Directive({ selector: '[mimiFileIcon]', host: { 'aria-hidden': 'true' } })
374
+ export class MimiFileIcon {}
375
+
376
+ /**
377
+ * La lista de archivos (`<ul>`): `<mimi-file-list [emptyFocus]="zona">`. Si se quita el último
378
+ * archivo con el foco en su botón, el foco va a `emptyFocus` (la zona de soltar).
379
+ */
380
+ @Component({
381
+ selector: 'mimi-file-list',
382
+ template: `<ng-content />`,
383
+ host: {
384
+ role: 'list',
385
+ '[attr.aria-label]': 'ariaLabel() || texts.list',
386
+ '[class]': 'classes()',
387
+ },
388
+ changeDetection: ChangeDetectionStrategy.OnPush,
389
+ })
390
+ export class MimiFileList {
391
+ protected readonly texts = inject(MIMI_FILE_UPLOAD_TEXT);
392
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
393
+ /** Adónde va el foco si se quita el último archivo (algo con `focus()`). */
394
+ readonly emptyFocus = input<{ focus(): void } | null>(null);
395
+ readonly userClass = input('', { alias: 'class' });
396
+ protected readonly classes = computed(() => cn('flex flex-col gap-2', this.userClass()));
397
+ }
398
+
399
+ const TYPE_ICON = {
400
+ image: {
401
+ tone: 'bg-info-soft text-info-soft-foreground',
402
+ paths: [
403
+ 'M21 15l-3.086-3.086a2 2 0 0 0-2.828 0L6 21',
404
+ 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
405
+ 'M9 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4',
406
+ ],
407
+ },
408
+ pdf: {
409
+ tone: 'bg-destructive-soft-bg text-destructive-soft-foreground',
410
+ paths: [
411
+ 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z',
412
+ 'M14 2v4a2 2 0 0 0 2 2h4',
413
+ 'M10 9H8',
414
+ 'M16 13H8',
415
+ 'M16 17H8',
416
+ ],
417
+ },
418
+ video: {
419
+ tone: 'bg-warning-soft text-warning-soft-foreground',
420
+ paths: [
421
+ 'm16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5',
422
+ 'M4 6h10a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z',
423
+ ],
424
+ },
425
+ other: {
426
+ tone: 'bg-muted text-muted-foreground',
427
+ paths: [
428
+ 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z',
429
+ 'M14 2v4a2 2 0 0 0 2 2h4',
430
+ ],
431
+ },
432
+ } as const;
433
+
434
+ const ITEM_BASE = cn(
435
+ 'flex items-center gap-3 border bg-card p-2.5 text-card-foreground shadow-[shadow:var(--mimi-shadow-neutral)]',
436
+ 'rounded-[var(--mimi-upload-item-radius,var(--mimi-radius,0.75rem))]',
437
+ );
438
+ const ITEM = {
439
+ default: cn(ITEM_BASE, 'border-border'),
440
+ error: cn(
441
+ ITEM_BASE,
442
+ 'border-[color:color-mix(in_oklab,var(--mimi-destructive)_45%,var(--mimi-border))]',
443
+ ),
444
+ } as const;
445
+
446
+ /**
447
+ * Una fila de la lista, con el estado que da la app:
448
+ *
449
+ * ```html
450
+ * <mimi-file-item [file]="file" [status]="estado" [progress]="64" (remove)="quitar(file)" />
451
+ * <mimi-file-item name="contrato.pdf" [size]="182000" status="done" (remove)="…" />
452
+ * ```
453
+ */
454
+ @Component({
455
+ selector: 'mimi-file-item',
456
+ imports: [MimiButton, MimiProgress],
457
+ template: `
458
+ @if (thumbnail(); as url) {
459
+ <img
460
+ alt=""
461
+ data-slot="thumbnail"
462
+ class="size-[var(--mimi-upload-thumb-size,44px)] shrink-0 rounded-[var(--mimi-radius-sm)] object-cover"
463
+ [src]="url"
464
+ />
465
+ } @else {
466
+ <span
467
+ aria-hidden="true"
468
+ data-slot="icon"
469
+ class="inline-flex size-[var(--mimi-upload-thumb-size,44px)] shrink-0 items-center justify-center rounded-[var(--mimi-radius-sm)] [&_svg]:size-5"
470
+ [class]="typeIcon().tone"
471
+ >
472
+ @if (customIcon()) {
473
+ <ng-content select="[mimiFileIcon]" />
474
+ } @else {
475
+ <svg
476
+ viewBox="0 0 24 24"
477
+ fill="none"
478
+ stroke="currentColor"
479
+ stroke-width="2"
480
+ stroke-linecap="round"
481
+ stroke-linejoin="round"
482
+ >
483
+ @for (d of typeIcon().paths; track $index) {
484
+ <path [attr.d]="d" />
485
+ }
486
+ </svg>
487
+ }
488
+ </span>
489
+ }
490
+ <span class="flex min-w-0 flex-1 flex-col gap-1">
491
+ <span class="flex items-baseline gap-2">
492
+ <span data-slot="name" class="min-w-0 flex-1 truncate text-sm font-medium">{{
493
+ fileName()
494
+ }}</span>
495
+ <span data-slot="size" class="shrink-0 text-xs text-muted-foreground tabular-nums">{{
496
+ sizeText()
497
+ }}</span>
498
+ </span>
499
+ @switch (status()) {
500
+ @case ('uploading') {
501
+ <span class="flex items-center gap-2">
502
+ <mimi-progress
503
+ class="flex-1 [--mimi-progress-height:5px]"
504
+ [value]="progress()"
505
+ aria-label="{{ texts.uploading(fileName()) }}"
506
+ />
507
+ <span class="w-9 shrink-0 text-right text-xs text-muted-foreground tabular-nums"
508
+ >{{ progress() ?? 0 }}%</span
509
+ >
510
+ </span>
511
+ }
512
+ @case ('done') {
513
+ <span
514
+ data-slot="done"
515
+ class="flex items-center gap-1 text-xs text-success-soft-foreground"
516
+ >
517
+ <svg
518
+ aria-hidden="true"
519
+ class="size-3.5"
520
+ viewBox="0 0 24 24"
521
+ fill="none"
522
+ stroke="currentColor"
523
+ stroke-width="2"
524
+ stroke-linecap="round"
525
+ stroke-linejoin="round"
526
+ >
527
+ <circle cx="12" cy="12" r="10" />
528
+ <path d="m9 12 2 2 4-4" />
529
+ </svg>
530
+ {{ texts.uploaded }}
531
+ </span>
532
+ }
533
+ @case ('error') {
534
+ <span data-slot="error" class="text-xs text-destructive-soft-foreground">{{
535
+ error()
536
+ }}</span>
537
+ }
538
+ }
539
+ </span>
540
+ @if (status() === 'error' && retryable()) {
541
+ <button
542
+ mimiBtn
543
+ type="button"
544
+ variant="outline"
545
+ size="sm"
546
+ class="h-[30px]"
547
+ (click)="retry.emit()"
548
+ >
549
+ {{ texts.retry }}
550
+ </button>
551
+ }
552
+ <button
553
+ #removeButton
554
+ mimiBtn
555
+ type="button"
556
+ variant="ghost"
557
+ size="icon"
558
+ data-slot="remove"
559
+ class="size-[30px] text-muted-foreground hover:bg-destructive-soft-bg hover:text-destructive-soft-foreground [&_svg]:size-4"
560
+ [attr.aria-label]="texts.remove(fileName())"
561
+ (click)="onRemove()"
562
+ >
563
+ <svg
564
+ aria-hidden="true"
565
+ viewBox="0 0 24 24"
566
+ fill="none"
567
+ stroke="currentColor"
568
+ stroke-width="2"
569
+ stroke-linecap="round"
570
+ stroke-linejoin="round"
571
+ >
572
+ <!-- Lucide trash-2 (ISC) -->
573
+ <path d="M3 6h18" />
574
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
575
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
576
+ <path d="M10 11v6" />
577
+ <path d="M14 11v6" />
578
+ </svg>
579
+ </button>
580
+ `,
581
+ host: {
582
+ role: 'listitem',
583
+ '[class]': 'classes()',
584
+ '[attr.data-status]': 'status()',
585
+ },
586
+ changeDetection: ChangeDetectionStrategy.OnPush,
587
+ })
588
+ export class MimiFileItem {
589
+ private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
590
+ private readonly list = inject(MimiFileList, { optional: true });
591
+ private readonly announcer = inject(LiveAnnouncer);
592
+ private readonly locale = inject(LOCALE_ID);
593
+ protected readonly texts = inject(MIMI_FILE_UPLOAD_TEXT);
594
+
595
+ /** El archivo (da el nombre, el tamaño, el tipo y la miniatura de las imágenes). */
596
+ readonly file = input<File | null>(null);
597
+ /** Nombre, tamaño (bytes) y tipo MIME, para archivos que no son un `File` (ya subidos). */
598
+ readonly name = input('');
599
+ readonly size = input<number | undefined, unknown>(undefined, {
600
+ transform: (v: unknown) => (v == null || v === '' ? undefined : numberAttribute(v)),
601
+ });
602
+ readonly type = input('');
603
+ /** URL de una miniatura propia (por ejemplo, la del servidor). */
604
+ readonly thumbnailUrl = input('');
605
+ readonly status = input<MimiFileStatus>('idle');
606
+ /** Porcentaje (0–100) con `status="uploading"`; sin él, la barra es indeterminada. */
607
+ readonly progress = input<number | null>(null);
608
+ /** Mensaje con `status="error"`. */
609
+ readonly error = input('');
610
+ /** Muestra «Reintentar» con `status="error"`. */
611
+ readonly retryable = input(true, { transform: booleanAttribute });
612
+ readonly userClass = input('', { alias: 'class' });
613
+
614
+ readonly remove = output<void>();
615
+ readonly retry = output<void>();
616
+
617
+ protected readonly customIcon = contentChild(MimiFileIcon);
618
+ // `read: ElementRef`: en un botón con `mimiBtn` (un componente), la referencia sería el componente.
619
+ private readonly removeButton = viewChild.required('removeButton', {
620
+ read: ElementRef<HTMLButtonElement>,
621
+ });
622
+
623
+ protected readonly fileName = computed(() => this.name() || this.file()?.name || '');
624
+ private readonly fileType = computed(() => this.type() || this.file()?.type || '');
625
+ protected readonly sizeText = computed(() => {
626
+ const size = this.size() ?? this.file()?.size;
627
+ return size === undefined ? '' : mimiFormatFileSize(size, this.locale);
628
+ });
629
+ protected readonly typeIcon = computed(() => {
630
+ const type = this.fileType();
631
+ const name = this.fileName().toLowerCase();
632
+ if (type.startsWith('image/')) return TYPE_ICON.image;
633
+ if (type === 'application/pdf' || name.endsWith('.pdf')) return TYPE_ICON.pdf;
634
+ if (type.startsWith('video/')) return TYPE_ICON.video;
635
+ return TYPE_ICON.other;
636
+ });
637
+ protected readonly classes = computed(() =>
638
+ cn(this.status() === 'error' ? ITEM.error : ITEM.default, this.userClass()),
639
+ );
640
+
641
+ /** URL de la miniatura de una imagen (`URL.createObjectURL`), liberada al cambiar o destruir. */
642
+ private objectUrl: string | null = null;
643
+ protected readonly thumbnail = computed(() => {
644
+ if (this.thumbnailUrl()) return this.thumbnailUrl();
645
+ const file = this.file();
646
+ untracked(() => this.revoke());
647
+ if (!file || !file.type.startsWith('image/')) return null;
648
+ this.objectUrl = URL.createObjectURL(file);
649
+ return this.objectUrl;
650
+ });
651
+
652
+ /** Anuncia «subido» y «con error» cuando el estado cambia (no el estado inicial). */
653
+ private previous: MimiFileStatus | null = null;
654
+ private readonly announceStatus = effect(() => {
655
+ const status = this.status();
656
+ const previous = this.previous;
657
+ this.previous = status;
658
+ if (previous === null || previous === status) return;
659
+ const name = untracked(this.fileName);
660
+ if (status === 'done') void this.announcer.announce(this.texts.done(name), 'polite');
661
+ if (status === 'error') {
662
+ void this.announcer.announce(this.texts.failed(name, untracked(this.error)), 'polite');
663
+ }
664
+ });
665
+
666
+ constructor() {
667
+ inject(DestroyRef).onDestroy(() => this.revoke());
668
+ }
669
+
670
+ private revoke(): void {
671
+ if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
672
+ this.objectUrl = null;
673
+ }
674
+
675
+ /**
676
+ * Quitar: si el foco está en este botón, pasa al botón de quitar del siguiente, del anterior o
677
+ * a `emptyFocus` de la lista, antes de que la fila desaparezca (nunca a `body`).
678
+ */
679
+ protected onRemove(): void {
680
+ if (document.activeElement === this.removeButton().nativeElement) {
681
+ const sibling = (element: Element | null) =>
682
+ element?.querySelector<HTMLElement>('[data-slot="remove"]') ?? null;
683
+ const next =
684
+ sibling(this.host.nextElementSibling) ?? sibling(this.host.previousElementSibling);
685
+ if (next) next.focus();
686
+ else this.list?.emptyFocus()?.focus();
687
+ }
688
+ this.remove.emit();
689
+ }
690
+ }
691
+
692
+ /** Todas las piezas de File Upload, para importarlas juntas. */
693
+ export const MimiFileUploadImports = [
694
+ MimiFileUpload,
695
+ MimiFileList,
696
+ MimiFileItem,
697
+ MimiFileIcon,
698
+ ] as const;
@@ -0,0 +1,2 @@
1
+ export * from './file-upload';
2
+ export * from './file-upload-texts';