@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,241 @@
1
+ /* Mimi · tema base (estilo Vivid).
2
+ Valores tomados de docs/design/tokens-vivid.css. Nombres según docs/spec.md, sección 6.
3
+ Este archivo no importa Tailwind ni carga fuentes: lo hace la app que lo usa. */
4
+
5
+ @custom-variant dark (&:where(.dark, .dark *));
6
+
7
+ /* ── Claro ─────────────────────────────────────── */
8
+ :root {
9
+ /* Colores */
10
+ --mimi-background: oklch(1 0 0);
11
+ --mimi-foreground: oklch(0.145 0 0);
12
+ --mimi-card: oklch(1 0 0);
13
+ --mimi-card-foreground: oklch(0.145 0 0);
14
+ --mimi-popover: oklch(1 0 0);
15
+ --mimi-popover-foreground: oklch(0.145 0 0);
16
+ --mimi-primary: oklch(0.205 0 0);
17
+ --mimi-primary-foreground: oklch(0.985 0 0);
18
+ --mimi-secondary: oklch(0.97 0 0);
19
+ --mimi-secondary-foreground: oklch(0.205 0 0);
20
+ --mimi-muted: oklch(0.97 0 0);
21
+ --mimi-muted-foreground: oklch(0.54 0 0);
22
+ --mimi-accent: oklch(0.97 0 0);
23
+ --mimi-accent-foreground: oklch(0.205 0 0);
24
+ --mimi-destructive: oklch(0.577 0.245 27.325);
25
+ --mimi-destructive-foreground: oklch(0.985 0 0);
26
+ --mimi-border: oklch(0.922 0 0);
27
+ --mimi-input: oklch(0.88 0 0);
28
+ --mimi-input-background: oklch(1 0 0);
29
+ --mimi-ring: oklch(0.708 0 0);
30
+ --mimi-overlay: oklch(0 0 0 / 50%);
31
+
32
+ /* Fuentes (sin cargar: la app decide si trae Outfit) */
33
+ --mimi-font-sans: 'Outfit', ui-sans-serif, system-ui, sans-serif;
34
+ --mimi-font-mono: 'Geist Mono', ui-monospace, monospace;
35
+
36
+ /* Forma y tamaños */
37
+ --mimi-radius: 0.75rem;
38
+ --mimi-radius-sm: min(calc(var(--mimi-radius) / 2), 6px);
39
+ --mimi-radius-card: min(calc(var(--mimi-radius) + 4px), 24px);
40
+ --mimi-badge-radius: 999px;
41
+ --mimi-control-height: 2.5rem;
42
+ --mimi-control-height-sm: 2rem;
43
+ --mimi-control-height-lg: 3rem;
44
+
45
+ /* Movimiento. `translate` mueve el pulgar del Switch (y --mimi-lift si se usa). */
46
+ --mimi-lift: 0px;
47
+ --mimi-press-scale: 0.975;
48
+ /* Escala al presionar de Switch y Checkbox (diseño: scale(0.9)) */
49
+ --mimi-press-scale-sm: 0.9;
50
+ --mimi-transition:
51
+ background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.25s ease,
52
+ scale 0.15s cubic-bezier(0.2, 0.8, 0.2, 1), translate 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
53
+
54
+ /* Sombras */
55
+ --mimi-shadow-card:
56
+ 0 0 0 1px oklch(0 0 0 / 0.02), 0 1px 2px oklch(0 0 0 / 0.04),
57
+ 0 6px 12px -4px oklch(0 0 0 / 0.05), 0 20px 40px -16px oklch(0 0 0 / 0.12);
58
+ --mimi-shadow-primary:
59
+ 0 1px 2px color-mix(in oklch, var(--mimi-primary) 25%, transparent),
60
+ 0 4px 12px -4px color-mix(in oklch, var(--mimi-primary) 35%, transparent);
61
+ --mimi-shadow-primary-hover:
62
+ 0 1px 2px color-mix(in oklch, var(--mimi-primary) 30%, transparent),
63
+ 0 8px 18px -6px color-mix(in oklch, var(--mimi-primary) 42%, transparent);
64
+ --mimi-shadow-destructive:
65
+ 0 1px 2px color-mix(in oklch, var(--mimi-destructive) 25%, transparent),
66
+ 0 4px 12px -4px color-mix(in oklch, var(--mimi-destructive) 35%, transparent);
67
+ --mimi-shadow-destructive-hover:
68
+ 0 1px 2px color-mix(in oklch, var(--mimi-destructive) 30%, transparent),
69
+ 0 8px 18px -6px color-mix(in oklch, var(--mimi-destructive) 42%, transparent);
70
+ --mimi-shadow-neutral: 0 1px 2px oklch(0 0 0 / 0.06), 0 4px 10px -4px oklch(0 0 0 / 0.08);
71
+ --mimi-shadow-neutral-hover: 0 2px 4px oklch(0 0 0 / 0.06), 0 10px 20px -8px oklch(0 0 0 / 0.14);
72
+ /* Pulgar del Switch (igual en claro y oscuro) */
73
+ --mimi-shadow-thumb: 0 1px 3px oklch(0 0 0 / 0.2);
74
+
75
+ /* Derivados */
76
+ --mimi-primary-hover: color-mix(in oklch, var(--mimi-primary) 88%, var(--mimi-background));
77
+ --mimi-secondary-hover: color-mix(in oklch, var(--mimi-secondary) 80%, var(--mimi-foreground) 6%);
78
+ --mimi-destructive-hover: color-mix(
79
+ in oklch,
80
+ var(--mimi-destructive) 88%,
81
+ var(--mimi-background)
82
+ );
83
+ --mimi-ring-soft: color-mix(in oklch, var(--mimi-ring) 32%, transparent);
84
+ --mimi-destructive-soft: color-mix(in oklch, var(--mimi-destructive) 24%, transparent);
85
+ --mimi-switch-off: color-mix(in oklch, var(--mimi-foreground) 20%, transparent);
86
+
87
+ color-scheme: light;
88
+ }
89
+
90
+ /* ── Oscuro ────────────────────────────────────── */
91
+ .dark {
92
+ --mimi-background: oklch(0.145 0 0);
93
+ --mimi-foreground: oklch(0.985 0 0);
94
+ --mimi-card: oklch(0.175 0 0);
95
+ --mimi-card-foreground: oklch(0.985 0 0);
96
+ --mimi-popover: oklch(0.175 0 0);
97
+ --mimi-popover-foreground: oklch(0.985 0 0);
98
+ --mimi-primary: oklch(0.922 0 0);
99
+ --mimi-primary-foreground: oklch(0.205 0 0);
100
+ --mimi-secondary: oklch(0.269 0 0);
101
+ --mimi-secondary-foreground: oklch(0.985 0 0);
102
+ --mimi-muted: oklch(0.225 0 0);
103
+ --mimi-muted-foreground: oklch(0.708 0 0);
104
+ --mimi-accent: oklch(0.269 0 0);
105
+ --mimi-accent-foreground: oklch(0.985 0 0);
106
+ --mimi-destructive: oklch(0.704 0.191 22.216);
107
+ --mimi-destructive-foreground: oklch(0.2 0.04 25);
108
+ --mimi-border: oklch(1 0 0 / 10%);
109
+ --mimi-input: oklch(1 0 0 / 15%);
110
+ --mimi-input-background: oklch(1 0 0 / 4.5%);
111
+ --mimi-ring: oklch(0.556 0 0);
112
+ --mimi-overlay: oklch(0 0 0 / 50%);
113
+
114
+ --mimi-shadow-card:
115
+ inset 0 1px 0 oklch(1 0 0 / 0.04), 0 1px 2px oklch(0 0 0 / 0.5),
116
+ 0 20px 40px -16px oklch(0 0 0 / 0.7);
117
+ --mimi-shadow-primary:
118
+ 0 1px 2px oklch(0 0 0 / 0.5),
119
+ 0 4px 12px -4px color-mix(in oklch, var(--mimi-primary) 30%, transparent);
120
+ --mimi-shadow-primary-hover:
121
+ 0 1px 2px oklch(0 0 0 / 0.5),
122
+ 0 8px 18px -6px color-mix(in oklch, var(--mimi-primary) 40%, transparent);
123
+ --mimi-shadow-destructive:
124
+ 0 1px 2px oklch(0 0 0 / 0.5),
125
+ 0 4px 12px -4px color-mix(in oklch, var(--mimi-destructive) 30%, transparent);
126
+ --mimi-shadow-destructive-hover:
127
+ 0 1px 2px oklch(0 0 0 / 0.5),
128
+ 0 8px 18px -6px color-mix(in oklch, var(--mimi-destructive) 40%, transparent);
129
+ --mimi-shadow-neutral:
130
+ inset 0 1px 0 oklch(1 0 0 / 0.05), 0 1px 2px oklch(0 0 0 / 0.5),
131
+ 0 4px 10px -4px oklch(0 0 0 / 0.6);
132
+ --mimi-shadow-neutral-hover:
133
+ inset 0 1px 0 oklch(1 0 0 / 0.06), 0 2px 4px oklch(0 0 0 / 0.5),
134
+ 0 10px 20px -8px oklch(0 0 0 / 0.75);
135
+
136
+ /* Los derivados se redeclaran para que tomen la paleta oscura */
137
+ --mimi-primary-hover: color-mix(in oklch, var(--mimi-primary) 88%, var(--mimi-background));
138
+ --mimi-secondary-hover: color-mix(in oklch, var(--mimi-secondary) 80%, var(--mimi-foreground) 6%);
139
+ --mimi-destructive-hover: color-mix(
140
+ in oklch,
141
+ var(--mimi-destructive) 88%,
142
+ var(--mimi-background)
143
+ );
144
+ --mimi-ring-soft: color-mix(in oklch, var(--mimi-ring) 32%, transparent);
145
+ --mimi-destructive-soft: color-mix(in oklch, var(--mimi-destructive) 24%, transparent);
146
+ --mimi-switch-off: color-mix(in oklch, var(--mimi-foreground) 20%, transparent);
147
+
148
+ color-scheme: dark;
149
+ }
150
+
151
+ /* ── Movimiento reducido: sin escala al hacer clic ni transición de scale y translate ── */
152
+ @media (prefers-reduced-motion: reduce) {
153
+ :root {
154
+ --mimi-press-scale: 1;
155
+ --mimi-press-scale-sm: 1;
156
+ --mimi-transition:
157
+ background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.25s ease;
158
+ }
159
+ }
160
+
161
+ /* ── Tokens expuestos a Tailwind (inline: los valores son var() y deben resolverse en uso) ── */
162
+ @theme inline {
163
+ --color-background: var(--mimi-background);
164
+ --color-foreground: var(--mimi-foreground);
165
+ --color-card: var(--mimi-card);
166
+ --color-card-foreground: var(--mimi-card-foreground);
167
+ --color-popover: var(--mimi-popover);
168
+ --color-popover-foreground: var(--mimi-popover-foreground);
169
+ --color-primary: var(--mimi-primary);
170
+ --color-primary-foreground: var(--mimi-primary-foreground);
171
+ --color-primary-hover: var(--mimi-primary-hover);
172
+ --color-secondary: var(--mimi-secondary);
173
+ --color-secondary-foreground: var(--mimi-secondary-foreground);
174
+ --color-secondary-hover: var(--mimi-secondary-hover);
175
+ --color-muted: var(--mimi-muted);
176
+ --color-muted-foreground: var(--mimi-muted-foreground);
177
+ --color-accent: var(--mimi-accent);
178
+ --color-accent-foreground: var(--mimi-accent-foreground);
179
+ --color-destructive: var(--mimi-destructive);
180
+ --color-destructive-foreground: var(--mimi-destructive-foreground);
181
+ --color-destructive-hover: var(--mimi-destructive-hover);
182
+ --color-destructive-soft: var(--mimi-destructive-soft);
183
+ --color-border: var(--mimi-border);
184
+ --color-input: var(--mimi-input);
185
+ --color-input-background: var(--mimi-input-background);
186
+ --color-ring: var(--mimi-ring);
187
+ --color-ring-soft: var(--mimi-ring-soft);
188
+ --color-overlay: var(--mimi-overlay);
189
+ --color-switch-off: var(--mimi-switch-off);
190
+
191
+ --radius-sm: var(--mimi-radius-sm);
192
+ --radius-md: calc(var(--mimi-radius) - 2px);
193
+ --radius-lg: var(--mimi-radius);
194
+ --radius-xl: calc(var(--mimi-radius) + 4px);
195
+ --radius-card: var(--mimi-radius-card);
196
+ --radius-badge: var(--mimi-badge-radius);
197
+
198
+ --shadow-card: var(--mimi-shadow-card);
199
+ --shadow-primary: var(--mimi-shadow-primary);
200
+ --shadow-primary-hover: var(--mimi-shadow-primary-hover);
201
+ --shadow-destructive: var(--mimi-shadow-destructive);
202
+ --shadow-destructive-hover: var(--mimi-shadow-destructive-hover);
203
+ --shadow-neutral: var(--mimi-shadow-neutral);
204
+ --shadow-neutral-hover: var(--mimi-shadow-neutral-hover);
205
+ --shadow-thumb: var(--mimi-shadow-thumb);
206
+
207
+ --font-sans: var(--mimi-font-sans);
208
+ --font-mono: var(--mimi-font-mono);
209
+
210
+ --animate-mimi-pulse: mimi-pulse 1.6s ease-in-out infinite;
211
+ }
212
+
213
+ @utility mimi-transition {
214
+ transition: var(--mimi-transition);
215
+ }
216
+
217
+ @layer base {
218
+ /* Tailwind 4 usa currentColor como color de borde por defecto */
219
+ *,
220
+ ::before,
221
+ ::after {
222
+ border-color: var(--mimi-border);
223
+ }
224
+
225
+ body {
226
+ background-color: var(--mimi-background);
227
+ color: var(--mimi-foreground);
228
+ }
229
+ }
230
+
231
+ @keyframes mimi-spin {
232
+ to {
233
+ transform: rotate(360deg);
234
+ }
235
+ }
236
+
237
+ @keyframes mimi-pulse {
238
+ 50% {
239
+ opacity: 0.45;
240
+ }
241
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Preset tipado del tema (spec 6.3). Cada propiedad se convierte en una variable --mimi-*
3
+ * de theme-base.css. Los derivados con color-mix (primary-hover, ring-soft, switch-off…)
4
+ * no están aquí: se recalculan solos a partir de los colores.
5
+ */
6
+
7
+ /** Colores. `colors` se aplica en claro y `darkColors` en oscuro. */
8
+ export interface MimiColorTokens {
9
+ background?: string;
10
+ foreground?: string;
11
+ card?: string;
12
+ cardForeground?: string;
13
+ popover?: string;
14
+ popoverForeground?: string;
15
+ primary?: string;
16
+ primaryForeground?: string;
17
+ secondary?: string;
18
+ secondaryForeground?: string;
19
+ muted?: string;
20
+ mutedForeground?: string;
21
+ accent?: string;
22
+ accentForeground?: string;
23
+ destructive?: string;
24
+ destructiveForeground?: string;
25
+ border?: string;
26
+ input?: string;
27
+ inputBackground?: string;
28
+ ring?: string;
29
+ /** Fondo que oscurece la página detrás de paneles y diálogos. */
30
+ overlay?: string;
31
+ }
32
+
33
+ /** Sombras (`box-shadow` completo). `shadows` en claro y `darkShadows` en oscuro. */
34
+ export interface MimiShadowTokens {
35
+ card?: string;
36
+ primary?: string;
37
+ primaryHover?: string;
38
+ destructive?: string;
39
+ destructiveHover?: string;
40
+ neutral?: string;
41
+ neutralHover?: string;
42
+ /** Pulgar del Switch. */
43
+ thumb?: string;
44
+ }
45
+
46
+ /** Radios fijos. Si no se definen, sm y card se calculan a partir de `radius`. */
47
+ export interface MimiRadiusTokens {
48
+ sm?: string;
49
+ card?: string;
50
+ badge?: string;
51
+ }
52
+
53
+ export interface MimiFontTokens {
54
+ sans?: string;
55
+ mono?: string;
56
+ }
57
+
58
+ export interface MimiMotionTokens {
59
+ /** Lista de transiciones. Debe animar `scale` y `translate` (pulgar del Switch y `lift`). */
60
+ transition?: string;
61
+ pressScale?: string | number;
62
+ /** Escala al presionar de Switch y Checkbox. */
63
+ pressScaleSm?: string | number;
64
+ lift?: string;
65
+ }
66
+
67
+ /** Alturas compartidas por Button, Input y Textarea (cascada de la spec 6.2). */
68
+ export interface MimiControlSizeTokens {
69
+ height?: string;
70
+ heightSm?: string;
71
+ heightLg?: string;
72
+ }
73
+
74
+ /** Tokens por componente. Cada componente los lee con la cascada de la spec 6.2. */
75
+ export interface MimiControlTokens extends MimiControlSizeTokens {
76
+ radius?: string;
77
+ paddingX?: string;
78
+ fontSize?: string;
79
+ borderWidth?: string;
80
+ focusRingWidth?: string;
81
+ }
82
+
83
+ /** paddingX y fontSize se aplican al tamaño default; sm y lg usan los valores del diseño. */
84
+ export interface MimiButtonTokens extends MimiControlTokens {
85
+ fontWeight?: string | number;
86
+ letterSpacing?: string;
87
+ }
88
+
89
+ export interface MimiInputTokens extends MimiControlTokens {
90
+ placeholderColor?: string;
91
+ disabledOpacity?: string | number;
92
+ }
93
+
94
+ export interface MimiCardTokens {
95
+ radius?: string;
96
+ borderWidth?: string;
97
+ shadow?: string;
98
+ paddingHeader?: string;
99
+ paddingContent?: string;
100
+ paddingFooter?: string;
101
+ }
102
+
103
+ export interface MimiComponentTokens {
104
+ button?: MimiButtonTokens;
105
+ input?: MimiInputTokens;
106
+ card?: MimiCardTokens;
107
+ // select y dialog se agregan en la Fase 4
108
+ }
109
+
110
+ export interface MimiThemePreset {
111
+ /** Solo informativo: no genera CSS. */
112
+ name?: string;
113
+ /** --mimi-radius */
114
+ radius?: string;
115
+ radii?: MimiRadiusTokens;
116
+ colors?: MimiColorTokens;
117
+ darkColors?: MimiColorTokens;
118
+ shadows?: MimiShadowTokens;
119
+ darkShadows?: MimiShadowTokens;
120
+ fonts?: MimiFontTokens;
121
+ motion?: MimiMotionTokens;
122
+ controls?: MimiControlSizeTokens;
123
+ components?: MimiComponentTokens;
124
+ }
@@ -0,0 +1,36 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { extendTailwindMerge } from 'tailwind-merge';
3
+
4
+ /**
5
+ * tailwind-merge solo reconoce tallas (sm, md, lg…) en las escalas de sombra y radio.
6
+ * Sin registrar los tokens de Mimi, `shadow-card` se tomaría como color de sombra y
7
+ * `rounded-card` no se fusionaría con otros `rounded-*`; lo mismo `animate-mimi-pulse` con
8
+ * `animate-none`. Los colores no hace falta registrarlos: la escala de color acepta cualquier
9
+ * nombre.
10
+ */
11
+ const twMerge = extendTailwindMerge({
12
+ extend: {
13
+ theme: {
14
+ shadow: [
15
+ 'card',
16
+ 'primary',
17
+ 'primary-hover',
18
+ 'destructive',
19
+ 'destructive-hover',
20
+ 'neutral',
21
+ 'neutral-hover',
22
+ 'thumb',
23
+ ],
24
+ radius: ['card', 'badge'],
25
+ animate: ['mimi-pulse'],
26
+ },
27
+ classGroups: {
28
+ transition: ['mimi-transition'],
29
+ },
30
+ },
31
+ });
32
+
33
+ /** Une clases con clsx y resuelve los conflictos de Tailwind (gana la última). */
34
+ export function cn(...inputs: ClassValue[]): string {
35
+ return twMerge(clsx(inputs));
36
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Estilos compartidos de Button, Input y Textarea (docs/design, hoja de componentes).
3
+ * Clases completas y literales para que Tailwind las detecte. Las alturas, el padding y el
4
+ * tamaño de letra los pone cada componente con su propia cascada de tokens (spec 6.2).
5
+ */
6
+
7
+ /** Foco de botones: contorno del color de anillo, separado 2px. */
8
+ export const buttonFocusStyles =
9
+ 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring';
10
+
11
+ /** Foco de Input y Textarea: borde de anillo y halo ring-soft de 3px. */
12
+ export const fieldFocusStyles =
13
+ 'outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring-soft';
14
+
15
+ /**
16
+ * Deshabilitado. `disabled:` para elementos nativos; `aria-disabled:` para a[mimiBtn],
17
+ * que además necesita pointer-events-none para no navegar.
18
+ */
19
+ export const controlDisabledStyles =
20
+ 'disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50';
21
+
22
+ /** Inválido: borde destructive fijo y halo destructive-soft solo al enfocar. */
23
+ export const controlInvalidStyles =
24
+ 'aria-invalid:border-destructive aria-invalid:focus-visible:border-destructive aria-invalid:focus-visible:ring-destructive-soft';
25
+
26
+ /** Transición y escala al hacer clic. Solo botones. */
27
+ export const controlPressStyles = 'mimi-transition active:scale-(--mimi-press-scale)';
@@ -0,0 +1,164 @@
1
+ import {
2
+ DestroyRef,
3
+ InjectionToken,
4
+ type Signal,
5
+ afterNextRender,
6
+ computed,
7
+ inject,
8
+ signal,
9
+ } from '@angular/core';
10
+ import { NgControl, type ValidationErrors } from '@angular/forms';
11
+ import { FormField } from '@angular/forms/signals';
12
+
13
+ /**
14
+ * Un error del campo, igual para los tres sistemas de formularios.
15
+ *
16
+ * - `kind`: la clave del error en minúsculas (`required`, `email`, `minlength`…). Signal Forms
17
+ * usa `minLength` / `maxLength`; se normalizan a los nombres de Reactive Forms.
18
+ * - `message`: el texto que trae el error (Signal Forms, desde el esquema de validación).
19
+ * - `params`: los datos del error con los nombres de Reactive Forms (`requiredLength`,
20
+ * `actualLength`, `min`, `max`, `requiredPattern`…).
21
+ */
22
+ export interface MimiFieldError {
23
+ kind: string;
24
+ message?: string;
25
+ params: Record<string, unknown>;
26
+ }
27
+
28
+ /** Estado de un campo de formulario, como signals (spec, sección 8). */
29
+ export interface MimiFieldState {
30
+ invalid: Signal<boolean>;
31
+ touched: Signal<boolean>;
32
+ dirty: Signal<boolean>;
33
+ disabled: Signal<boolean>;
34
+ /** Inválido y ya tocado o modificado: el momento de mostrar el error. */
35
+ showError: Signal<boolean>;
36
+ /** Errores actuales, en orden. */
37
+ errors: Signal<readonly MimiFieldError[]>;
38
+ }
39
+
40
+ /**
41
+ * Lo que un control le cuenta a `mimi-form-field`: su elemento (para el `id` y el
42
+ * `aria-describedby`), su estado y si muestra el error (el formulario o `showError` a mano).
43
+ * Lo proveen Input, Textarea, Switch y Checkbox.
44
+ */
45
+ export interface MimiFieldControl {
46
+ readonly controlElement: HTMLElement;
47
+ readonly fieldState: MimiFieldState;
48
+ readonly showInvalid: Signal<boolean>;
49
+ }
50
+
51
+ export const MIMI_FIELD_CONTROL = new InjectionToken<MimiFieldControl>('MIMI_FIELD_CONTROL');
52
+
53
+ interface Snapshot {
54
+ invalid: boolean;
55
+ touched: boolean;
56
+ dirty: boolean;
57
+ disabled: boolean;
58
+ errors: readonly MimiFieldError[];
59
+ }
60
+
61
+ const EMPTY: Snapshot = {
62
+ invalid: false,
63
+ touched: false,
64
+ dirty: false,
65
+ disabled: false,
66
+ errors: [],
67
+ };
68
+
69
+ /**
70
+ * Lee el estado del campo del elemento actual, sea cual sea el sistema de formularios:
71
+ *
72
+ * - Signal Forms (`[formField]`): usa `FormField.state()`, que ya son signals.
73
+ * - Reactive Forms (`formControlName`, `[formControl]`) y `[(ngModel)]`: se suscribe a
74
+ * `control.events` (los flags de AbstractControl no son signals, CLAUDE.md regla 7).
75
+ * - Sin formulario: todo en `false`.
76
+ *
77
+ * Con `formControlName` / `[formControl]` el control existe recién después de que esa directiva
78
+ * procesa sus entradas, que puede ser después de crear la nuestra; por eso la suscripción se
79
+ * intenta enseguida y, si todavía no hay control, después del primer render.
80
+ *
81
+ * Debe llamarse en un contexto de inyección (inicializador de una propiedad).
82
+ */
83
+ export function injectFieldState(): MimiFieldState {
84
+ const formField = inject(FormField, { optional: true, self: true });
85
+ if (formField) {
86
+ const state = computed(() => formField.state());
87
+ return build(
88
+ computed<Snapshot>(() => ({
89
+ invalid: state().invalid(),
90
+ touched: state().touched(),
91
+ dirty: state().dirty(),
92
+ disabled: state().disabled(),
93
+ errors: state()
94
+ .errors()
95
+ .map((error) => fromSignalError(error)),
96
+ })),
97
+ );
98
+ }
99
+
100
+ const snapshot = signal<Snapshot>(EMPTY);
101
+ const ngControl = inject(NgControl, { optional: true, self: true });
102
+ if (ngControl) {
103
+ const destroyRef = inject(DestroyRef);
104
+ let connected = false;
105
+ const connect = () => {
106
+ const control = ngControl.control;
107
+ if (connected || !control) return;
108
+ connected = true;
109
+ const read = () =>
110
+ snapshot.set({
111
+ invalid: control.invalid,
112
+ touched: control.touched,
113
+ dirty: control.dirty,
114
+ disabled: control.disabled,
115
+ errors: fromValidationErrors(control.errors),
116
+ });
117
+ read();
118
+ const subscription = control.events.subscribe(read);
119
+ destroyRef.onDestroy(() => subscription.unsubscribe());
120
+ };
121
+ connect();
122
+ afterNextRender(connect);
123
+ }
124
+ return build(snapshot);
125
+ }
126
+
127
+ /** Estado vacío (sin formulario). Útil para controles que todavía no conocen su formulario. */
128
+ export function emptyFieldState(): MimiFieldState {
129
+ return build(signal(EMPTY));
130
+ }
131
+
132
+ function build(snapshot: Signal<Snapshot>): MimiFieldState {
133
+ return {
134
+ invalid: computed(() => snapshot().invalid),
135
+ touched: computed(() => snapshot().touched),
136
+ dirty: computed(() => snapshot().dirty),
137
+ disabled: computed(() => snapshot().disabled),
138
+ showError: computed(() => snapshot().invalid && (snapshot().touched || snapshot().dirty)),
139
+ errors: computed(() => snapshot().errors, { equal: sameErrors }),
140
+ };
141
+ }
142
+
143
+ const sameErrors = (a: readonly MimiFieldError[], b: readonly MimiFieldError[]) =>
144
+ a.length === b.length && a.every((e, i) => e.kind === b[i].kind && e.message === b[i].message);
145
+
146
+ /** Error de Signal Forms → MimiFieldError (nombres de Reactive Forms en params). */
147
+ function fromSignalError(error: { kind: string; message?: string }): MimiFieldError {
148
+ const data = error as unknown as Record<string, unknown>;
149
+ const kind = error.kind.toLowerCase();
150
+ const params: Record<string, unknown> = { ...data };
151
+ if (kind === 'minlength') params['requiredLength'] = data['minLength'];
152
+ if (kind === 'maxlength') params['requiredLength'] = data['maxLength'];
153
+ delete params['fieldTree'];
154
+ return { kind, message: error.message, params };
155
+ }
156
+
157
+ /** ValidationErrors de Reactive Forms / ngModel → MimiFieldError[], en orden. */
158
+ function fromValidationErrors(errors: ValidationErrors | null): MimiFieldError[] {
159
+ if (!errors) return [];
160
+ return Object.entries(errors).map(([key, value]) => ({
161
+ kind: key.toLowerCase(),
162
+ params: value && typeof value === 'object' ? (value as Record<string, unknown>) : {},
163
+ }));
164
+ }