@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,2 @@
1
+ export * from './combobox';
2
+ export * from './combobox-texts';
@@ -0,0 +1,240 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ ElementRef,
5
+ LOCALE_ID,
6
+ booleanAttribute,
7
+ computed,
8
+ inject,
9
+ input,
10
+ model,
11
+ output,
12
+ signal,
13
+ viewChild,
14
+ } from '@angular/core';
15
+ import type { FormValueControl } from '@angular/forms/signals';
16
+ import { MimiCalendar } from '@/components/ui/calendar/calendar';
17
+ import { MIMI_DATE_PICKER_TEXT } from '@/components/ui/calendar/calendar-texts';
18
+ import { type MimiDate, mimiFormatDate, mimiIsDate } from '@/components/ui/calendar/dates';
19
+ import { MimiPopover, MimiPopoverContent, MimiPopoverTrigger } from '@/components/ui/popover';
20
+ import { cn } from '@/components/ui/utils/cn';
21
+ import {
22
+ controlInvalidStyles,
23
+ controlPressStyles,
24
+ fieldFocusStyles,
25
+ } from '@/components/ui/utils/control-styles';
26
+ import {
27
+ MIMI_FIELD_CONTROL,
28
+ type MimiFieldControl,
29
+ injectFieldState,
30
+ } from '@/components/ui/utils/field-state';
31
+
32
+ /*
33
+ * Date Picker (docs/design/Mimi F3 Combobox Fechas Hora.dc.html, «2 · Date Picker y Date
34
+ * Range»; prefijo de tokens `date`, spec 13): un botón con la fecha que abre el calendario en un
35
+ * Popover (`role="dialog"`). No se escribe la fecha a mano en la 0.3.0 (decidido por defecto en
36
+ * la G2.9, como el diseño).
37
+ */
38
+
39
+ /** El disparador: la caja de Input con un botón (los estados de Input, spec 13, «Campos»). */
40
+ export const MIMI_DATE_TRIGGER_CLASSES = cn(
41
+ 'flex h-[var(--mimi-control-height,2.5rem)] w-full min-w-0 cursor-pointer items-center gap-2 px-3 text-left font-sans text-sm text-foreground',
42
+ 'rounded-[var(--mimi-radius,0.75rem)] border border-input bg-input-background not-disabled:hover:border-input-hover',
43
+ 'disabled:cursor-not-allowed disabled:opacity-[var(--mimi-disabled-opacity,0.5)] [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',
44
+ fieldFocusStyles,
45
+ controlInvalidStyles,
46
+ controlPressStyles,
47
+ );
48
+
49
+ /** El panel del Popover con los tokens del calendario. */
50
+ export const MIMI_DATE_PANEL_CLASSES =
51
+ 'w-auto gap-2 p-[var(--mimi-date-padding,12px)] bg-[color:var(--mimi-date-menu-bg,var(--mimi-popover))] shadow-[shadow:var(--mimi-date-menu-shadow,var(--mimi-shadow-popover))]';
52
+
53
+ /** Formato del disparador por defecto: «30 sept 2026». */
54
+ const DEFAULT_FORMAT: Intl.DateTimeFormatOptions = {
55
+ day: 'numeric',
56
+ month: 'short',
57
+ year: 'numeric',
58
+ };
59
+
60
+ let nextId = 0;
61
+
62
+ /** `null` y la cadena vacía son «sin límite». */
63
+ const toDate = (value: unknown): MimiDate | undefined =>
64
+ value === null || value === undefined || value === '' ? undefined : String(value);
65
+
66
+ /**
67
+ * Date Picker:
68
+ *
69
+ * ```html
70
+ * <mimi-date-picker aria-label="Fecha de entrega" [(value)]="fecha" [isDateDisabled]="finDeSemana" />
71
+ * ```
72
+ *
73
+ * `value` es una fecha ISO (`'2026-09-30'`; `toMimiDate` y `fromMimiDate` convierten de y a
74
+ * `Date`). Implementa `FormValueControl`: funciona con `[formField]`, `formControlName` y
75
+ * `[(ngModel)]` (spec 8). `class` va al disparador y `panelClass` al panel.
76
+ */
77
+ @Component({
78
+ selector: 'mimi-date-picker',
79
+ imports: [MimiPopover, MimiPopoverTrigger, MimiPopoverContent, MimiCalendar],
80
+ providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiDatePicker }],
81
+ template: `
82
+ <mimi-popover
83
+ align="start"
84
+ [arrow]="false"
85
+ [class]="panelClasses()"
86
+ [(open)]="open"
87
+ (openChange)="onOpenChange($event)"
88
+ aria-label="{{ texts.pickDate }}"
89
+ >
90
+ <button
91
+ #trigger
92
+ type="button"
93
+ mimiPopoverTrigger
94
+ [id]="controlId()"
95
+ [class]="classes()"
96
+ [disabled]="disabled()"
97
+ [attr.aria-label]="ariaLabel() || null"
98
+ [attr.aria-labelledby]="ariaLabelledby() || null"
99
+ [attr.aria-describedby]="valueId"
100
+ [attr.aria-invalid]="showInvalid() || null"
101
+ [attr.aria-required]="required() || null"
102
+ [attr.data-placeholder]="label() ? null : ''"
103
+ >
104
+ <svg
105
+ aria-hidden="true"
106
+ viewBox="0 0 24 24"
107
+ fill="none"
108
+ stroke="currentColor"
109
+ stroke-width="2"
110
+ stroke-linecap="round"
111
+ stroke-linejoin="round"
112
+ >
113
+ <!-- Lucide calendar (ISC) -->
114
+ <path d="M8 2v4" />
115
+ <path d="M16 2v4" />
116
+ <rect width="18" height="18" x="3" y="4" rx="2" />
117
+ <path d="M3 10h18" />
118
+ </svg>
119
+ <span
120
+ class="min-w-0 flex-1 truncate tabular-nums"
121
+ [class.text-muted-foreground]="!label()"
122
+ [id]="valueId"
123
+ >{{ label() || placeholder() }}</span
124
+ >
125
+ </button>
126
+ <ng-template mimiPopoverContent>
127
+ <mimi-calendar
128
+ [value]="calendarValue()"
129
+ [min]="min() ?? null"
130
+ [max]="max() ?? null"
131
+ [isDateDisabled]="isDateDisabled()"
132
+ [locale]="lang()"
133
+ [weekStart]="weekStart()"
134
+ (datePick)="pick($event)"
135
+ />
136
+ </ng-template>
137
+ </mimi-popover>
138
+ `,
139
+ host: {
140
+ class: 'block w-full min-w-0',
141
+ '[attr.class]': 'null',
142
+ '[attr.id]': 'null',
143
+ '[attr.aria-label]': 'null',
144
+ '[attr.aria-labelledby]': 'null',
145
+ '[attr.data-state]': 'open() ? "open" : "closed"',
146
+ '[attr.data-invalid]': 'showInvalid() ? "" : null',
147
+ '[attr.data-disabled]': 'disabled() ? "" : null',
148
+ },
149
+ changeDetection: ChangeDetectionStrategy.OnPush,
150
+ })
151
+ export class MimiDatePicker implements FormValueControl<MimiDate | null>, MimiFieldControl {
152
+ private readonly defaultLocale = inject(LOCALE_ID);
153
+ protected readonly texts = inject(MIMI_DATE_PICKER_TEXT);
154
+
155
+ /** La fecha elegida (`'2026-09-30'`), o `null`. Con `[(value)]` o un formulario. */
156
+ readonly value = model<MimiDate | null>(null);
157
+ /**
158
+ * Primera y última fecha que se pueden elegir (incluidas). Tienen el tipo del valor (lo exige
159
+ * `FormValueControl`). Con `[formField]`, Signal Forms no deja enlazarlas (NG8022): los límites
160
+ * van en `isDateDisabled` y la validación, en el esquema (spec 8).
161
+ */
162
+ readonly min = input<MimiDate | undefined, unknown>(undefined, { transform: toDate });
163
+ readonly max = input<MimiDate | undefined, unknown>(undefined, { transform: toDate });
164
+ /** Fechas que no se pueden elegir (por ejemplo, los fines de semana). */
165
+ readonly isDateDisabled = input<((date: MimiDate) => boolean) | null>(null);
166
+ /** Idioma (nombres, formato y primer día de la semana). Por defecto, `LOCALE_ID`. */
167
+ readonly locale = input<string>();
168
+ /** Primer día de la semana (1 = lunes … 7 = domingo). Por defecto, el del idioma. */
169
+ readonly weekStart = input<number>();
170
+ /** Formato del disparador (opciones de `Intl.DateTimeFormat`). Por defecto, «30 sept 2026». */
171
+ readonly format = input<Intl.DateTimeFormatOptions>(DEFAULT_FORMAT);
172
+ readonly placeholder = input('');
173
+ readonly disabled = input(false, { transform: booleanAttribute });
174
+ /** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
175
+ readonly invalid = input(false, { transform: booleanAttribute });
176
+ readonly touched = input(false, { transform: booleanAttribute });
177
+ readonly dirty = input(false, { transform: booleanAttribute });
178
+ readonly required = input(false, { transform: booleanAttribute });
179
+ readonly id = input<string>();
180
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
181
+ readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
182
+ /** Clases del disparador, mezcladas con `cn()`. */
183
+ readonly userClass = input('', { alias: 'class' });
184
+ /** Clases del panel. */
185
+ readonly panelClass = input('');
186
+
187
+ /** Se cerró el panel: marca el control como tocado. */
188
+ readonly touch = output<void>();
189
+
190
+ private readonly trigger = viewChild.required<ElementRef<HTMLButtonElement>>('trigger');
191
+ readonly fieldState = injectFieldState();
192
+ get controlElement(): HTMLElement {
193
+ return this.trigger().nativeElement;
194
+ }
195
+
196
+ private readonly baseId = `mimi-date-picker-${nextId++}`;
197
+ protected readonly controlId = computed(() => this.id() || this.baseId);
198
+ protected readonly valueId = `${this.baseId}-value`;
199
+ readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
200
+
201
+ protected readonly open = signal(false);
202
+ protected readonly lang = computed(() => this.locale() || this.defaultLocale);
203
+ protected readonly classes = computed(() => cn(MIMI_DATE_TRIGGER_CLASSES, this.userClass()));
204
+ protected readonly panelClasses = computed(() => cn(MIMI_DATE_PANEL_CLASSES, this.panelClass()));
205
+
206
+ /** Solo una fecha válida llega al calendario (un valor raro del formulario se muestra igual). */
207
+ protected readonly calendarValue = computed(() => {
208
+ const value = this.value();
209
+ return mimiIsDate(value) ? value : null;
210
+ });
211
+ /**
212
+ * Texto del disparador. El valor vive en Mimi (spec 4): una fecha fuera de `min`/`max` o no
213
+ * disponible que llega del formulario se muestra igual; el formulario decide si es un error.
214
+ */
215
+ protected readonly label = computed(() => {
216
+ const value = this.calendarValue();
217
+ return value ? mimiFormatDate(value, this.lang(), this.format()) : '';
218
+ });
219
+
220
+ /** Enfoca el disparador. Lo usa Signal Forms para llevar el foco al campo con error. */
221
+ focus(options?: FocusOptions): void {
222
+ this.trigger().nativeElement.focus(options);
223
+ }
224
+
225
+ protected pick(date: MimiDate): void {
226
+ this.value.set(date);
227
+ this.setOpen(false);
228
+ }
229
+
230
+ /** Lo llama el Popover (clic, Escape, clic fuera). */
231
+ protected onOpenChange(open: boolean): void {
232
+ if (!open) this.touch.emit();
233
+ }
234
+
235
+ private setOpen(open: boolean): void {
236
+ if (this.open() === open) return;
237
+ this.open.set(open);
238
+ if (!open) this.touch.emit();
239
+ }
240
+ }
@@ -0,0 +1,3 @@
1
+ // Solo la API pública: las clases del disparador y del panel son internas (las usa el Date Range
2
+ // desde './date-picker').
3
+ export { MimiDatePicker } from './date-picker';
@@ -0,0 +1,308 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ ElementRef,
5
+ LOCALE_ID,
6
+ booleanAttribute,
7
+ computed,
8
+ inject,
9
+ input,
10
+ model,
11
+ output,
12
+ signal,
13
+ viewChild,
14
+ } from '@angular/core';
15
+ import type { FormValueControl } from '@angular/forms/signals';
16
+ import { MimiCalendar } from '@/components/ui/calendar/calendar';
17
+ import { MIMI_DATE_PICKER_TEXT } from '@/components/ui/calendar/calendar-texts';
18
+ import {
19
+ type MimiDate,
20
+ type MimiDateRange,
21
+ mimiDaysBetween,
22
+ mimiFormatDate,
23
+ mimiIsDate,
24
+ } from '@/components/ui/calendar/dates';
25
+ import {
26
+ MIMI_DATE_PANEL_CLASSES,
27
+ MIMI_DATE_TRIGGER_CLASSES,
28
+ } from '@/components/ui/date-picker/date-picker';
29
+ import { MimiPopover, MimiPopoverContent, MimiPopoverTrigger } from '@/components/ui/popover';
30
+ import { cn } from '@/components/ui/utils/cn';
31
+ import { buttonFocusStyles } from '@/components/ui/utils/control-styles';
32
+ import {
33
+ MIMI_FIELD_CONTROL,
34
+ type MimiFieldControl,
35
+ injectFieldState,
36
+ } from '@/components/ui/utils/field-state';
37
+
38
+ /*
39
+ * Date Range (docs/design/Mimi F3 Combobox Fechas Hora.dc.html, «2 · Date Picker y Date
40
+ * Range»; prefijo de tokens `date`, spec 13): un botón con el periodo y la cuenta de días que
41
+ * abre el calendario en modo rango, con atajos opcionales. Componente aparte del Date Picker
42
+ * (decidido por defecto en la G2.9), con el mismo calendario.
43
+ *
44
+ * Elección: el primer clic marca el inicio; el segundo, el fin (si es anterior, se intercambian);
45
+ * el siguiente empieza otro periodo. El modelo cambia solo con los dos extremos (decidido por
46
+ * defecto en la G2.9): lo que se ve con solo el inicio vive en Mimi.
47
+ */
48
+
49
+ /** Un atajo: texto y periodo (`{ label: 'Últimos 7 días', range: { start, end } }`). */
50
+ export interface MimiDateRangePreset {
51
+ label: string;
52
+ range: MimiDateRange;
53
+ }
54
+
55
+ const DEFAULT_FORMAT: Intl.DateTimeFormatOptions = {
56
+ day: 'numeric',
57
+ month: 'short',
58
+ year: 'numeric',
59
+ };
60
+
61
+ const PRESET_BASE = cn(
62
+ 'h-8 cursor-pointer rounded-[var(--mimi-radius-sm)] border-0 px-2 text-left text-[13px] text-foreground mimi-transition hover:bg-accent active:scale-[0.96]',
63
+ buttonFocusStyles,
64
+ );
65
+ const PRESET = {
66
+ on: cn(PRESET_BASE, 'bg-accent font-semibold'),
67
+ off: cn(PRESET_BASE, 'bg-transparent'),
68
+ } as const;
69
+
70
+ const toDate = (value: unknown): MimiDate | undefined =>
71
+ value === null || value === undefined || value === '' ? undefined : String(value);
72
+
73
+ const isRange = (value: unknown): value is MimiDateRange =>
74
+ !!value &&
75
+ typeof value === 'object' &&
76
+ mimiIsDate((value as MimiDateRange).start) &&
77
+ mimiIsDate((value as MimiDateRange).end);
78
+
79
+ let nextId = 0;
80
+
81
+ /**
82
+ * Date Range:
83
+ *
84
+ * ```html
85
+ * <mimi-date-range aria-label="Periodo" [(value)]="periodo" [maxDate]="hoy" [presets]="atajos" />
86
+ * ```
87
+ *
88
+ * `value` es `{ start, end }` con fechas ISO (`MimiDateRange`), o `null`. Implementa
89
+ * `FormValueControl`. `class` va al disparador y `panelClass` al panel.
90
+ */
91
+ @Component({
92
+ selector: 'mimi-date-range',
93
+ imports: [MimiPopover, MimiPopoverTrigger, MimiPopoverContent, MimiCalendar],
94
+ providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiDateRangePicker }],
95
+ template: `
96
+ <mimi-popover
97
+ align="start"
98
+ [arrow]="false"
99
+ [class]="panelClasses()"
100
+ [(open)]="open"
101
+ (openChange)="onOpenChange($event)"
102
+ aria-label="{{ texts.pickRange }}"
103
+ >
104
+ <button
105
+ #trigger
106
+ type="button"
107
+ mimiPopoverTrigger
108
+ [id]="controlId()"
109
+ [class]="classes()"
110
+ [disabled]="disabled()"
111
+ [attr.aria-label]="ariaLabel() || null"
112
+ [attr.aria-labelledby]="ariaLabelledby() || null"
113
+ [attr.aria-describedby]="valueId"
114
+ [attr.aria-invalid]="showInvalid() || null"
115
+ [attr.aria-required]="required() || null"
116
+ [attr.data-placeholder]="label() ? null : ''"
117
+ >
118
+ <svg
119
+ aria-hidden="true"
120
+ viewBox="0 0 24 24"
121
+ fill="none"
122
+ stroke="currentColor"
123
+ stroke-width="2"
124
+ stroke-linecap="round"
125
+ stroke-linejoin="round"
126
+ >
127
+ <!-- Lucide calendar-range (ISC) -->
128
+ <rect width="18" height="18" x="3" y="4" rx="2" />
129
+ <path d="M16 2v4" />
130
+ <path d="M3 10h18" />
131
+ <path d="M8 2v4" />
132
+ <path d="M17 14h-6" />
133
+ <path d="M13 18H7" />
134
+ <path d="M7 14h.01" />
135
+ <path d="M17 18h.01" />
136
+ </svg>
137
+ <span [id]="valueId" class="flex min-w-0 flex-1 items-center gap-2">
138
+ <span
139
+ class="min-w-0 flex-1 truncate tabular-nums"
140
+ [class.text-muted-foreground]="!label()"
141
+ >{{ label() || placeholder() }}</span
142
+ >
143
+ @if (days(); as days) {
144
+ <!-- La coma separa la fecha de los días para el lector (el espacio lo da el flex). -->
145
+ <span class="sr-only">, </span>
146
+ <span data-slot="days" class="shrink-0 text-xs text-muted-foreground">{{ days }}</span>
147
+ }
148
+ </span>
149
+ </button>
150
+ <ng-template mimiPopoverContent>
151
+ @if (presets().length) {
152
+ <div
153
+ data-slot="presets"
154
+ class="flex w-[var(--mimi-date-presets-width,130px)] shrink-0 flex-col gap-0.5 border-e border-border p-2"
155
+ >
156
+ @for (preset of presets(); track preset.label) {
157
+ <button
158
+ type="button"
159
+ [class]="isPreset(preset) ? presetOn : presetOff"
160
+ [attr.aria-pressed]="isPreset(preset)"
161
+ (click)="pickPreset(preset)"
162
+ >
163
+ {{ preset.label }}
164
+ </button>
165
+ }
166
+ </div>
167
+ }
168
+ <div class="flex flex-col gap-2 p-[var(--mimi-date-padding,12px)]">
169
+ <mimi-calendar
170
+ mode="range"
171
+ [rangeStart]="shownStart()"
172
+ [rangeEnd]="shownEnd()"
173
+ [min]="minDate() ?? null"
174
+ [max]="maxDate() ?? null"
175
+ [isDateDisabled]="isDateDisabled()"
176
+ [locale]="lang()"
177
+ [weekStart]="weekStart()"
178
+ (datePick)="pickDay($event)"
179
+ />
180
+ <span data-slot="help" class="text-xs text-muted-foreground" aria-live="polite">{{
181
+ startPick() ? texts.pickEnd : texts.pickStart
182
+ }}</span>
183
+ </div>
184
+ </ng-template>
185
+ </mimi-popover>
186
+ `,
187
+ host: {
188
+ class: 'block w-full min-w-0',
189
+ '[attr.class]': 'null',
190
+ '[attr.id]': 'null',
191
+ '[attr.aria-label]': 'null',
192
+ '[attr.aria-labelledby]': 'null',
193
+ '[attr.data-state]': 'open() ? "open" : "closed"',
194
+ '[attr.data-invalid]': 'showInvalid() ? "" : null',
195
+ '[attr.data-disabled]': 'disabled() ? "" : null',
196
+ },
197
+ changeDetection: ChangeDetectionStrategy.OnPush,
198
+ })
199
+ export class MimiDateRangePicker
200
+ implements FormValueControl<MimiDateRange | null>, MimiFieldControl
201
+ {
202
+ private readonly defaultLocale = inject(LOCALE_ID);
203
+ protected readonly texts = inject(MIMI_DATE_PICKER_TEXT);
204
+
205
+ /** El periodo (`{ start: '2026-09-24', end: '2026-09-30' }`), o `null`. */
206
+ readonly value = model<MimiDateRange | null>(null);
207
+ /**
208
+ * Primera y última fecha que se pueden elegir. No se llaman `min`/`max`: `FormValueControl` les
209
+ * daría el tipo del valor (un periodo).
210
+ */
211
+ readonly minDate = input<MimiDate | undefined, unknown>(undefined, { transform: toDate });
212
+ readonly maxDate = input<MimiDate | undefined, unknown>(undefined, { transform: toDate });
213
+ /** Fechas que no se pueden elegir como inicio o fin. */
214
+ readonly isDateDisabled = input<((date: MimiDate) => boolean) | null>(null);
215
+ /** Atajos de la columna izquierda. Sin atajos, no hay columna. */
216
+ readonly presets = input<readonly MimiDateRangePreset[]>([]);
217
+ readonly locale = input<string>();
218
+ readonly weekStart = input<number>();
219
+ /** Formato de cada fecha en el botón (opciones de `Intl.DateTimeFormat`). */
220
+ readonly format = input<Intl.DateTimeFormatOptions>(DEFAULT_FORMAT);
221
+ readonly placeholder = input('');
222
+ readonly disabled = input(false, { transform: booleanAttribute });
223
+ readonly invalid = input(false, { transform: booleanAttribute });
224
+ readonly touched = input(false, { transform: booleanAttribute });
225
+ readonly dirty = input(false, { transform: booleanAttribute });
226
+ readonly required = input(false, { transform: booleanAttribute });
227
+ readonly id = input<string>();
228
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
229
+ readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
230
+ readonly userClass = input('', { alias: 'class' });
231
+ readonly panelClass = input('');
232
+
233
+ /** Se cerró el panel: marca el control como tocado. */
234
+ readonly touch = output<void>();
235
+
236
+ private readonly trigger = viewChild.required<ElementRef<HTMLButtonElement>>('trigger');
237
+ readonly fieldState = injectFieldState();
238
+ get controlElement(): HTMLElement {
239
+ return this.trigger().nativeElement;
240
+ }
241
+
242
+ private readonly baseId = `mimi-date-range-${nextId++}`;
243
+ protected readonly controlId = computed(() => this.id() || this.baseId);
244
+ protected readonly valueId = `${this.baseId}-value`;
245
+ readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
246
+
247
+ protected readonly open = signal(false);
248
+ /** Inicio elegido mientras falta el fin (no está en el modelo). */
249
+ protected readonly startPick = signal<MimiDate | null>(null);
250
+ protected readonly presetOn = PRESET.on;
251
+ protected readonly presetOff = PRESET.off;
252
+
253
+ protected readonly lang = computed(() => this.locale() || this.defaultLocale);
254
+ protected readonly classes = computed(() => cn(MIMI_DATE_TRIGGER_CLASSES, this.userClass()));
255
+ protected readonly panelClasses = computed(() =>
256
+ cn(MIMI_DATE_PANEL_CLASSES, 'flex-row gap-0 overflow-hidden p-0', this.panelClass()),
257
+ );
258
+
259
+ private readonly range = computed(() => {
260
+ const value = this.value();
261
+ return isRange(value) ? value : null;
262
+ });
263
+ protected readonly shownStart = computed(() => this.startPick() ?? this.range()?.start ?? null);
264
+ protected readonly shownEnd = computed(() =>
265
+ this.startPick() ? null : (this.range()?.end ?? null),
266
+ );
267
+
268
+ protected readonly label = computed(() => {
269
+ const range = this.range();
270
+ if (!range) return '';
271
+ const format = (date: MimiDate) => mimiFormatDate(date, this.lang(), this.format());
272
+ return `${format(range.start)} – ${format(range.end)}`;
273
+ });
274
+ protected readonly days = computed(() => {
275
+ const range = this.range();
276
+ return range ? this.texts.days(mimiDaysBetween(range.start, range.end)) : '';
277
+ });
278
+
279
+ protected isPreset(preset: MimiDateRangePreset): boolean {
280
+ const range = this.range();
281
+ return !!range && range.start === preset.range.start && range.end === preset.range.end;
282
+ }
283
+
284
+ focus(options?: FocusOptions): void {
285
+ this.trigger().nativeElement.focus(options);
286
+ }
287
+
288
+ protected pickDay(date: MimiDate): void {
289
+ const start = this.startPick();
290
+ if (start === null) {
291
+ this.startPick.set(date);
292
+ return;
293
+ }
294
+ this.startPick.set(null);
295
+ this.value.set(date < start ? { start: date, end: start } : { start, end: date });
296
+ }
297
+
298
+ protected pickPreset(preset: MimiDateRangePreset): void {
299
+ this.startPick.set(null);
300
+ this.value.set({ ...preset.range });
301
+ }
302
+
303
+ protected onOpenChange(open: boolean): void {
304
+ if (open) return;
305
+ this.startPick.set(null);
306
+ this.touch.emit();
307
+ }
308
+ }
@@ -0,0 +1 @@
1
+ export * from './date-range';
@@ -0,0 +1,77 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /*
4
+ * Textos de File Upload, en archivo propio: así se pueden proveer en `app.config.ts` sin llevar
5
+ * el componente al chunk inicial. Los tamaños se escriben con `Intl.NumberFormat` y el idioma.
6
+ */
7
+
8
+ export interface MimiFileUploadText {
9
+ /** Título de la zona en reposo (también su nombre accesible). */
10
+ title: string;
11
+ /** Título con archivos encima. */
12
+ drop: (count: number) => string;
13
+ /** Nombre de la lista. */
14
+ list: string;
15
+ /** Nombre de la barra de un archivo. */
16
+ uploading: (name: string) => string;
17
+ uploaded: string;
18
+ retry: string;
19
+ remove: (name: string) => string;
20
+ /** Motivos de rechazo. */
21
+ tooLarge: (max: string) => string;
22
+ wrongType: string;
23
+ tooMany: (max: number) => string;
24
+ /** Anuncios. */
25
+ added: (count: number) => string;
26
+ rejected: (name: string, reason: string) => string;
27
+ done: (name: string) => string;
28
+ failed: (name: string, error: string) => string;
29
+ }
30
+
31
+ export const MIMI_FILE_UPLOAD_TEXT_EN: MimiFileUploadText = {
32
+ title: 'Drag files here or click',
33
+ drop: (count) =>
34
+ count > 0 ? `Drop to upload ${count} ${count === 1 ? 'file' : 'files'}` : 'Drop to upload',
35
+ list: 'Files',
36
+ uploading: (name) => `Uploading ${name}`,
37
+ uploaded: 'Uploaded',
38
+ retry: 'Retry',
39
+ remove: (name) => `Remove ${name}`,
40
+ tooLarge: (max) => `Exceeds the ${max} limit.`,
41
+ wrongType: 'File type not allowed.',
42
+ tooMany: (max) => `Up to ${max} ${max === 1 ? 'file' : 'files'}.`,
43
+ added: (count) => `${count} ${count === 1 ? 'file' : 'files'} added`,
44
+ rejected: (name, reason) => `${name} not added: ${reason}`,
45
+ done: (name) => `${name} uploaded`,
46
+ failed: (name, error) => `${name} failed: ${error}`,
47
+ };
48
+
49
+ export const MIMI_FILE_UPLOAD_TEXT_ES: MimiFileUploadText = {
50
+ title: 'Arrastra archivos o haz clic',
51
+ drop: (count) =>
52
+ count > 0
53
+ ? `Suelta para subir ${count} ${count === 1 ? 'archivo' : 'archivos'}`
54
+ : 'Suelta para subir',
55
+ list: 'Archivos',
56
+ uploading: (name) => `Subiendo ${name}`,
57
+ uploaded: 'Subido',
58
+ retry: 'Reintentar',
59
+ remove: (name) => `Quitar ${name}`,
60
+ tooLarge: (max) => `Supera el máximo de ${max}.`,
61
+ wrongType: 'Tipo de archivo no permitido.',
62
+ tooMany: (max) => `Hasta ${max} ${max === 1 ? 'archivo' : 'archivos'}.`,
63
+ added: (count) => `${count} ${count === 1 ? 'archivo agregado' : 'archivos agregados'}`,
64
+ rejected: (name, reason) => `${name} no se agregó: ${reason}`,
65
+ done: (name) => `${name} subido`,
66
+ failed: (name, error) => `${name} con error: ${error}`,
67
+ };
68
+
69
+ export const MIMI_FILE_UPLOAD_TEXT = new InjectionToken<MimiFileUploadText>(
70
+ 'MIMI_FILE_UPLOAD_TEXT',
71
+ { providedIn: 'root', factory: () => MIMI_FILE_UPLOAD_TEXT_EN },
72
+ );
73
+
74
+ /** Cambia los textos de File Upload. Lo que no definas sigue en inglés. */
75
+ export function provideMimiFileUploadText(text: Partial<MimiFileUploadText>): Provider {
76
+ return { provide: MIMI_FILE_UPLOAD_TEXT, useValue: { ...MIMI_FILE_UPLOAD_TEXT_EN, ...text } };
77
+ }