@mimi-ng/cli 0.1.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/THIRD_PARTY_NOTICES.md +84 -0
  4. package/dist/collection.json +22 -0
  5. package/dist/init/css.js +32 -0
  6. package/dist/init/index.js +297 -0
  7. package/dist/init/paths.js +43 -0
  8. package/dist/init/schema.json +24 -0
  9. package/dist/ng-add/index.js +8 -0
  10. package/dist/registry.js +30 -0
  11. package/dist/registry.json +194 -0
  12. package/dist/templates/components/avatar/avatar.ts +129 -0
  13. package/dist/templates/components/avatar/avatar.variants.ts +25 -0
  14. package/dist/templates/components/avatar/index.ts +2 -0
  15. package/dist/templates/components/badge/badge.ts +21 -0
  16. package/dist/templates/components/badge/badge.variants.ts +27 -0
  17. package/dist/templates/components/badge/index.ts +2 -0
  18. package/dist/templates/components/button/button.ts +99 -0
  19. package/dist/templates/components/button/button.variants.ts +60 -0
  20. package/dist/templates/components/button/index.ts +2 -0
  21. package/dist/templates/components/card/card.ts +133 -0
  22. package/dist/templates/components/card/index.ts +1 -0
  23. package/dist/templates/components/checkbox/checkbox.ts +178 -0
  24. package/dist/templates/components/checkbox/index.ts +1 -0
  25. package/dist/templates/components/form-field/error-messages.ts +68 -0
  26. package/dist/templates/components/form-field/form-error.ts +82 -0
  27. package/dist/templates/components/form-field/form-field.ts +114 -0
  28. package/dist/templates/components/form-field/index.ts +3 -0
  29. package/dist/templates/components/input/index.ts +2 -0
  30. package/dist/templates/components/input/input.ts +52 -0
  31. package/dist/templates/components/input/input.variants.ts +38 -0
  32. package/dist/templates/components/separator/index.ts +2 -0
  33. package/dist/templates/components/separator/separator.ts +38 -0
  34. package/dist/templates/components/separator/separator.variants.ts +20 -0
  35. package/dist/templates/components/skeleton/index.ts +1 -0
  36. package/dist/templates/components/skeleton/skeleton.ts +27 -0
  37. package/dist/templates/components/switch/index.ts +1 -0
  38. package/dist/templates/components/switch/switch.ts +138 -0
  39. package/dist/templates/components/textarea/index.ts +2 -0
  40. package/dist/templates/components/textarea/textarea.ts +46 -0
  41. package/dist/templates/components/textarea/textarea.variants.ts +27 -0
  42. package/dist/templates/theme/index.ts +2 -0
  43. package/dist/templates/theme/provider.ts +150 -0
  44. package/dist/templates/theme/theme-base.css +241 -0
  45. package/dist/templates/theme/types.ts +124 -0
  46. package/dist/templates/utils/cn.ts +36 -0
  47. package/dist/templates/utils/control-styles.ts +27 -0
  48. package/dist/templates/utils/field-state.ts +164 -0
  49. package/dist/ui/index.js +172 -0
  50. package/dist/ui/schema.json +75 -0
  51. package/dist/utils/project.js +28 -0
  52. package/dist/utils/write-files.js +167 -0
  53. package/package.json +55 -0
@@ -0,0 +1,133 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ input,
6
+ numberAttribute,
7
+ } from '@angular/core';
8
+ import { cn } from '@/components/ui/utils/cn';
9
+
10
+ /*
11
+ * Card (docs/design/Mimi Componentes.dc.html, sección 4, "Plan Equipo"). Clases literales para
12
+ * que Tailwind las detecte. Tokens de la spec 6.3 (MimiCardTokens), con la cascada de la 6.2:
13
+ * --mimi-card-* → token global → valor del diseño.
14
+ */
15
+
16
+ /** Contenedor de la tarjeta. */
17
+ @Component({
18
+ selector: 'mimi-card',
19
+ template: '<ng-content />',
20
+ host: { '[class]': 'classes()' },
21
+ changeDetection: ChangeDetectionStrategy.OnPush,
22
+ })
23
+ export class MimiCard {
24
+ readonly userClass = input('', { alias: 'class' });
25
+ protected readonly classes = computed(() =>
26
+ cn(
27
+ 'flex flex-col bg-card text-card-foreground',
28
+ 'rounded-[var(--mimi-card-radius,var(--mimi-radius-card))]',
29
+ 'border-[length:var(--mimi-card-border-width,1px)] border-border',
30
+ 'shadow-[shadow:var(--mimi-card-shadow,var(--mimi-shadow-card))]',
31
+ this.userClass(),
32
+ ),
33
+ );
34
+ }
35
+
36
+ /** Encabezado: título y descripción. */
37
+ @Component({
38
+ selector: 'mimi-card-header',
39
+ template: '<ng-content />',
40
+ host: { '[class]': 'classes()' },
41
+ changeDetection: ChangeDetectionStrategy.OnPush,
42
+ })
43
+ export class MimiCardHeader {
44
+ readonly userClass = input('', { alias: 'class' });
45
+ protected readonly classes = computed(() =>
46
+ cn(
47
+ 'flex flex-col gap-1.5 p-[var(--mimi-card-padding-header,1.5rem_1.5rem_1rem)]',
48
+ this.userClass(),
49
+ ),
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Título. Es un encabezado para los lectores de pantalla (`role="heading"` + `aria-level`).
55
+ * No pongas un `<h3>` dentro: quedaría duplicado. Si prefieres tu propio `<h3>`, úsalo en lugar
56
+ * de este componente, con las mismas clases.
57
+ */
58
+ @Component({
59
+ selector: 'mimi-card-title',
60
+ template: '<ng-content />',
61
+ host: {
62
+ role: 'heading',
63
+ '[attr.aria-level]': 'level()',
64
+ '[class]': 'classes()',
65
+ },
66
+ changeDetection: ChangeDetectionStrategy.OnPush,
67
+ })
68
+ export class MimiCardTitle {
69
+ /** Nivel del encabezado (aria-level). */
70
+ readonly level = input(3, { transform: numberAttribute });
71
+ readonly userClass = input('', { alias: 'class' });
72
+ protected readonly classes = computed(() =>
73
+ cn('block text-lg leading-tight font-semibold tracking-[-0.01em]', this.userClass()),
74
+ );
75
+ }
76
+
77
+ /** Descripción bajo el título. */
78
+ @Component({
79
+ selector: 'mimi-card-description',
80
+ template: '<ng-content />',
81
+ host: { '[class]': 'classes()' },
82
+ changeDetection: ChangeDetectionStrategy.OnPush,
83
+ })
84
+ export class MimiCardDescription {
85
+ readonly userClass = input('', { alias: 'class' });
86
+ protected readonly classes = computed(() =>
87
+ cn('block text-sm text-muted-foreground', this.userClass()),
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Contenido. Sin padding arriba porque va bajo el encabezado; si es el primer hijo de la
93
+ * tarjeta (sin encabezado), suma 24px arriba para no quedar pegado al borde.
94
+ */
95
+ @Component({
96
+ selector: 'mimi-card-content',
97
+ template: '<ng-content />',
98
+ host: { '[class]': 'classes()' },
99
+ changeDetection: ChangeDetectionStrategy.OnPush,
100
+ })
101
+ export class MimiCardContent {
102
+ readonly userClass = input('', { alias: 'class' });
103
+ protected readonly classes = computed(() =>
104
+ cn('block p-[var(--mimi-card-padding-content,0_1.5rem_1.25rem)] first:pt-6', this.userClass()),
105
+ );
106
+ }
107
+
108
+ /** Pie con acciones, alineadas a la derecha como en el diseño. */
109
+ @Component({
110
+ selector: 'mimi-card-footer',
111
+ template: '<ng-content />',
112
+ host: { '[class]': 'classes()' },
113
+ changeDetection: ChangeDetectionStrategy.OnPush,
114
+ })
115
+ export class MimiCardFooter {
116
+ readonly userClass = input('', { alias: 'class' });
117
+ protected readonly classes = computed(() =>
118
+ cn(
119
+ 'flex items-center justify-end gap-2 p-[var(--mimi-card-padding-footer,0_1.5rem_1.5rem)] first:pt-6',
120
+ this.userClass(),
121
+ ),
122
+ );
123
+ }
124
+
125
+ /** Todas las piezas de Card, para importarlas juntas. */
126
+ export const MimiCardImports = [
127
+ MimiCard,
128
+ MimiCardHeader,
129
+ MimiCardTitle,
130
+ MimiCardDescription,
131
+ MimiCardContent,
132
+ MimiCardFooter,
133
+ ] as const;
@@ -0,0 +1 @@
1
+ export * from './card';
@@ -0,0 +1,178 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ ElementRef,
5
+ booleanAttribute,
6
+ computed,
7
+ input,
8
+ model,
9
+ output,
10
+ viewChild,
11
+ } from '@angular/core';
12
+ import type { FormCheckboxControl } from '@angular/forms/signals';
13
+ import { cn } from '@/components/ui/utils/cn';
14
+ import { buttonFocusStyles, controlDisabledStyles } from '@/components/ui/utils/control-styles';
15
+ import {
16
+ MIMI_FIELD_CONTROL,
17
+ type MimiFieldControl,
18
+ injectFieldState,
19
+ } from '@/components/ui/utils/field-state';
20
+
21
+ let nextId = 0;
22
+
23
+ /**
24
+ * Casilla de verificación (docs/design/Mimi Componentes.dc.html, sección 7):
25
+ * `<mimi-checkbox [(checked)]="recordarme">Recordarme</mimi-checkbox>`.
26
+ *
27
+ * Por dentro es un `<button type="button" role="checkbox">` y no un `<input type="checkbox">`:
28
+ * el check del diseño no se puede dibujar de forma fiable con pseudoelementos sobre un input en
29
+ * todos los navegadores. Se alterna con Espacio (Enter no hace nada, patrón WAI) y con clic en
30
+ * el texto, que va en un `<label for>`.
31
+ *
32
+ * Implementa `FormCheckboxControl`: funciona con `[formField]`, `formControlName` y
33
+ * `[(ngModel)]` sin adaptador (spec, sección 8).
34
+ */
35
+ @Component({
36
+ selector: 'mimi-checkbox',
37
+ // mimi-form-field lo encuentra por este token para mostrar el mensaje de error.
38
+ providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiCheckbox }],
39
+ template: `
40
+ <button
41
+ #control
42
+ type="button"
43
+ role="checkbox"
44
+ [id]="controlId()"
45
+ [class]="boxClasses"
46
+ [disabled]="disabled()"
47
+ [attr.aria-checked]="indeterminate() ? 'mixed' : checked()"
48
+ [attr.aria-invalid]="showInvalid() || null"
49
+ [attr.aria-required]="required() || null"
50
+ [attr.aria-label]="ariaLabel() || null"
51
+ [attr.aria-labelledby]="ariaLabelledby() || null"
52
+ [attr.data-state]="state()"
53
+ (click)="toggle()"
54
+ (keydown.enter)="$event.preventDefault()"
55
+ (blur)="touch.emit()"
56
+ >
57
+ @switch (state()) {
58
+ @case ('checked') {
59
+ <!-- Lucide check -->
60
+ <svg
61
+ xmlns="http://www.w3.org/2000/svg"
62
+ viewBox="0 0 24 24"
63
+ fill="none"
64
+ stroke="currentColor"
65
+ stroke-width="3"
66
+ stroke-linecap="round"
67
+ stroke-linejoin="round"
68
+ class="size-3"
69
+ aria-hidden="true"
70
+ >
71
+ <path d="M20 6 9 17l-5-5" />
72
+ </svg>
73
+ }
74
+ @case ('indeterminate') {
75
+ <!-- Lucide minus -->
76
+ <svg
77
+ xmlns="http://www.w3.org/2000/svg"
78
+ viewBox="0 0 24 24"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ stroke-width="3"
82
+ stroke-linecap="round"
83
+ stroke-linejoin="round"
84
+ class="size-3"
85
+ aria-hidden="true"
86
+ >
87
+ <path d="M5 12h14" />
88
+ </svg>
89
+ }
90
+ }
91
+ </button>
92
+ <label
93
+ [for]="controlId()"
94
+ class="cursor-pointer empty:hidden peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
95
+ ><ng-content
96
+ /></label>
97
+ `,
98
+ host: {
99
+ '[class]': 'classes()',
100
+ '[attr.data-state]': 'state()',
101
+ '[attr.data-disabled]': 'disabled() || null',
102
+ // id y aria-* se pasan al botón: fuera del anfitrión, para no duplicarlos.
103
+ '[attr.id]': 'null',
104
+ '[attr.aria-label]': 'null',
105
+ '[attr.aria-labelledby]': 'null',
106
+ },
107
+ changeDetection: ChangeDetectionStrategy.OnPush,
108
+ })
109
+ export class MimiCheckbox implements FormCheckboxControl, MimiFieldControl {
110
+ /** Marcada o no. Con `[(checked)]` o un formulario. */
111
+ readonly checked = model(false);
112
+ /** Estado mixto ("algunos"): gana a `checked`. Al hacer clic se marca y deja de ser mixto. */
113
+ readonly indeterminate = model(false);
114
+ readonly disabled = input(false, { transform: booleanAttribute });
115
+ /** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
116
+ readonly invalid = input(false, { transform: booleanAttribute });
117
+ readonly touched = input(false, { transform: booleanAttribute });
118
+ readonly dirty = input(false, { transform: booleanAttribute });
119
+ readonly required = input(false, { transform: booleanAttribute });
120
+ /** id del botón, para una etiqueta externa (`<label for>`). */
121
+ readonly id = input<string>();
122
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
123
+ readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
124
+ readonly userClass = input('', { alias: 'class' });
125
+
126
+ /** Se emite al perder el foco: marca el control como tocado. */
127
+ readonly touch = output<void>();
128
+
129
+ private readonly control = viewChild.required<ElementRef<HTMLButtonElement>>('control');
130
+
131
+ /** Estado del formulario, con los errores, para el mensaje de mimi-form-field. */
132
+ readonly fieldState = injectFieldState();
133
+
134
+ /** El botón interno: mimi-form-field le agrega el aria-describedby del mensaje. */
135
+ get controlElement(): HTMLElement {
136
+ return this.control().nativeElement;
137
+ }
138
+ private readonly autoId = `mimi-checkbox-${nextId++}`;
139
+
140
+ protected readonly controlId = computed(() => this.id() || this.autoId);
141
+ protected readonly state = computed(() =>
142
+ this.indeterminate() ? 'indeterminate' : this.checked() ? 'checked' : 'unchecked',
143
+ );
144
+ /** Error visible (inválido y tocado o modificado). Lo lee también mimi-form-field. */
145
+ readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
146
+
147
+ protected readonly classes = computed(() =>
148
+ cn('inline-flex items-center gap-2.5 text-sm', this.userClass()),
149
+ );
150
+
151
+ // Caja de 16px. Marcada o mixta: primary con su sombra. Foco: borde ring (sin marcar) y contorno.
152
+ protected readonly boxClasses = cn(
153
+ 'peer inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-input bg-input-background text-primary-foreground',
154
+ 'mimi-transition active:scale-(--mimi-press-scale-sm)',
155
+ 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:shadow-primary',
156
+ 'data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:shadow-primary',
157
+ 'data-[state=unchecked]:focus-visible:border-ring data-[state=unchecked]:aria-invalid:border-destructive',
158
+ 'disabled:shadow-none',
159
+ buttonFocusStyles,
160
+ controlDisabledStyles,
161
+ );
162
+
163
+ /** Alterna el estado (no hace nada si está deshabilitada). Desde mixta, pasa a marcada. */
164
+ toggle(): void {
165
+ if (this.disabled()) return;
166
+ if (this.indeterminate()) {
167
+ this.indeterminate.set(false);
168
+ this.checked.set(true);
169
+ return;
170
+ }
171
+ this.checked.update((value) => !value);
172
+ }
173
+
174
+ /** Enfoca el botón. Lo usa Signal Forms para llevar el foco al campo con error. */
175
+ focus(options?: FocusOptions): void {
176
+ this.control().nativeElement.focus(options);
177
+ }
178
+ }
@@ -0,0 +1 @@
1
+ export * from './checkbox';
@@ -0,0 +1,68 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+ import type { MimiFieldError } from '@/components/ui/utils/field-state';
3
+
4
+ /**
5
+ * Texto de un error, fijo o calculado con los datos del error. Los datos usan los nombres de
6
+ * Reactive Forms en los tres sistemas: `requiredLength` (minlength / maxlength), `min`, `max`,
7
+ * `requiredPattern`…
8
+ */
9
+ export type MimiErrorMessage = string | ((params: Record<string, unknown>) => string);
10
+
11
+ /**
12
+ * Mensajes por clave de error, en minúsculas (`required`, `email`, `minlength`…). `default`
13
+ * se usa para cualquier error sin mensaje propio.
14
+ */
15
+ export type MimiErrorMessages = Record<string, MimiErrorMessage>;
16
+
17
+ /** Mensajes predeterminados (inglés). */
18
+ export const MIMI_ERROR_MESSAGES_EN: MimiErrorMessages = {
19
+ required: 'This field is required.',
20
+ email: 'Enter a valid email address.',
21
+ minlength: (e) => `Use at least ${e['requiredLength']} characters.`,
22
+ maxlength: (e) => `Use at most ${e['requiredLength']} characters.`,
23
+ min: (e) => `The minimum value is ${e['min']}.`,
24
+ max: (e) => `The maximum value is ${e['max']}.`,
25
+ pattern: 'The format is not valid.',
26
+ default: 'Check this field.',
27
+ };
28
+
29
+ /** Mensajes en español: `provideMimiErrorMessages(MIMI_ERROR_MESSAGES_ES)`. */
30
+ export const MIMI_ERROR_MESSAGES_ES: MimiErrorMessages = {
31
+ required: 'Este campo es obligatorio.',
32
+ email: 'Ingresa un correo válido.',
33
+ minlength: (e) => `Usa al menos ${e['requiredLength']} caracteres.`,
34
+ maxlength: (e) => `Usa como máximo ${e['requiredLength']} caracteres.`,
35
+ min: (e) => `El valor mínimo es ${e['min']}.`,
36
+ max: (e) => `El valor máximo es ${e['max']}.`,
37
+ pattern: 'El formato no es válido.',
38
+ default: 'Revisa este campo.',
39
+ };
40
+
41
+ /** Mensajes activos. Sin `provideMimiErrorMessages`, los predeterminados en inglés. */
42
+ export const MIMI_ERROR_MESSAGES = new InjectionToken<MimiErrorMessages>('MIMI_ERROR_MESSAGES', {
43
+ providedIn: 'root',
44
+ factory: () => MIMI_ERROR_MESSAGES_EN,
45
+ });
46
+
47
+ /**
48
+ * Cambia los mensajes de error. Lo que no definas sigue en inglés:
49
+ *
50
+ * ```ts
51
+ * provideMimiErrorMessages(MIMI_ERROR_MESSAGES_ES)
52
+ * provideMimiErrorMessages({ ...MIMI_ERROR_MESSAGES_ES, required: 'Obligatorio.' })
53
+ * ```
54
+ *
55
+ * Va en `app.config.ts` o en los `providers` de un componente o ruta.
56
+ */
57
+ export function provideMimiErrorMessages(messages: MimiErrorMessages): Provider {
58
+ const normalized: MimiErrorMessages = {};
59
+ for (const [key, value] of Object.entries(messages)) normalized[key.toLowerCase()] = value;
60
+ return { provide: MIMI_ERROR_MESSAGES, useValue: { ...MIMI_ERROR_MESSAGES_EN, ...normalized } };
61
+ }
62
+
63
+ /** Texto de un error: el `message` que trae (Signal Forms), el del mapa o `default`. */
64
+ export function mimiErrorText(error: MimiFieldError, messages: MimiErrorMessages): string {
65
+ if (error.message) return error.message;
66
+ const entry = messages[error.kind] ?? messages['default'] ?? MIMI_ERROR_MESSAGES_EN['default'];
67
+ return typeof entry === 'function' ? entry(error.params) : entry;
68
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ InjectionToken,
5
+ type Signal,
6
+ computed,
7
+ inject,
8
+ input,
9
+ } from '@angular/core';
10
+ import { cn } from '@/components/ui/utils/cn';
11
+
12
+ /** Lo que `mimi-form-field` le pasa a su mensaje de error. */
13
+ export interface MimiFormFieldContext {
14
+ /** id del mensaje: el control lo tiene en su `aria-describedby`. */
15
+ readonly errorId: string;
16
+ /** Hay que mostrar el error (inválido y tocado o modificado, o `showError` a mano). */
17
+ readonly showError: Signal<boolean>;
18
+ /** Texto del primer error, según los mensajes activos. */
19
+ readonly message: Signal<string>;
20
+ }
21
+
22
+ export const MIMI_FORM_FIELD_CONTEXT = new InjectionToken<MimiFormFieldContext>(
23
+ 'MIMI_FORM_FIELD_CONTEXT',
24
+ );
25
+
26
+ /**
27
+ * Mensaje de error de `mimi-form-field` (docs/design/Mimi Componentes.dc.html, sección 3):
28
+ * 13px, color destructive y el ícono circle-alert de 14px.
29
+ *
30
+ * `mimi-form-field` ya muestra uno solo. Escríbelo solo para cambiar el texto
31
+ * (`<mimi-form-error>Ese usuario ya existe.</mimi-form-error>`) o la posición.
32
+ *
33
+ * Siempre está en el DOM con `aria-live="polite"`: sin error queda vacío y fuera del flujo
34
+ * (`sr-only`); al aparecer el error solo cambia su contenido, así los lectores lo anuncian.
35
+ */
36
+ @Component({
37
+ selector: 'mimi-form-error',
38
+ template: `
39
+ @if (context.showError()) {
40
+ <!-- Lucide circle-alert -->
41
+ <svg
42
+ xmlns="http://www.w3.org/2000/svg"
43
+ viewBox="0 0 24 24"
44
+ fill="none"
45
+ stroke="currentColor"
46
+ stroke-width="2"
47
+ stroke-linecap="round"
48
+ stroke-linejoin="round"
49
+ aria-hidden="true"
50
+ >
51
+ <circle cx="12" cy="12" r="10" />
52
+ <line x1="12" x2="12" y1="8" y2="12" />
53
+ <line x1="12" x2="12.01" y1="16" y2="16" />
54
+ </svg>
55
+ <!-- Tu texto, si escribiste uno; si no, el del primer error. -->
56
+ <span
57
+ ><ng-content>{{ context.message() }}</ng-content></span
58
+ >
59
+ }
60
+ `,
61
+ host: {
62
+ '[id]': 'context.errorId',
63
+ 'aria-live': 'polite',
64
+ '[class]': 'classes()',
65
+ '[attr.data-state]': 'context.showError() ? "visible" : "hidden"',
66
+ },
67
+ changeDetection: ChangeDetectionStrategy.OnPush,
68
+ })
69
+ export class MimiFormError {
70
+ readonly userClass = input('', { alias: 'class' });
71
+
72
+ protected readonly context = inject(MIMI_FORM_FIELD_CONTEXT);
73
+
74
+ protected readonly classes = computed(() =>
75
+ cn(
76
+ 'flex items-center gap-1.5 text-[13px] text-destructive [&_svg]:size-3.5 [&_svg]:shrink-0',
77
+ this.userClass(),
78
+ // Sin error: vacío y fuera del flujo, pero en el DOM para que aria-live funcione.
79
+ !this.context.showError() && 'sr-only',
80
+ ),
81
+ );
82
+ }
@@ -0,0 +1,114 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ ElementRef,
5
+ afterRenderEffect,
6
+ computed,
7
+ contentChild,
8
+ inject,
9
+ input,
10
+ signal,
11
+ } from '@angular/core';
12
+ import { cn } from '@/components/ui/utils/cn';
13
+ import { MIMI_FIELD_CONTROL } from '@/components/ui/utils/field-state';
14
+ import { MIMI_ERROR_MESSAGES, mimiErrorText } from './error-messages';
15
+ import { MIMI_FORM_FIELD_CONTEXT, type MimiFormFieldContext, MimiFormError } from './form-error';
16
+
17
+ let nextId = 0;
18
+
19
+ /**
20
+ * Agrupa etiqueta, control y mensaje de error (docs/design/Mimi Componentes.dc.html,
21
+ * sección 3), sin escribir un solo `@if`:
22
+ *
23
+ * ```html
24
+ * <mimi-form-field label="Correo">
25
+ * <input mimiInput type="email" formControlName="email" />
26
+ * </mimi-form-field>
27
+ * ```
28
+ *
29
+ * - Encuentra el control (Input, Textarea, Switch o Checkbox) por `MIMI_FIELD_CONTROL`.
30
+ * - `label` dibuja un `<label for>`; si proyectas tu propio `<label>` sin `for`, se lo asigna.
31
+ * Si el control no tiene `id`, le pone uno.
32
+ * - Con error, la etiqueta se pone roja y aparece el mensaje del primer error. El control
33
+ * lleva el mensaje en su `aria-describedby`.
34
+ * - Para cambiar el texto o la posición del mensaje, escribe `<mimi-form-error>`.
35
+ */
36
+ @Component({
37
+ selector: 'mimi-form-field',
38
+ imports: [MimiFormError],
39
+ template: `
40
+ @if (label()) {
41
+ <label [attr.for]="controlId()" [class]="labelClasses">{{ label() }}</label>
42
+ }
43
+ <ng-content />
44
+ @if (!ownError()) {
45
+ <mimi-form-error />
46
+ }
47
+ `,
48
+ providers: [{ provide: MIMI_FORM_FIELD_CONTEXT, useExisting: MimiFormField }],
49
+ host: {
50
+ '[class]': 'classes()',
51
+ '[attr.data-invalid]': 'showError() ? "" : null',
52
+ '[attr.data-disabled]': 'disabled() ? "" : null',
53
+ },
54
+ changeDetection: ChangeDetectionStrategy.OnPush,
55
+ })
56
+ export class MimiFormField implements MimiFormFieldContext {
57
+ /** Texto de la etiqueta. También puedes proyectar tu propio `<label>`. */
58
+ readonly label = input<string>();
59
+ readonly userClass = input('', { alias: 'class' });
60
+
61
+ private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
62
+ private readonly messages = inject(MIMI_ERROR_MESSAGES);
63
+ private readonly control = contentChild(MIMI_FIELD_CONTROL, { descendants: true });
64
+ protected readonly ownError = contentChild(MimiFormError, { descendants: true });
65
+
66
+ private readonly baseId = `mimi-form-field-${nextId++}`;
67
+ readonly errorId = `${this.baseId}-error`;
68
+ /** id del control (el suyo o el que le asigna el form-field). */
69
+ protected readonly controlId = signal<string | null>(null);
70
+
71
+ readonly showError = computed(() => this.control()?.showInvalid() ?? false);
72
+ protected readonly disabled = computed(() => this.control()?.fieldState.disabled() ?? false);
73
+ readonly message = computed(() => {
74
+ const error = this.control()?.fieldState.errors()[0];
75
+ return error
76
+ ? mimiErrorText(error, this.messages)
77
+ : mimiErrorText({ kind: 'default', params: {} }, this.messages);
78
+ });
79
+
80
+ protected readonly classes = computed(() =>
81
+ cn(
82
+ // La etiqueta (la de label="…" o la tuya) se pone roja con error.
83
+ 'flex flex-col gap-1.5 [&[data-invalid]>label]:text-destructive',
84
+ this.userClass(),
85
+ ),
86
+ );
87
+
88
+ protected readonly labelClasses = 'text-sm font-medium';
89
+
90
+ constructor() {
91
+ // Conecta el control: id, etiquetas propias sin `for` y aria-describedby del mensaje.
92
+ afterRenderEffect(() => {
93
+ const control = this.control();
94
+ if (!control) return;
95
+ const element = control.controlElement;
96
+ if (!element.id) element.id = `${this.baseId}-control`;
97
+ this.controlId.set(element.id);
98
+
99
+ for (const label of Array.from(this.host.querySelectorAll(':scope > label'))) {
100
+ if (!label.hasAttribute('for')) label.setAttribute('for', element.id);
101
+ }
102
+
103
+ const describedBy = (element.getAttribute('aria-describedby') ?? '')
104
+ .split(/\s+/)
105
+ .filter(Boolean);
106
+ if (!describedBy.includes(this.errorId)) {
107
+ element.setAttribute('aria-describedby', [...describedBy, this.errorId].join(' '));
108
+ }
109
+ });
110
+ }
111
+ }
112
+
113
+ /** Todas las piezas de FormField, para importarlas juntas. */
114
+ export const MimiFormFieldImports = [MimiFormField, MimiFormError] as const;
@@ -0,0 +1,3 @@
1
+ export * from './error-messages';
2
+ export * from './form-error';
3
+ export * from './form-field';
@@ -0,0 +1,2 @@
1
+ export * from './input';
2
+ export * from './input.variants';
@@ -0,0 +1,52 @@
1
+ import { Directive, ElementRef, computed, inject, input } from '@angular/core';
2
+ import { cn } from '@/components/ui/utils/cn';
3
+ import {
4
+ MIMI_FIELD_CONTROL,
5
+ type MimiFieldControl,
6
+ injectFieldState,
7
+ } from '@/components/ui/utils/field-state';
8
+ import { type InputSize, inputVariants } from './input.variants';
9
+
10
+ /**
11
+ * Campo de texto de Mimi sobre el `<input>` nativo: funciona con `[formField]`,
12
+ * `formControlName`, `[formControl]` y `[(ngModel)]` sin adaptadores (spec, sección 8).
13
+ *
14
+ * Se pone en rojo con `aria-invalid="true"` cuando el campo es inválido y ya se tocó o
15
+ * modificó. `showError` permite decidirlo a mano.
16
+ */
17
+ @Directive({
18
+ selector: 'input[mimiInput]',
19
+ // mimi-form-field lo encuentra por este token (utils/field-state.ts).
20
+ providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiInput }],
21
+ exportAs: 'mimiInput',
22
+ host: {
23
+ '[class]': 'classes()',
24
+ '[attr.aria-invalid]': 'showInvalid() ? "true" : null',
25
+ '[attr.data-size]': 'size()',
26
+ '[attr.data-invalid]': 'showInvalid() ? "" : null',
27
+ '[attr.data-disabled]': 'fieldState.disabled() ? "" : null',
28
+ },
29
+ })
30
+ export class MimiInput implements MimiFieldControl {
31
+ readonly size = input<InputSize>('default');
32
+ /**
33
+ * Muestra el error a mano. `undefined` (por defecto): decide el formulario. `true`: lo muestra.
34
+ * `false`: lo oculta aunque el formulario sea inválido.
35
+ *
36
+ * No se llama `invalid`: Signal Forms escribe su estado en cualquier entrada con ese nombre
37
+ * (también touched, dirty, disabled, errors, required, name…) de las directivas del elemento.
38
+ */
39
+ readonly showError = input<boolean | undefined>(undefined);
40
+ readonly userClass = input('', { alias: 'class' });
41
+
42
+ /** Estado del campo según su formulario. Lo lee también MimiFormField. */
43
+ readonly fieldState = injectFieldState();
44
+ readonly controlElement = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
45
+
46
+ /** Error visible: el formulario o `showError` a mano. */
47
+ readonly showInvalid = computed(() => this.showError() ?? this.fieldState.showError());
48
+
49
+ protected readonly classes = computed(() =>
50
+ cn(inputVariants({ size: this.size() }), this.userClass()),
51
+ );
52
+ }
@@ -0,0 +1,38 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import {
3
+ controlDisabledStyles,
4
+ controlInvalidStyles,
5
+ fieldFocusStyles,
6
+ } from '@/components/ui/utils/control-styles';
7
+
8
+ /**
9
+ * Clases de mimiInput (docs/design/Mimi Componentes.dc.html, sección 2). Literales para que
10
+ * Tailwind las detecte. Cascada de la spec 6.2: --mimi-input-* → --mimi-control-* /
11
+ * --mimi-radius → valor del diseño.
12
+ */
13
+ export const inputVariants = cva(
14
+ [
15
+ 'w-full min-w-0 rounded-[var(--mimi-input-radius,var(--mimi-radius,0.75rem))]',
16
+ 'border-[length:var(--mimi-input-border-width,1px)] border-input bg-input-background',
17
+ 'px-[var(--mimi-input-padding-x,0.75rem)] font-sans text-foreground mimi-transition',
18
+ 'placeholder:text-[color:var(--mimi-input-placeholder-color,var(--mimi-muted-foreground))]',
19
+ fieldFocusStyles,
20
+ 'focus-visible:ring-[length:var(--mimi-input-focus-ring-width,3px)]',
21
+ controlDisabledStyles,
22
+ 'disabled:opacity-[var(--mimi-input-disabled-opacity,0.5)]',
23
+ controlInvalidStyles,
24
+ ],
25
+ {
26
+ variants: {
27
+ size: {
28
+ sm: 'h-[var(--mimi-input-height-sm,var(--mimi-control-height-sm,2rem))] text-[13px]',
29
+ default:
30
+ 'h-[var(--mimi-input-height,var(--mimi-control-height,2.5rem))] text-[length:var(--mimi-input-font-size,0.875rem)]',
31
+ lg: 'h-[var(--mimi-input-height-lg,var(--mimi-control-height-lg,3rem))] text-[15px]',
32
+ },
33
+ },
34
+ defaultVariants: { size: 'default' },
35
+ },
36
+ );
37
+
38
+ export type InputSize = NonNullable<VariantProps<typeof inputVariants>['size']>;