@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.
- package/README.md +35 -2
- package/dist/init/css.js +1 -1
- package/dist/init/index.js +25 -0
- package/dist/init/paths.js +20 -0
- package/dist/registry.json +385 -14
- package/dist/templates/blocks/login-01/index.ts +1 -0
- package/dist/templates/blocks/login-01/login-01.html +174 -0
- package/dist/templates/blocks/login-01/login-01.ts +105 -0
- package/dist/templates/blocks/login-02/index.ts +1 -0
- package/dist/templates/blocks/login-02/login-02.html +186 -0
- package/dist/templates/blocks/login-02/login-02.ts +92 -0
- package/dist/templates/blocks/otp-01/index.ts +1 -0
- package/dist/templates/blocks/otp-01/otp-01.html +166 -0
- package/dist/templates/blocks/otp-01/otp-01.ts +212 -0
- package/dist/templates/blocks/pricing-01/index.ts +1 -0
- package/dist/templates/blocks/pricing-01/pricing-01.html +173 -0
- package/dist/templates/blocks/pricing-01/pricing-01.ts +178 -0
- package/dist/templates/blocks/settings-01/index.ts +1 -0
- package/dist/templates/blocks/settings-01/settings-01.html +424 -0
- package/dist/templates/blocks/settings-01/settings-01.ts +382 -0
- package/dist/templates/blocks/signup-01/index.ts +1 -0
- package/dist/templates/blocks/signup-01/signup-01.html +332 -0
- package/dist/templates/blocks/signup-01/signup-01.ts +176 -0
- package/dist/templates/components/calendar/calendar-texts.ts +72 -0
- package/dist/templates/components/calendar/calendar.ts +588 -0
- package/dist/templates/components/calendar/dates.ts +181 -0
- package/dist/templates/components/calendar/index.ts +3 -0
- package/dist/templates/components/chip/chip-texts.ts +40 -0
- package/dist/templates/components/chip/chip.ts +116 -0
- package/dist/templates/components/chip/index.ts +2 -0
- package/dist/templates/components/color-picker/color-picker-texts.ts +57 -0
- package/dist/templates/components/color-picker/color-picker.ts +718 -0
- package/dist/templates/components/color-picker/color.ts +157 -0
- package/dist/templates/components/color-picker/index.ts +3 -0
- package/dist/templates/components/combobox/combobox-texts.ts +70 -0
- package/dist/templates/components/combobox/combobox.ts +798 -0
- package/dist/templates/components/combobox/index.ts +2 -0
- package/dist/templates/components/date-picker/date-picker.ts +240 -0
- package/dist/templates/components/date-picker/index.ts +3 -0
- package/dist/templates/components/date-range/date-range.ts +308 -0
- package/dist/templates/components/date-range/index.ts +1 -0
- package/dist/templates/components/file-upload/file-upload-texts.ts +77 -0
- package/dist/templates/components/file-upload/file-upload.ts +698 -0
- package/dist/templates/components/file-upload/index.ts +2 -0
- package/dist/templates/components/input-mask/index.ts +1 -0
- package/dist/templates/components/input-mask/input-mask.ts +319 -0
- package/dist/templates/components/input-otp/index.ts +1 -0
- package/dist/templates/components/input-otp/input-otp.ts +212 -0
- package/dist/templates/components/number-input/index.ts +2 -0
- package/dist/templates/components/number-input/number-input-texts.ts +46 -0
- package/dist/templates/components/number-input/number-input.ts +450 -0
- package/dist/templates/components/option-list/index.ts +11 -0
- package/dist/templates/components/option-list/option-list.ts +518 -0
- package/dist/templates/components/password/index.ts +2 -0
- package/dist/templates/components/password/password-texts.ts +68 -0
- package/dist/templates/components/password/password.ts +363 -0
- package/dist/templates/components/popover/popover.ts +12 -4
- package/dist/templates/components/progress/index.ts +1 -0
- package/dist/templates/components/progress/progress.ts +217 -0
- package/dist/templates/components/select/select.ts +3 -2
- package/dist/templates/components/slider/index.ts +2 -0
- package/dist/templates/components/slider/slider-texts.ts +26 -0
- package/dist/templates/components/slider/slider.ts +425 -0
- package/dist/templates/components/tabs/tabs.ts +88 -23
- package/dist/templates/components/tags-input/index.ts +2 -0
- package/dist/templates/components/tags-input/tags-input-texts.ts +44 -0
- package/dist/templates/components/tags-input/tags-input.ts +303 -0
- package/dist/templates/components/toggle-group/index.ts +1 -0
- package/dist/templates/components/toggle-group/toggle-group.ts +406 -0
- package/dist/templates/theme/provider.ts +13 -0
- package/dist/templates/theme/theme-base.css +103 -0
- package/dist/templates/theme/types.ts +216 -0
- package/dist/templates/utils/overlay.ts +17 -4
- package/dist/ui/index.js +232 -10
- package/dist/ui/schema.json +82 -2
- package/dist/utils/project.js +35 -0
- package/dist/utils/tailwind.js +80 -0
- package/dist/utils/write-files.js +49 -9
- package/package.json +1 -1
|
@@ -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,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
|
+
}
|