@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,363 @@
1
+ import { LiveAnnouncer } from '@angular/cdk/a11y';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ DestroyRef,
6
+ afterNextRender,
7
+ afterRenderEffect,
8
+ booleanAttribute,
9
+ computed,
10
+ contentChild,
11
+ inject,
12
+ input,
13
+ signal,
14
+ } from '@angular/core';
15
+ import { MimiInput } from '@/components/ui/input';
16
+ import { cn } from '@/components/ui/utils/cn';
17
+ import { buttonFocusStyles } from '@/components/ui/utils/control-styles';
18
+ import { MIMI_PASSWORD_TEXT } from './password-texts';
19
+
20
+ /*
21
+ * Password (docs/design/Mimi F3 Slider OTP Numero Mascara.dc.html, «8 · Password»; prefijo de
22
+ * tokens `password`, spec 13). Un contenedor que proyecta el `<input mimiInput>` del usuario
23
+ * (CLAUDE.md, regla 6: el valor y el formulario son del input nativo) y le agrega el botón de
24
+ * mostrar u ocultar y, si se pide, el medidor.
25
+ *
26
+ * El botón cambia el `type` del mismo input (no lo reemplaza: el gestor de contraseñas y el
27
+ * formulario conservan la referencia). El medidor es orientativo, no una medida de seguridad.
28
+ */
29
+
30
+ /** 0: vacío; 1: débil; 2: media; 3: fuerte. */
31
+ export type MimiPasswordLevel = 0 | 1 | 2 | 3;
32
+
33
+ /** Una regla del medidor: su texto y si la contraseña la cumple. */
34
+ export interface MimiPasswordRule {
35
+ label: string;
36
+ test: (value: string) => boolean;
37
+ }
38
+
39
+ /**
40
+ * La heurística del diseño (`strength()`), generalizada a cualquier lista de reglas: todas,
41
+ * fuerte; al menos la mitad, media; menos, débil. Con las cuatro por defecto: 0–1 débil, 2–3
42
+ * media, 4 fuerte.
43
+ */
44
+ export function mimiPasswordLevel(
45
+ value: string,
46
+ rules: readonly MimiPasswordRule[],
47
+ ): MimiPasswordLevel {
48
+ if (!value) return 0;
49
+ if (!rules.length) return 1;
50
+ const passed = rules.filter((rule) => rule.test(value)).length;
51
+ if (passed === rules.length) return 3;
52
+ return passed >= rules.length / 2 ? 2 : 1;
53
+ }
54
+
55
+ const BOX_CLASSES = cn(
56
+ 'relative flex w-full min-w-0 items-center gap-1.5 font-sans text-foreground mimi-transition',
57
+ 'h-[var(--mimi-input-height,var(--mimi-control-height,2.5rem))] rounded-[var(--mimi-input-radius,var(--mimi-radius,0.75rem))] ps-3 pe-1',
58
+ 'border border-input bg-input-background',
59
+ 'not-data-[disabled]:hover:border-input-hover',
60
+ 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring-soft',
61
+ 'data-[invalid]:border-destructive data-[invalid]:focus-within:border-destructive data-[invalid]:focus-within:ring-destructive-ring',
62
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-[var(--mimi-disabled-opacity,0.5)]',
63
+ // El input del usuario (mimiInput) va sin su caja: la caja es esta. Sin el ojo de Edge.
64
+ '[&_input]:h-full [&_input]:min-w-0 [&_input]:flex-1 [&_input]:rounded-none [&_input]:border-0 [&_input]:bg-transparent [&_input]:p-0 [&_input]:shadow-none',
65
+ '[&_input]:focus-visible:ring-0 [&_input]:disabled:opacity-100 [&_input::-ms-reveal]:hidden',
66
+ );
67
+
68
+ const TOGGLE_CLASSES = cn(
69
+ 'inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-muted-foreground mimi-transition',
70
+ 'size-[var(--mimi-password-toggle-size,32px)] rounded-[var(--mimi-password-toggle-radius,var(--mimi-radius-sm))]',
71
+ '[&_svg]:size-4 [&_svg]:shrink-0',
72
+ 'enabled:hover:bg-[color:var(--mimi-password-toggle-hover,var(--mimi-accent))] enabled:hover:text-foreground enabled:active:scale-(--mimi-press-scale-sm)',
73
+ 'disabled:cursor-not-allowed',
74
+ buttonFocusStyles,
75
+ );
76
+
77
+ /** Barra vacía y barras con el color del nivel (globales del Grupo 2 con respaldo, G2.0b). */
78
+ const BAR_EMPTY = 'bg-[color:var(--mimi-password-meter-track,var(--mimi-track,var(--mimi-input)))]';
79
+ const BAR_FILL: Record<1 | 2 | 3, string> = {
80
+ 1: 'bg-[color:var(--mimi-strength-weak,var(--mimi-destructive))]',
81
+ 2: 'bg-[color:var(--mimi-strength-medium,var(--mimi-warning))]',
82
+ 3: 'bg-[color:var(--mimi-strength-strong,var(--mimi-success))]',
83
+ };
84
+ const LEVEL_TEXT: Record<1 | 2 | 3, string> = {
85
+ 1: 'text-destructive-soft-foreground',
86
+ 2: 'text-warning-soft-foreground',
87
+ 3: 'text-success-soft-foreground',
88
+ };
89
+
90
+ let nextId = 0;
91
+
92
+ /**
93
+ * Password:
94
+ *
95
+ * ```html
96
+ * <mimi-password meter>
97
+ * <input mimiInput type="password" autocomplete="new-password" formControlName="password" />
98
+ * </mimi-password>
99
+ * ```
100
+ *
101
+ * El `type`, el `autocomplete` y el nombre del campo son del usuario.
102
+ */
103
+ @Component({
104
+ selector: 'mimi-password',
105
+ template: `
106
+ <div
107
+ data-slot="box"
108
+ [class]="boxClasses()"
109
+ [attr.data-invalid]="invalid() ? '' : null"
110
+ [attr.data-disabled]="isDisabled() ? '' : null"
111
+ >
112
+ <ng-content />
113
+ <button
114
+ type="button"
115
+ data-slot="toggle"
116
+ [class]="toggleClasses"
117
+ [attr.aria-label]="text.show"
118
+ [attr.aria-pressed]="shown()"
119
+ [disabled]="isDisabled()"
120
+ (click)="toggle()"
121
+ >
122
+ @if (shown()) {
123
+ <svg
124
+ viewBox="0 0 24 24"
125
+ fill="none"
126
+ stroke="currentColor"
127
+ stroke-width="2"
128
+ stroke-linecap="round"
129
+ stroke-linejoin="round"
130
+ aria-hidden="true"
131
+ >
132
+ <!-- Lucide eye-off (ISC) -->
133
+ <path
134
+ d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"
135
+ />
136
+ <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
137
+ <path
138
+ d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"
139
+ />
140
+ <path d="m2 2 20 20" />
141
+ </svg>
142
+ } @else {
143
+ <svg
144
+ viewBox="0 0 24 24"
145
+ fill="none"
146
+ stroke="currentColor"
147
+ stroke-width="2"
148
+ stroke-linecap="round"
149
+ stroke-linejoin="round"
150
+ aria-hidden="true"
151
+ >
152
+ <!-- Lucide eye (ISC) -->
153
+ <path
154
+ d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
155
+ />
156
+ <circle cx="12" cy="12" r="3" />
157
+ </svg>
158
+ }
159
+ </button>
160
+ </div>
161
+ @if (meter()) {
162
+ <div data-slot="meter" class="flex flex-col gap-2" [id]="meterId">
163
+ <div class="flex items-center gap-2.5">
164
+ <div
165
+ aria-hidden="true"
166
+ class="grid flex-1 grid-cols-4 gap-[var(--mimi-password-meter-gap,4px)]"
167
+ >
168
+ @for (bar of bars(); track $index) {
169
+ <span
170
+ class="h-[var(--mimi-password-meter-height,6px)] rounded-full transition-colors duration-300 motion-reduce:transition-none"
171
+ [class]="bar"
172
+ ></span>
173
+ }
174
+ </div>
175
+ <span
176
+ data-slot="level"
177
+ class="min-w-12 text-end text-[13px] font-semibold"
178
+ [class]="levelClass()"
179
+ >{{ levelText() }}</span
180
+ >
181
+ </div>
182
+ @if (activeRules().length) {
183
+ <ul class="m-0 grid list-none grid-cols-2 gap-x-3 gap-y-1 p-0">
184
+ @for (rule of ruleState(); track rule.label) {
185
+ <li
186
+ class="flex items-center gap-1.5 text-[12.5px]"
187
+ [class]="rule.ok ? 'text-success-soft-foreground' : 'text-muted-foreground'"
188
+ [attr.data-state]="rule.ok ? 'met' : 'unmet'"
189
+ >
190
+ @if (rule.ok) {
191
+ <svg
192
+ class="size-3.5 shrink-0"
193
+ viewBox="0 0 24 24"
194
+ fill="none"
195
+ stroke="currentColor"
196
+ stroke-width="2"
197
+ stroke-linecap="round"
198
+ stroke-linejoin="round"
199
+ aria-hidden="true"
200
+ >
201
+ <!-- Lucide circle-check (ISC) -->
202
+ <circle cx="12" cy="12" r="10" />
203
+ <path d="m9 12 2 2 4-4" />
204
+ </svg>
205
+ } @else {
206
+ <svg
207
+ class="size-3.5 shrink-0"
208
+ viewBox="0 0 24 24"
209
+ fill="none"
210
+ stroke="currentColor"
211
+ stroke-width="2"
212
+ stroke-linecap="round"
213
+ stroke-linejoin="round"
214
+ aria-hidden="true"
215
+ >
216
+ <!-- Lucide circle (ISC) -->
217
+ <circle cx="12" cy="12" r="10" />
218
+ </svg>
219
+ }
220
+ {{ rule.label }}
221
+ </li>
222
+ }
223
+ </ul>
224
+ }
225
+ </div>
226
+ }
227
+ `,
228
+ host: {
229
+ class: 'flex w-full min-w-0 flex-col gap-2',
230
+ '[attr.data-state]': 'shown() ? "shown" : "hidden"',
231
+ '[attr.data-level]': 'meter() ? level() : null',
232
+ '(input)': 'read()',
233
+ '(change)': 'read()',
234
+ },
235
+ changeDetection: ChangeDetectionStrategy.OnPush,
236
+ })
237
+ export class MimiPassword {
238
+ private readonly announcer = inject(LiveAnnouncer);
239
+ protected readonly text = inject(MIMI_PASSWORD_TEXT);
240
+
241
+ /** Muestra el medidor (nivel y reglas) debajo del campo. */
242
+ readonly meter = input(false, { transform: booleanAttribute });
243
+ /**
244
+ * Reglas del medidor. Por defecto, las cuatro del diseño. Con `[]` no hay lista y la heurística
245
+ * no tiene qué contar (todo es «débil»): el nivel lo da `strength`.
246
+ */
247
+ readonly rules = input<readonly MimiPasswordRule[] | undefined>(undefined);
248
+ /** Nivel propio (0 a 3). Por defecto, la heurística de las reglas (orientativa). */
249
+ readonly strength = input<((value: string) => MimiPasswordLevel) | undefined>(undefined);
250
+ /** Clases de la caja, mezcladas con `cn()` (gana la del usuario). */
251
+ readonly userClass = input('', { alias: 'class' });
252
+
253
+ private readonly field = contentChild(MimiInput);
254
+ /** El input del usuario: el de mimiInput o, si no lo usa, el primero que haya. */
255
+ private readonly inputElement = computed(
256
+ () => (this.field()?.controlElement as HTMLInputElement | undefined) ?? null,
257
+ );
258
+
259
+ protected readonly shown = signal(false);
260
+ protected readonly value = signal('');
261
+ /** `disabled` escrito en el input (sin formulario). */
262
+ private readonly nativeDisabled = signal(false);
263
+ protected readonly isDisabled = computed(
264
+ () => this.nativeDisabled() || (this.field()?.fieldState.disabled() ?? false),
265
+ );
266
+ protected readonly invalid = computed(() => this.field()?.showInvalid() ?? false);
267
+
268
+ readonly meterId = `mimi-password-meter-${nextId++}`;
269
+
270
+ /** Reglas activas (las del usuario o las cuatro del diseño, con los textos del idioma). */
271
+ protected readonly activeRules = computed<readonly MimiPasswordRule[]>(
272
+ () =>
273
+ this.rules() ?? [
274
+ { label: this.text.minLength, test: (v) => v.length >= 8 },
275
+ { label: this.text.uppercase, test: (v) => /\p{Lu}/u.test(v) },
276
+ { label: this.text.number, test: (v) => /\d/.test(v) },
277
+ { label: this.text.symbol, test: (v) => /[^\p{L}\p{N}\s]/u.test(v) },
278
+ ],
279
+ );
280
+ protected readonly ruleState = computed(() =>
281
+ this.activeRules().map((rule) => ({ label: rule.label, ok: rule.test(this.value()) })),
282
+ );
283
+ readonly level = computed<MimiPasswordLevel>(() => {
284
+ const value = this.value();
285
+ if (!value) return 0;
286
+ const own = this.strength();
287
+ return own ? own(value) : mimiPasswordLevel(value, this.activeRules());
288
+ });
289
+ protected readonly levelText = computed(() => {
290
+ const level = this.level();
291
+ return level === 0 ? '' : [this.text.weak, this.text.medium, this.text.strong][level - 1];
292
+ });
293
+ protected readonly levelClass = computed(() => {
294
+ const level = this.level();
295
+ return level === 0 ? 'text-muted-foreground' : LEVEL_TEXT[level];
296
+ });
297
+ /** Barras llenas como el diseño (`bars()`): débil 1, media 2 o 3 (según las reglas), fuerte 4. */
298
+ protected readonly bars = computed(() => {
299
+ const level = this.level();
300
+ if (level === 0) return [BAR_EMPTY, BAR_EMPTY, BAR_EMPTY, BAR_EMPTY];
301
+ const passed = this.ruleState().filter((r) => r.ok).length;
302
+ const filled = level === 3 ? 4 : level === 2 ? Math.max(2, Math.min(passed, 3)) : 1;
303
+ return [0, 1, 2, 3].map((i) => (i < filled ? BAR_FILL[level] : BAR_EMPTY));
304
+ });
305
+
306
+ protected readonly boxClasses = computed(() => cn(BOX_CLASSES, this.userClass()));
307
+ protected readonly toggleClasses = TOGGLE_CLASSES;
308
+
309
+ /** El último nivel anunciado: solo se anuncia un cambio que viene de escribir. */
310
+ private announced: MimiPasswordLevel = 0;
311
+ private typed = false;
312
+
313
+ constructor() {
314
+ afterNextRender(() => {
315
+ const el = this.inputElement();
316
+ if (!el) return;
317
+ this.value.set(el.value);
318
+ this.announced = this.level();
319
+ this.nativeDisabled.set(el.disabled);
320
+ });
321
+ // El medidor describe el input (sin borrar el aria-describedby del usuario ni el de
322
+ // mimi-form-field).
323
+ afterRenderEffect({
324
+ write: () => {
325
+ const el = this.inputElement();
326
+ if (!el) return;
327
+ const ids = (el.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
328
+ const has = ids.includes(this.meterId);
329
+ if (this.meter() && !has) ids.push(this.meterId);
330
+ if (!this.meter() && has) ids.splice(ids.indexOf(this.meterId), 1);
331
+ if (ids.length) el.setAttribute('aria-describedby', ids.join(' '));
332
+ else el.removeAttribute('aria-describedby');
333
+ },
334
+ });
335
+ inject(DestroyRef).onDestroy(() => {
336
+ const el = this.inputElement();
337
+ if (el) el.type = 'password';
338
+ });
339
+ }
340
+
341
+ /** Muestra u oculta: cambia el `type` del mismo input. El foco se queda en el botón. */
342
+ protected toggle(): void {
343
+ const el = this.inputElement();
344
+ if (!el || this.isDisabled()) return;
345
+ const shown = !this.shown();
346
+ el.type = shown ? 'text' : 'password';
347
+ this.shown.set(shown);
348
+ }
349
+
350
+ /** Lee el valor al escribir y anuncia el nivel solo si cambió. */
351
+ protected read(): void {
352
+ const el = this.inputElement();
353
+ if (!el) return;
354
+ this.typed = true;
355
+ this.value.set(el.value);
356
+ this.nativeDisabled.set(el.disabled);
357
+ const level = this.level();
358
+ if (this.meter() && this.typed && level !== this.announced) {
359
+ this.announced = level;
360
+ if (level > 0) void this.announcer.announce(this.text.announce(this.levelText()), 'polite');
361
+ }
362
+ }
363
+ }
@@ -278,7 +278,10 @@ export class MimiPopover {
278
278
  this.arrowOffset.set(trigger && panel ? mimiArrowOffset(trigger, panel, side) : null);
279
279
  }
280
280
 
281
- /** Al abrir, el foco va al primer elemento enfocable del panel o, si no hay, al panel. */
281
+ /**
282
+ * Al abrir, el foco va al elemento con `autofocus` del panel (el día elegido del calendario,
283
+ * G2.9) o, si no hay, al primer elemento enfocable o al panel.
284
+ */
282
285
  private focusPanel(): void {
283
286
  afterNextRender(
284
287
  {
@@ -286,9 +289,14 @@ export class MimiPopover {
286
289
  const panel = this.panel()?.nativeElement;
287
290
  if (!panel || this.state() !== 'open') return;
288
291
  this.warnMissingLabel();
289
- const first = Array.from(panel.querySelectorAll<HTMLElement>('*')).find(
290
- (element) => this.checker.isFocusable(element) && this.checker.isTabbable(element),
291
- );
292
+ const preferred = panel.querySelector<HTMLElement>('[autofocus]');
293
+ const first =
294
+ preferred && this.checker.isFocusable(preferred)
295
+ ? preferred
296
+ : Array.from(panel.querySelectorAll<HTMLElement>('*')).find(
297
+ (element) =>
298
+ this.checker.isFocusable(element) && this.checker.isTabbable(element),
299
+ );
292
300
  (first ?? panel).focus();
293
301
  },
294
302
  },
@@ -0,0 +1 @@
1
+ export * from './progress';
@@ -0,0 +1,217 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ ElementRef,
5
+ afterRenderEffect,
6
+ computed,
7
+ inject,
8
+ input,
9
+ isDevMode,
10
+ numberAttribute,
11
+ } from '@angular/core';
12
+ import { cn } from '@/components/ui/utils/cn';
13
+
14
+ /*
15
+ * Progress (docs/design/Mimi F5 Estructura.dc.html, «4 · Progress»; prefijo de tokens
16
+ * `progress`, spec 13): barra o círculo, determinado o indeterminado.
17
+ *
18
+ * Rol `progressbar` de WAI-ARIA en el propio elemento (la APG no tiene un patrón propio y
19
+ * @angular/aria no trae uno). No recibe el foco y no anuncia nada por su cuenta: el valor se lee
20
+ * cuando el lector llega a la barra (`aria-valuenow`); el final de la tarea lo avisa la app
21
+ * (decidido el 03/11/2026, plan G2.10).
22
+ */
23
+
24
+ /** Tonos del diseño (spec 4, «Variante y tono»; el diseño escribe `destructive`). */
25
+ export type MimiProgressTone = 'primary' | 'success' | 'warning' | 'info' | 'danger';
26
+ export type MimiProgressShape = 'bar' | 'circle';
27
+ /** Solo el círculo: 76px (`default`) o 44px (`sm`), los del diseño. */
28
+ export type MimiProgressSize = 'default' | 'sm';
29
+
30
+ /*
31
+ * Pista: el token del componente, el global `--mimi-track` y, para un tema anterior a la 0.3.0
32
+ * que no lo tenga, `--mimi-input` (G2.0b; spec 6.2): casi el mismo gris en claro y en oscuro, y
33
+ * sin el `color-mix` de la fórmula, que Lightning CSS duplica en el CSS global. Escrita entera en
34
+ * cada clase (Tailwind solo ve clases literales).
35
+ */
36
+
37
+ /** Color de la parte llena por tono: el token solo cambia el primario (los tonos son globales). */
38
+ const FILL_BG: Record<MimiProgressTone, string> = {
39
+ primary: 'bg-[color:var(--mimi-progress-fill,var(--mimi-primary))]',
40
+ success: 'bg-success',
41
+ warning: 'bg-warning',
42
+ info: 'bg-info',
43
+ danger: 'bg-destructive',
44
+ };
45
+ const FILL_STROKE: Record<MimiProgressTone, string> = {
46
+ primary: 'stroke-[color:var(--mimi-progress-fill,var(--mimi-primary))]',
47
+ success: 'stroke-success',
48
+ warning: 'stroke-warning',
49
+ info: 'stroke-info',
50
+ danger: 'stroke-destructive',
51
+ };
52
+
53
+ const BAR_CLASSES =
54
+ 'relative block w-full h-[var(--mimi-progress-height,8px)] overflow-hidden rounded-full bg-[color:var(--mimi-progress-track,var(--mimi-track,var(--mimi-input)))]';
55
+ const CIRCLE_CLASSES: Record<MimiProgressSize, string> = {
56
+ default:
57
+ 'relative inline-grid shrink-0 place-items-center size-[var(--mimi-progress-circle-size,76px)] text-[16px]',
58
+ // 44px, trazo 4 y texto de 11px: el pequeño del diseño, sin token propio (spec 13).
59
+ sm: 'relative inline-grid shrink-0 place-items-center size-11 text-[11px]',
60
+ };
61
+
62
+ /** Barra determinada: el ancho anima 0,35 s con la curva del diseño. */
63
+ const BAR_FILL =
64
+ 'block h-full rounded-full [transition:width_.35s_cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none';
65
+ /**
66
+ * Barra indeterminada: un tramo del 40 % que cruza (`mimi-indet`). Con movimiento reducido, la
67
+ * barra entera late (la nota del diseño: «late en lugar de desplazarse»).
68
+ */
69
+ const BAR_INDETERMINATE =
70
+ 'absolute inset-y-0 start-0 w-2/5 rounded-full animate-[mimi-indet_1.4s_cubic-bezier(.4,0,.2,1)_infinite] rtl:-scale-x-100 motion-reduce:w-full motion-reduce:animate-mimi-pulse';
71
+
72
+ /** Arco del círculo: el trazo anima 0,4 s; indeterminado gira (con movimiento reducido, late). */
73
+ const ARC =
74
+ 'fill-none [stroke-linecap:round] [transition:stroke-dasharray_.4s_cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none';
75
+ const SPIN = 'animate-[mimi-spin_1s_linear_infinite] motion-reduce:animate-mimi-pulse';
76
+
77
+ /** Grosor del trazo, en unidades del `viewBox` de 36. */
78
+ const STROKE: Record<MimiProgressSize, string> = {
79
+ default: '[stroke-width:var(--mimi-progress-circle-width,3)]',
80
+ sm: '[stroke-width:4]',
81
+ };
82
+
83
+ /**
84
+ * Progress:
85
+ *
86
+ * ```html
87
+ * <mimi-progress aria-label="Subiendo informe.pdf" [value]="64" />
88
+ * <mimi-progress aria-label="Sincronizando" /> <!-- indeterminado -->
89
+ * <mimi-progress aria-label="Ventas" shape="circle" tone="success" [value]="100" />
90
+ * ```
91
+ *
92
+ * `value` sin definir (o `null`) es indeterminado. El valor se acota entre 0 y `max`.
93
+ */
94
+ @Component({
95
+ selector: 'mimi-progress',
96
+ template: `
97
+ @if (shape() === 'bar') {
98
+ @if (determinate()) {
99
+ <span data-slot="fill" [class]="barFill()" [style.width.%]="percent()"></span>
100
+ } @else {
101
+ <span data-slot="fill" [class]="barIndeterminate()"></span>
102
+ }
103
+ } @else {
104
+ <svg aria-hidden="true" viewBox="0 0 36 36" [class]="spinClass()">
105
+ <circle cx="18" cy="18" r="15.915" [class]="trackClass()" />
106
+ <circle
107
+ data-slot="fill"
108
+ cx="18"
109
+ cy="18"
110
+ r="15.915"
111
+ [class]="arcClass()"
112
+ [attr.stroke-dasharray]="dashArray()"
113
+ />
114
+ </svg>
115
+ @if (determinate()) {
116
+ <span aria-hidden="true" class="relative font-bold tabular-nums">{{ circleText() }}</span>
117
+ }
118
+ }
119
+ `,
120
+ host: {
121
+ role: 'progressbar',
122
+ '[attr.aria-label]': 'ariaLabel() || null',
123
+ '[attr.aria-labelledby]': 'ariaLabelledby() || null',
124
+ 'aria-valuemin': '0',
125
+ '[attr.aria-valuemax]': 'safeMax()',
126
+ '[attr.aria-valuenow]': 'determinate() ? clamped() : null',
127
+ '[attr.aria-busy]': 'determinate() ? null : "true"',
128
+ '[attr.data-state]': 'determinate() ? "determinate" : "indeterminate"',
129
+ '[attr.data-tone]': 'tone()',
130
+ '[attr.data-shape]': 'shape()',
131
+ '[attr.data-size]': 'shape() === "circle" ? size() : null',
132
+ '[class]': 'classes()',
133
+ },
134
+ changeDetection: ChangeDetectionStrategy.OnPush,
135
+ })
136
+ export class MimiProgress {
137
+ private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
138
+
139
+ /** El avance, entre 0 y `max`. Sin valor (o `null`), indeterminado. */
140
+ readonly value = input<number | null | undefined, unknown>(undefined, {
141
+ transform: (v: unknown) =>
142
+ v === null || v === undefined || v === '' ? null : numberAttribute(v),
143
+ });
144
+ /** El total (100 por defecto). */
145
+ readonly max = input(100, { transform: numberAttribute });
146
+ readonly tone = input<MimiProgressTone>('primary');
147
+ readonly shape = input<MimiProgressShape>('bar');
148
+ /** Tamaño del círculo. */
149
+ readonly size = input<MimiProgressSize>('default');
150
+ /** Nombre accesible (obligatorio, o `aria-labelledby`). */
151
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
152
+ readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
153
+ /** Clases del elemento con el rol (la pista o el círculo), mezcladas con `cn()`. */
154
+ readonly userClass = input('', { alias: 'class' });
155
+
156
+ /** `max` válido: un número mayor que 0 (si no, 100). */
157
+ protected readonly safeMax = computed(() => {
158
+ const max = this.max();
159
+ return Number.isFinite(max) && max > 0 ? max : 100;
160
+ });
161
+ protected readonly determinate = computed(() => {
162
+ const value = this.value();
163
+ return value !== null && value !== undefined && !Number.isNaN(value);
164
+ });
165
+ /** El valor acotado entre 0 y `max`. */
166
+ protected readonly clamped = computed(() =>
167
+ Math.min(this.safeMax(), Math.max(0, this.value() ?? 0)),
168
+ );
169
+ protected readonly percent = computed(() => (this.clamped() / this.safeMax()) * 100);
170
+ /** En el círculo grande, «64%»; en el pequeño, «64» (como el diseño). */
171
+ protected readonly circleText = computed(() => {
172
+ const n = Math.round(this.percent());
173
+ return this.size() === 'sm' ? `${n}` : `${n}%`;
174
+ });
175
+ protected readonly dashArray = computed(() =>
176
+ this.determinate() ? `${this.percent()} 100` : '28 100',
177
+ );
178
+
179
+ protected readonly classes = computed(() =>
180
+ cn(
181
+ this.shape() === 'bar' ? BAR_CLASSES : CIRCLE_CLASSES[this.size()],
182
+ 'align-middle font-sans',
183
+ this.userClass(),
184
+ ),
185
+ );
186
+ protected readonly barFill = computed(() => cn(BAR_FILL, FILL_BG[this.tone()]));
187
+ protected readonly barIndeterminate = computed(() => cn(BAR_INDETERMINATE, FILL_BG[this.tone()]));
188
+ protected readonly trackClass = computed(() =>
189
+ cn(
190
+ 'fill-none stroke-[color:var(--mimi-progress-track,var(--mimi-track,var(--mimi-input)))]',
191
+ STROKE[this.size()],
192
+ ),
193
+ );
194
+ protected readonly arcClass = computed(() =>
195
+ cn(ARC, FILL_STROKE[this.tone()], STROKE[this.size()]),
196
+ );
197
+ protected readonly spinClass = computed(() =>
198
+ cn('absolute inset-0 size-full -rotate-90', !this.determinate() && SPIN),
199
+ );
200
+
201
+ constructor() {
202
+ if (isDevMode()) afterRenderEffect({ read: () => this.warn() });
203
+ }
204
+
205
+ private warned = false;
206
+ private warn(): void {
207
+ if (this.warned) return;
208
+ if (
209
+ this.host.getAttribute('aria-label')?.trim() ||
210
+ this.host.getAttribute('aria-labelledby')?.trim()
211
+ ) {
212
+ return;
213
+ }
214
+ this.warned = true;
215
+ console.warn('[Mimi] Progress: no tiene aria-label ni aria-labelledby (su nombre accesible).');
216
+ }
217
+ }
@@ -818,9 +818,10 @@ export class MimiSelect<T = unknown> implements FormValueControl<T | null>, Mimi
818
818
  protected readonly clearClasses = CLEAR_CLASSES;
819
819
  protected readonly searchInputClasses = SEARCH_INPUT_CLASSES;
820
820
  protected readonly viewportMargin = MIMI_VIEWPORT_MARGIN;
821
- /** En el lado pedido (debajo, por defecto); si no cabe, en el opuesto. */
821
+ /** En el lado pedido (debajo, por defecto); si no cabe, en el opuesto. Con las tres
822
+ * alineaciones de cada lado (5.17a), sin pasar a los costados. */
822
823
  protected readonly positions = computed(() =>
823
- mimiConnectedPositions(this.side(), this.align(), OFFSET).slice(0, 2),
824
+ mimiConnectedPositions(this.side(), this.align(), OFFSET).slice(0, 6),
824
825
  );
825
826
 
826
827
  protected readonly classes = computed(() => cn(TRIGGER_CLASSES, this.userClass()));
@@ -0,0 +1,2 @@
1
+ export * from './slider';
2
+ export * from './slider-texts';
@@ -0,0 +1,26 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /** Textos de Slider, en archivo propio (como Select, G1.5). */
4
+ export interface MimiSliderText {
5
+ /** Nombre del pulgar de abajo en el rango. */
6
+ minimum: string;
7
+ /** Nombre del pulgar de arriba en el rango. */
8
+ maximum: string;
9
+ }
10
+
11
+ /** Textos predeterminados (inglés). */
12
+ export const MIMI_SLIDER_TEXT_EN: MimiSliderText = { minimum: 'Minimum', maximum: 'Maximum' };
13
+
14
+ /** Textos en español: `provideMimiSliderText(MIMI_SLIDER_TEXT_ES)`. */
15
+ export const MIMI_SLIDER_TEXT_ES: MimiSliderText = { minimum: 'Mínimo', maximum: 'Máximo' };
16
+
17
+ /** Textos activos. Sin `provideMimiSliderText`, los predeterminados en inglés. */
18
+ export const MIMI_SLIDER_TEXT = new InjectionToken<MimiSliderText>('MIMI_SLIDER_TEXT', {
19
+ providedIn: 'root',
20
+ factory: () => MIMI_SLIDER_TEXT_EN,
21
+ });
22
+
23
+ /** Cambia los textos de Slider. Lo que no definas sigue en inglés. */
24
+ export function provideMimiSliderText(text: Partial<MimiSliderText>): Provider {
25
+ return { provide: MIMI_SLIDER_TEXT, useValue: { ...MIMI_SLIDER_TEXT_EN, ...text } };
26
+ }