@mimi-ng/cli 0.1.0 → 0.2.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 (64) hide show
  1. package/README.md +19 -1
  2. package/dist/collection.json +1 -1
  3. package/dist/init/css.js +17 -0
  4. package/dist/init/index.js +7 -5
  5. package/dist/init/items.js +9 -0
  6. package/dist/ng-add/index.js +17 -1
  7. package/dist/ng-add/schema.json +27 -0
  8. package/dist/registry.json +222 -1
  9. package/dist/templates/components/avatar/avatar.ts +1 -1
  10. package/dist/templates/components/avatar/avatar.variants.ts +4 -4
  11. package/dist/templates/components/badge/badge.ts +41 -6
  12. package/dist/templates/components/badge/badge.variants.ts +105 -11
  13. package/dist/templates/components/button/button.ts +74 -7
  14. package/dist/templates/components/button/button.variants.ts +145 -21
  15. package/dist/templates/components/card/card.ts +8 -5
  16. package/dist/templates/components/checkbox/checkbox.ts +3 -3
  17. package/dist/templates/components/confirm/confirm-texts.ts +46 -0
  18. package/dist/templates/components/confirm/confirm.ts +190 -0
  19. package/dist/templates/components/confirm/index.ts +2 -0
  20. package/dist/templates/components/context-menu/context-menu.ts +378 -0
  21. package/dist/templates/components/context-menu/index.ts +14 -0
  22. package/dist/templates/components/dialog/dialog-texts.ts +41 -0
  23. package/dist/templates/components/dialog/dialog.ts +701 -0
  24. package/dist/templates/components/dialog/index.ts +18 -0
  25. package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
  26. package/dist/templates/components/dropdown-menu/index.ts +14 -0
  27. package/dist/templates/components/form-field/form-error.ts +1 -1
  28. package/dist/templates/components/form-field/form-field.ts +3 -2
  29. package/dist/templates/components/input/input.variants.ts +6 -4
  30. package/dist/templates/components/menu/index.ts +15 -0
  31. package/dist/templates/components/menu/menu.ts +656 -0
  32. package/dist/templates/components/pagination/index.ts +2 -0
  33. package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
  34. package/dist/templates/components/pagination/pagination-texts.ts +69 -0
  35. package/dist/templates/components/pagination/pagination.ts +443 -0
  36. package/dist/templates/components/popover/index.ts +12 -0
  37. package/dist/templates/components/popover/popover.ts +399 -0
  38. package/dist/templates/components/select/index.ts +12 -0
  39. package/dist/templates/components/select/select-texts.ts +62 -0
  40. package/dist/templates/components/select/select.ts +1085 -0
  41. package/dist/templates/components/separator/separator.variants.ts +10 -7
  42. package/dist/templates/components/skeleton/skeleton.ts +4 -1
  43. package/dist/templates/components/switch/switch.ts +6 -6
  44. package/dist/templates/components/tabs/index.ts +2 -0
  45. package/dist/templates/components/tabs/tabs.ts +426 -0
  46. package/dist/templates/components/textarea/textarea.variants.ts +14 -11
  47. package/dist/templates/components/toast/index.ts +3 -0
  48. package/dist/templates/components/toast/toast-config.ts +45 -0
  49. package/dist/templates/components/toast/toast-texts.ts +46 -0
  50. package/dist/templates/components/toast/toast.ts +1000 -0
  51. package/dist/templates/components/toolbar/index.ts +1 -0
  52. package/dist/templates/components/toolbar/toolbar.ts +377 -0
  53. package/dist/templates/components/tooltip/index.ts +7 -0
  54. package/dist/templates/components/tooltip/tooltip.ts +407 -0
  55. package/dist/templates/theme/provider.ts +62 -6
  56. package/dist/templates/theme/theme-base.css +307 -9
  57. package/dist/templates/theme/types.ts +278 -4
  58. package/dist/templates/utils/cn.ts +15 -1
  59. package/dist/templates/utils/control-styles.ts +2 -2
  60. package/dist/templates/utils/overlay.ts +140 -0
  61. package/dist/ui/index.js +80 -8
  62. package/dist/ui/schema.json +44 -0
  63. package/dist/utils/write-files.js +156 -59
  64. package/package.json +6 -5
@@ -5,15 +5,18 @@ import { cva, type VariantProps } from 'class-variance-authority';
5
5
  * Tailwind las detecte. Línea de 1px con el color --mimi-border. La vertical toma el alto de su
6
6
  * fila flex (align-self: stretch), como en el diseño.
7
7
  */
8
- export const separatorVariants = cva('shrink-0 bg-border', {
9
- variants: {
10
- orientation: {
11
- horizontal: 'block h-px w-full',
12
- vertical: 'w-px self-stretch',
8
+ export const separatorVariants = cva(
9
+ 'shrink-0 bg-[color:var(--mimi-separator-color,var(--mimi-border))]',
10
+ {
11
+ variants: {
12
+ orientation: {
13
+ horizontal: 'block h-[var(--mimi-separator-size,1px)] w-full',
14
+ vertical: 'w-[var(--mimi-separator-size,1px)] self-stretch',
15
+ },
13
16
  },
17
+ defaultVariants: { orientation: 'horizontal' },
14
18
  },
15
- defaultVariants: { orientation: 'horizontal' },
16
- });
19
+ );
17
20
 
18
21
  export type SeparatorOrientation = NonNullable<
19
22
  VariantProps<typeof separatorVariants>['orientation']
@@ -22,6 +22,9 @@ export class MimiSkeleton {
22
22
  readonly userClass = input('', { alias: 'class' });
23
23
 
24
24
  protected readonly classes = computed(() =>
25
- cn('block rounded-sm bg-muted animate-mimi-pulse motion-reduce:animate-none', this.userClass()),
25
+ cn(
26
+ 'block rounded-[var(--mimi-skeleton-radius,var(--mimi-radius-sm))] bg-[color:var(--mimi-skeleton-bg,var(--mimi-muted))] animate-[mimi-pulse_var(--mimi-skeleton-duration,1.6s)_ease-in-out_infinite] motion-reduce:animate-none',
27
+ this.userClass(),
28
+ ),
26
29
  );
27
30
  }
@@ -109,19 +109,19 @@ export class MimiSwitch implements FormCheckboxControl, MimiFieldControl {
109
109
  cn('inline-flex items-center gap-2.5 text-sm', this.userClass()),
110
110
  );
111
111
 
112
- // Pista: 36×20, 2px de padding. Encendida: primary con su sombra.
112
+ // Pista: 36×20 (--mimi-switch-width/-height), 2px de padding. Encendida: primary con su sombra.
113
113
  protected readonly trackClasses = cn(
114
- 'peer group inline-flex h-5 w-9 shrink-0 items-center rounded-full bg-switch-off p-0.5',
114
+ 'peer group inline-flex h-[var(--mimi-switch-height,1.25rem)] w-[var(--mimi-switch-width,2.25rem)] shrink-0 items-center rounded-full bg-[color:var(--mimi-switch-track-off,var(--mimi-switch-off))] p-0.5',
115
115
  'mimi-transition active:scale-(--mimi-press-scale-sm)',
116
- 'data-[state=checked]:bg-primary data-[state=checked]:shadow-primary disabled:shadow-none',
116
+ 'data-[state=checked]:bg-[color:var(--mimi-switch-track-on,var(--mimi-primary))] data-[state=checked]:shadow-primary disabled:shadow-none',
117
117
  buttonFocusStyles,
118
118
  controlDisabledStyles,
119
119
  );
120
120
 
121
- // Pulgar: 16px; se desplaza 16px al encender.
121
+ // Pulgar: alto de la pista menos el padding (16px); al encender se desplaza ancho − alto (16px).
122
122
  protected readonly thumbClasses = cn(
123
- 'pointer-events-none block size-4 rounded-full bg-background shadow-thumb mimi-transition',
124
- 'group-data-[state=checked]:translate-x-4 group-data-[state=checked]:bg-primary-foreground',
123
+ 'pointer-events-none block size-[calc(var(--mimi-switch-height,1.25rem)_-_0.25rem)] rounded-full bg-[color:var(--mimi-switch-thumb,var(--mimi-background))] shadow-thumb mimi-transition',
124
+ 'group-data-[state=checked]:translate-x-[calc(var(--mimi-switch-width,2.25rem)_-_var(--mimi-switch-height,1.25rem))] group-data-[state=checked]:bg-primary-foreground',
125
125
  'group-disabled:shadow-none',
126
126
  );
127
127
 
@@ -0,0 +1,2 @@
1
+ // Solo la API pública: MimiTabsOutlet es interno (se queda en './tabs').
2
+ export { MimiTab, MimiTabLabel, MimiTabs, MimiTabsImports, type MimiTabsVariant } from './tabs';
@@ -0,0 +1,426 @@
1
+ import { Tab, TabContent, TabList, TabPanel, Tabs } from '@angular/aria/tabs';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ DOCUMENT,
6
+ Directive,
7
+ ElementRef,
8
+ TemplateRef,
9
+ ViewContainerRef,
10
+ afterRenderEffect,
11
+ booleanAttribute,
12
+ computed,
13
+ contentChildren,
14
+ effect,
15
+ inject,
16
+ input,
17
+ isDevMode,
18
+ model,
19
+ signal,
20
+ untracked,
21
+ viewChild,
22
+ } from '@angular/core';
23
+ import { cva } from 'class-variance-authority';
24
+ import { cn } from '@/components/ui/utils/cn';
25
+ import { buttonFocusStyles } from '@/components/ui/utils/control-styles';
26
+
27
+ /*
28
+ * Tabs (docs/design/Mimi F2 Toast Tabs Toolbar.dc.html, «3 · Tabs»; prefijo de tokens `tabs`,
29
+ * spec 13): pestañas en columnas iguales, subrayadas o en píldora, con un indicador que se
30
+ * desliza a la elegida.
31
+ *
32
+ * Por dentro usa @angular/aria/tabs (roles, foco itinerante, flechas, Inicio/Fin, contenido
33
+ * diferido), siempre dentro de la plantilla de Mimi: la API es propia. Dos choques con aria
34
+ * contenidos (spec 4, «Base técnica»; plan, G1.5b):
35
+ * - El valor vive en Mimi. aria solo pinta la pestaña elegida y nunca se toma un valor suyo que no
36
+ * coincide con una pestaña (aria vacía su modelo si el valor no tiene pestaña).
37
+ * - Después de la primera interacción, aria ya no mueve el `tabindex="0"` a la elegida. Mimi
38
+ * recrea la fila de pestañas (`ngTabList`) cuando el valor cambia desde fuera o cambian las
39
+ * pestañas, sin el foco dentro: aria vuelve a empezar y la pone en la elegida.
40
+ */
41
+
42
+ /** Cómo se ve: subrayada (por defecto) o en píldora. */
43
+ export type MimiTabsVariant = 'underline' | 'pill';
44
+
45
+ /**
46
+ * Una pestaña: `<ng-template mimiTab="cuenta" label="Cuenta">contenido del panel</ng-template>`.
47
+ * El contenido no se crea hasta que la pestaña se elige.
48
+ */
49
+ @Directive({ selector: 'ng-template[mimiTab]' })
50
+ export class MimiTab {
51
+ /**
52
+ * El valor de la pestaña (único y no vacío). No es `input.required`: las pestañas declaradas en
53
+ * un `@for` se ven antes de recibir su valor, y vacío significa «todavía no» (NG0950).
54
+ */
55
+ readonly value = input('', { alias: 'mimiTab' });
56
+ /** El texto de la pestaña. Con `mimiTabLabel` sin texto visible, es su nombre accesible. */
57
+ readonly label = input('');
58
+ /** Un contador al lado del texto (por ejemplo, avisos sin leer). */
59
+ readonly count = input<number | string | null | undefined>(undefined);
60
+ /** Se puede enfocar con las flechas, pero no elegir. */
61
+ readonly disabled = input(false, { transform: booleanAttribute });
62
+ /** Uso interno: el contenido del panel. */
63
+ readonly template = inject<TemplateRef<unknown>>(TemplateRef);
64
+ }
65
+
66
+ /**
67
+ * Etiqueta propia de una pestaña, con ícono o solo ícono:
68
+ * `<ng-template mimiTabLabel="dia"><svg lucideSun></svg>Día</ng-template>`.
69
+ */
70
+ @Directive({ selector: 'ng-template[mimiTabLabel]' })
71
+ export class MimiTabLabel {
72
+ /** El valor de la pestaña a la que pertenece. */
73
+ readonly value = input('', { alias: 'mimiTabLabel' });
74
+ /** Uso interno: la etiqueta. */
75
+ readonly template = inject<TemplateRef<unknown>>(TemplateRef);
76
+ }
77
+
78
+ /**
79
+ * Uso interno (se exporta porque el compilador lo exige): crea una plantilla del usuario. Hace lo
80
+ * mismo que NgTemplateOutlet, sin @angular/common.
81
+ * @internal No está en el índice; no forma parte de la API de Mimi.
82
+ */
83
+ @Directive({ selector: '[mimiTabsOutlet]' })
84
+ export class MimiTabsOutlet {
85
+ readonly template = input.required<TemplateRef<unknown>>({ alias: 'mimiTabsOutlet' });
86
+ /** Al cambiar, la vista se vuelve a crear (la fila de pestañas: choque 2). */
87
+ readonly key = input<unknown>(undefined, { alias: 'mimiTabsOutletKey' });
88
+ private readonly viewContainer = inject(ViewContainerRef);
89
+
90
+ private readonly render = effect(() => {
91
+ const template = this.template();
92
+ this.key();
93
+ untracked(() => {
94
+ this.viewContainer.clear();
95
+ this.viewContainer.createEmbeddedView(template);
96
+ });
97
+ });
98
+ }
99
+
100
+ /** El contenedor visible de las pestañas (recibe el `class` del usuario). */
101
+ const containerVariants = cva('relative font-sans', {
102
+ variants: {
103
+ variant: {
104
+ underline: 'border-b border-border',
105
+ // 4px fijos (no rem): el indicador resta los mismos 8px.
106
+ pill: 'max-w-[520px] rounded-[calc(var(--mimi-radius)_+_4px)] bg-[color:var(--mimi-tabs-pill-bg,var(--mimi-muted))] p-[4px]',
107
+ },
108
+ },
109
+ defaultVariants: { variant: 'underline' },
110
+ });
111
+
112
+ /**
113
+ * El indicador: columnas iguales, así que su ancho es el de una columna y se desplaza su propio
114
+ * ancho por cada pestaña (sin medir nada). En derecha a izquierda, al revés.
115
+ */
116
+ const indicatorVariants = cva(
117
+ [
118
+ 'pointer-events-none absolute',
119
+ 'translate-x-[calc(100%_*_var(--mimi-tabs-index))] rtl:translate-x-[calc(-100%_*_var(--mimi-tabs-index))]',
120
+ 'transition-[translate] duration-[450ms] ease-[var(--mimi-tabs-spring,cubic-bezier(0.3,1.35,0.5,1))] motion-reduce:transition-none',
121
+ ],
122
+ {
123
+ variants: {
124
+ variant: {
125
+ underline:
126
+ '-bottom-px start-0 h-0.5 w-[calc(100%_/_var(--mimi-tabs-count))] rounded-[2px] bg-[color:var(--mimi-tabs-indicator,var(--mimi-foreground))]',
127
+ pill: 'start-[4px] top-[4px] bottom-[4px] w-[calc((100%_-_8px)_/_var(--mimi-tabs-count))] rounded-[var(--mimi-radius)] bg-[color:var(--mimi-tabs-pill-active,var(--mimi-card))] shadow-neutral',
128
+ },
129
+ },
130
+ defaultVariants: { variant: 'underline' },
131
+ },
132
+ );
133
+
134
+ const tabVariants = cva(
135
+ [
136
+ 'relative z-1 inline-flex min-w-0 cursor-pointer items-center justify-center border-0 bg-transparent whitespace-nowrap',
137
+ 'font-medium text-[color:var(--mimi-tabs-fg,var(--mimi-muted-foreground))] mimi-transition',
138
+ 'aria-selected:font-semibold aria-selected:text-[color:var(--mimi-tabs-active-fg,var(--mimi-foreground))]',
139
+ 'not-aria-disabled:hover:text-[color:var(--mimi-tabs-active-fg,var(--mimi-foreground))] not-aria-disabled:active:scale-(--mimi-press-scale)',
140
+ // Deshabilitada (sin diseño, plan D6): enfocable con las flechas, pero no se elige.
141
+ 'aria-disabled:cursor-not-allowed aria-disabled:opacity-(--mimi-disabled-opacity)',
142
+ buttonFocusStyles,
143
+ ],
144
+ {
145
+ variants: {
146
+ variant: {
147
+ underline: 'h-[var(--mimi-tabs-height,44px)] gap-2 text-sm',
148
+ // 34px sin token (el token de alto es el de la subrayada): plan D6.
149
+ pill: 'h-[34px] gap-1.5 rounded-[var(--mimi-radius)] text-[13.5px] [&_svg]:size-[15px] [&_svg]:shrink-0',
150
+ },
151
+ },
152
+ defaultVariants: { variant: 'underline' },
153
+ },
154
+ );
155
+
156
+ const COUNT_CLASSES =
157
+ 'inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-info-soft px-1.5 text-[11px] font-bold text-info-soft-foreground';
158
+
159
+ /**
160
+ * Pestañas:
161
+ *
162
+ * ```html
163
+ * <mimi-tabs [(value)]="seccion" aria-label="Secciones de la cuenta">
164
+ * <ng-template mimiTab="cuenta" label="Cuenta">Nombre, correo y foto.</ng-template>
165
+ * <ng-template mimiTab="avisos" label="Avisos" [count]="3">…</ng-template>
166
+ * </mimi-tabs>
167
+ * ```
168
+ *
169
+ * Sin valor, se elige la primera habilitada. Un valor sin pestaña (pestañas que llegan después)
170
+ * no se pierde: se muestra la primera habilitada hasta que llega la suya.
171
+ */
172
+ @Component({
173
+ selector: 'mimi-tabs',
174
+ imports: [Tabs, TabList, Tab, TabPanel, TabContent, MimiTabsOutlet],
175
+ template: `
176
+ <div ngTabs class="contents">
177
+ @if (tabs().length) {
178
+ <div
179
+ #list
180
+ data-slot="list"
181
+ [class]="containerClasses()"
182
+ [attr.data-variant]="variant()"
183
+ [style.--mimi-tabs-count]="tabs().length"
184
+ [style.--mimi-tabs-index]="paintedIndex()"
185
+ >
186
+ <!-- Antes de las pestañas: en la píldora, el fondo queda debajo del texto. -->
187
+ <span
188
+ aria-hidden="true"
189
+ data-slot="indicator"
190
+ [class]="indicatorClasses()"
191
+ [hidden]="paintedIndex() < 0"
192
+ ></span>
193
+ <!-- Se recrea (generation) para que aria vuelva a poner el tabindex en la elegida. -->
194
+ <ng-container [mimiTabsOutlet]="row" [mimiTabsOutletKey]="generation()" />
195
+ <ng-template #row>
196
+ <div
197
+ ngTabList
198
+ data-slot="tablist"
199
+ [selectedTab]="painted()"
200
+ (selectedTabChange)="fromAria($event)"
201
+ [attr.aria-label]="ariaLabel() || null"
202
+ [attr.aria-labelledby]="ariaLabelledby() || null"
203
+ [class]="tablistClasses"
204
+ (click)="choose($event)"
205
+ (keydown.enter)="choose($event)"
206
+ (keydown.space)="choose($event)"
207
+ >
208
+ @for (tab of tabs(); track tab) {
209
+ <button
210
+ ngTab
211
+ [value]="tab.value()"
212
+ [disabled]="tab.disabled()"
213
+ [attr.data-value]="tab.value()"
214
+ [attr.data-state]="tab.value() === painted() ? 'active' : 'inactive'"
215
+ [attr.data-disabled]="tab.disabled() ? '' : null"
216
+ [attr.aria-label]="iconOnly().has(tab) ? tab.label() || null : null"
217
+ [class]="tabClasses()"
218
+ >
219
+ @if (labelFor(tab); as label) {
220
+ <span data-slot="label" class="inline-flex min-w-0 items-center gap-[inherit]">
221
+ <ng-container [mimiTabsOutlet]="label" />
222
+ </span>
223
+ } @else {
224
+ <span data-slot="label" class="truncate">{{ tab.label() }}</span>
225
+ }
226
+ @if (tab.count() !== undefined && tab.count() !== null) {
227
+ <span data-slot="count" [class]="countClasses">{{ tab.count() }}</span>
228
+ }
229
+ </button>
230
+ }
231
+ </div>
232
+ </ng-template>
233
+ </div>
234
+ }
235
+ @for (tab of tabs(); track tab) {
236
+ <div
237
+ ngTabPanel
238
+ data-slot="panel"
239
+ [value]="tab.value()"
240
+ [preserveContent]="preserveContent()"
241
+ [hidden]="tab.value() !== painted()"
242
+ [attr.data-state]="tab.value() === painted() ? 'active' : 'inactive'"
243
+ [class]="panelClasses()"
244
+ >
245
+ <ng-template ngTabContent><ng-container [mimiTabsOutlet]="tab.template" /></ng-template>
246
+ </div>
247
+ }
248
+ </div>
249
+ `,
250
+ host: {
251
+ style: 'display: flex; flex-direction: column; gap: 14px',
252
+ // El class es del contenedor de las pestañas; aria-label y aria-labelledby, del tablist.
253
+ '[attr.class]': 'null',
254
+ '[attr.aria-label]': 'null',
255
+ '[attr.aria-labelledby]': 'null',
256
+ '[attr.data-variant]': 'variant()',
257
+ },
258
+ changeDetection: ChangeDetectionStrategy.OnPush,
259
+ })
260
+ export class MimiTabs {
261
+ private readonly document = inject(DOCUMENT);
262
+
263
+ /** La pestaña elegida. Admite `[(value)]`. */
264
+ readonly value = model<string | undefined>(undefined);
265
+ /** Por defecto, `underline`. */
266
+ readonly variant = input<MimiTabsVariant>('underline');
267
+ /** Conserva el contenido de los paneles al cambiar de pestaña (por defecto se destruye). */
268
+ readonly preserveContent = input(false, { transform: booleanAttribute });
269
+ /** Nombre accesible de la fila de pestañas. */
270
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
271
+ readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
272
+ /** Clases del contenedor de las pestañas, mezcladas con `cn()` (gana la del usuario). */
273
+ readonly userClass = input('', { alias: 'class' });
274
+ /** Clases de los paneles. */
275
+ readonly panelClass = input('');
276
+
277
+ protected readonly tabs = contentChildren(MimiTab);
278
+ private readonly labels = contentChildren(MimiTabLabel);
279
+ private readonly list = viewChild<ElementRef<HTMLElement>>('list');
280
+
281
+ /** Sube para recrear el `ngTabList` (choque 2). */
282
+ protected readonly generation = signal(0);
283
+ /** Pestañas con etiqueta propia sin texto visible: `label` es su nombre accesible. */
284
+ protected readonly iconOnly = signal<ReadonlySet<MimiTab>>(new Set());
285
+
286
+ private readonly firstEnabled = computed(() =>
287
+ this.tabs().find((tab) => tab.value() && !tab.disabled()),
288
+ );
289
+
290
+ /** La pestaña que se muestra: la del valor si existe; si no, la primera habilitada. */
291
+ protected readonly painted = computed(() => {
292
+ const value = this.value();
293
+ return value && this.tabs().some((tab) => tab.value() === value)
294
+ ? value
295
+ : this.firstEnabled()?.value();
296
+ });
297
+ protected readonly paintedIndex = computed(() =>
298
+ this.tabs().findIndex((tab) => tab.value() === this.painted()),
299
+ );
300
+
301
+ protected readonly containerClasses = computed(() =>
302
+ cn(containerVariants({ variant: this.variant() }), this.userClass()),
303
+ );
304
+ protected readonly indicatorClasses = computed(() =>
305
+ indicatorVariants({ variant: this.variant() }),
306
+ );
307
+ protected readonly tabClasses = computed(() => tabVariants({ variant: this.variant() }));
308
+ // Sin outline-none: en Tailwind 4 pone --tw-outline-style en none y anula el anillo de foco.
309
+ protected readonly tablistClasses = cn(
310
+ 'grid grid-cols-[repeat(var(--mimi-tabs-count),minmax(0,1fr))] rounded-[inherit]',
311
+ // Con todas deshabilitadas, el foco queda en la fila (aria).
312
+ buttonFocusStyles,
313
+ );
314
+ protected readonly countClasses = COUNT_CLASSES;
315
+ protected readonly panelClasses = computed(() =>
316
+ cn('px-0.5 py-1', buttonFocusStyles, this.panelClass()),
317
+ );
318
+
319
+ /** El último valor que puso Mimi (no viene de fuera). */
320
+ private known: string | undefined;
321
+ private started = false;
322
+
323
+ /** Sin valor, la primera habilitada (al iniciar y si vuelve a undefined). */
324
+ private readonly defaultValue = effect(() => {
325
+ const value = this.value();
326
+ const first = this.firstEnabled()?.value();
327
+ if (value !== undefined || first === undefined) return;
328
+ untracked(() => this.set(first));
329
+ });
330
+
331
+ /** Valor desde fuera, o pestañas que cambian: recrear la fila si el foco no está dentro. */
332
+ private readonly resync = effect(() => {
333
+ const value = this.value();
334
+ const tabs = this.tabs();
335
+ untracked(() => {
336
+ const external = value !== this.known;
337
+ this.known = value;
338
+ if (!this.started) {
339
+ this.started = true;
340
+ this.lastTabs = tabs;
341
+ return;
342
+ }
343
+ if (!external && tabs === this.lastTabs) return;
344
+ this.lastTabs = tabs;
345
+ if (!this.focusInside(tabs)) this.generation.update((n) => n + 1);
346
+ });
347
+ });
348
+ private lastTabs: readonly MimiTab[] | null = null;
349
+
350
+ constructor() {
351
+ afterRenderEffect({
352
+ read: () => {
353
+ const list = this.list()?.nativeElement;
354
+ const tabs = this.tabs();
355
+ if (!list) return;
356
+ // Etiquetas propias sin texto visible (solo ícono): label pasa a ser el nombre.
357
+ const iconOnly = new Set<MimiTab>();
358
+ const buttons = Array.from(list.querySelectorAll<HTMLElement>('[role="tab"]'));
359
+ for (const tab of tabs) {
360
+ if (!this.labelFor(tab)) continue;
361
+ const button = buttons.find((b) => b.getAttribute('data-value') === tab.value());
362
+ const label = button?.querySelector('[data-slot="label"]');
363
+ if (label && !label.textContent?.trim()) iconOnly.add(tab);
364
+ }
365
+ const before = untracked(this.iconOnly);
366
+ if (iconOnly.size !== before.size || [...iconOnly].some((tab) => !before.has(tab))) {
367
+ this.iconOnly.set(iconOnly);
368
+ }
369
+ if (isDevMode()) this.warn(tabs, iconOnly);
370
+ },
371
+ });
372
+ }
373
+
374
+ protected labelFor(tab: MimiTab): TemplateRef<unknown> | undefined {
375
+ return this.labels().find((label) => label.value() === tab.value())?.template;
376
+ }
377
+
378
+ /** Lo que aria elige con el teclado. Solo se toma si es una pestaña habilitada que existe. */
379
+ protected fromAria(value: string | undefined): void {
380
+ const tab = this.tabs().find((t) => t.value() === value);
381
+ if (tab && !tab.disabled()) this.set(tab.value());
382
+ }
383
+
384
+ /**
385
+ * Clic, Enter o Espacio en una pestaña. aria no emite nada si es la que ya pinta (la que se
386
+ * muestra mientras se espera otro valor): Mimi toma la elección.
387
+ */
388
+ protected choose(event: Event): void {
389
+ const button = (event.target as Element | null)?.closest('[role="tab"]');
390
+ const value = button?.getAttribute('data-value');
391
+ const tab = this.tabs().find((t) => t.value() === value);
392
+ if (tab && !tab.disabled()) this.set(tab.value());
393
+ }
394
+
395
+ private set(value: string): void {
396
+ this.known = value;
397
+ this.value.set(value);
398
+ }
399
+
400
+ /** El foco está en una pestaña que sigue existiendo. */
401
+ private focusInside(tabs: readonly MimiTab[]): boolean {
402
+ const active = this.document.activeElement;
403
+ const list = this.list()?.nativeElement;
404
+ if (!active || !list?.contains(active)) return false;
405
+ const focused = active.closest('[role="tab"]')?.getAttribute('data-value');
406
+ return focused === null || focused === undefined || tabs.some((t) => t.value() === focused);
407
+ }
408
+
409
+ private warned = false;
410
+ private warn(tabs: readonly MimiTab[], iconOnly: ReadonlySet<MimiTab>): void {
411
+ if (this.warned) return;
412
+ const values = tabs.map((tab) => tab.value());
413
+ const problems = [
414
+ values.some((value) => !value) && 'una pestaña tiene el valor vacío',
415
+ values.some((value, i) => values.indexOf(value) !== i) && 'hay valores repetidos',
416
+ [...iconOnly].some((tab) => !tab.label()) &&
417
+ 'una pestaña sin texto visible no tiene label (su nombre accesible)',
418
+ ].filter(Boolean);
419
+ if (!problems.length) return;
420
+ this.warned = true;
421
+ console.warn(`[Mimi] Tabs: ${problems.join('; ')}.`);
422
+ }
423
+ }
424
+
425
+ /** Todas las piezas de Tabs, para importarlas juntas. */
426
+ export const MimiTabsImports = [MimiTabs, MimiTab, MimiTabLabel] as const;
@@ -6,22 +6,25 @@ import {
6
6
  } from '@/components/ui/utils/control-styles';
7
7
 
8
8
  /**
9
- * Clases de mimiTextarea (docs/design/Mimi Componentes.dc.html, sección 2). Usa los mismos
10
- * tokens que Input (--mimi-input-*). Con una fila mide lo mismo que Input y Button: alto mínimo
11
- * = alto del control, interlineado de 20px y padding vertical (alto − 20px − 2 bordes) / 2.
9
+ * Clases de mimiTextarea (docs/design/Mimi Componentes.dc.html, sección 2). Tokens propios
10
+ * (--mimi-textarea-*) con respaldo en los de Input (--mimi-input-*): quien ya personalizó Input
11
+ * no ve cambios. Con una fila mide lo mismo que Input y Button: alto mínimo = alto del control,
12
+ * interlineado de 20px y padding vertical (alto − interlineado − 2 bordes) / 2.
12
13
  */
13
14
  export const textareaVariants = cva([
14
- 'block w-full min-w-0 rounded-[var(--mimi-input-radius,var(--mimi-radius,0.75rem))]',
15
- 'border-[length:var(--mimi-input-border-width,1px)] border-input bg-input-background',
16
- 'min-h-[var(--mimi-input-height,var(--mimi-control-height,2.5rem))]',
17
- 'px-[var(--mimi-input-padding-x,0.75rem)]',
18
- 'py-[calc((var(--mimi-input-height,var(--mimi-control-height,2.5rem))_-_20px_-_2*var(--mimi-input-border-width,1px))/2)]',
19
- 'font-sans text-[length:var(--mimi-input-font-size,0.875rem)] leading-5 text-foreground',
15
+ 'block w-full min-w-0 rounded-[var(--mimi-textarea-radius,var(--mimi-input-radius,var(--mimi-radius,0.75rem)))]',
16
+ 'border-[length:var(--mimi-input-border-width,1px)] border-[color:var(--mimi-textarea-border,var(--mimi-input-border,var(--mimi-input)))] bg-[color:var(--mimi-textarea-bg,var(--mimi-input-bg,var(--mimi-input-background)))]',
17
+ 'min-h-[var(--mimi-textarea-min-height,var(--mimi-input-height,var(--mimi-control-height,2.5rem)))]',
18
+ 'px-[var(--mimi-input-px,var(--mimi-input-padding-x,0.75rem))]',
19
+ 'py-[var(--mimi-textarea-py,calc((var(--mimi-textarea-min-height,var(--mimi-input-height,var(--mimi-control-height,2.5rem)))_-_var(--mimi-textarea-line-height,1.25rem)_-_2*var(--mimi-input-border-width,1px))/2))]',
20
+ 'font-sans text-[length:var(--mimi-input-font-size,0.875rem)] leading-[var(--mimi-textarea-line-height,1.25rem)] text-[color:var(--mimi-input-fg,var(--mimi-foreground))]',
20
21
  'resize-y mimi-transition disabled:resize-none',
21
- 'placeholder:text-[color:var(--mimi-input-placeholder-color,var(--mimi-muted-foreground))]',
22
+ 'placeholder:text-[color:var(--mimi-input-placeholder,var(--mimi-input-placeholder-color,var(--mimi-muted-foreground)))]',
22
23
  fieldFocusStyles,
24
+ 'focus-visible:border-[color:var(--mimi-input-border-focus,var(--mimi-ring))]',
23
25
  'focus-visible:ring-[length:var(--mimi-input-focus-ring-width,3px)]',
24
26
  controlDisabledStyles,
25
- 'disabled:opacity-[var(--mimi-input-disabled-opacity,0.5)]',
27
+ 'disabled:opacity-[var(--mimi-input-disabled-opacity,var(--mimi-disabled-opacity,0.5))]',
26
28
  controlInvalidStyles,
29
+ 'aria-invalid:border-[color:var(--mimi-input-error,var(--mimi-destructive))] aria-invalid:focus-visible:border-[color:var(--mimi-input-error,var(--mimi-destructive))]',
27
30
  ]);
@@ -0,0 +1,3 @@
1
+ export * from './toast';
2
+ export * from './toast-config';
3
+ export * from './toast-texts';
@@ -0,0 +1,45 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /*
4
+ * Opciones globales de Toast, en archivo propio y sin el componente: así `provideMimiToast()` se
5
+ * puede poner en `app.config.ts` sin llevar Toast al chunk inicial.
6
+ */
7
+
8
+ /** Dónde aparece la pila. El diseño solo muestra `bottom-right`. */
9
+ export type MimiToastPosition =
10
+ 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
11
+
12
+ /** Opciones de todos los toasts de la aplicación. */
13
+ export interface MimiToastConfig {
14
+ /** Por defecto, `bottom-right`. */
15
+ position: MimiToastPosition;
16
+ /** Cuánto dura un toast sin acción, en milisegundos. Por defecto, 6000. */
17
+ duration: number;
18
+ /** Tecla (`KeyboardEvent.key`) que lleva el foco a los toasts. Por defecto, `F8`. */
19
+ hotkey: string;
20
+ }
21
+
22
+ export const MIMI_TOAST_DEFAULTS: MimiToastConfig = {
23
+ position: 'bottom-right',
24
+ duration: 6000,
25
+ hotkey: 'F8',
26
+ };
27
+
28
+ /** Opciones activas. Sin `provideMimiToast`, las predeterminadas. */
29
+ export const MIMI_TOAST_CONFIG = new InjectionToken<MimiToastConfig>('MIMI_TOAST_CONFIG', {
30
+ providedIn: 'root',
31
+ factory: () => MIMI_TOAST_DEFAULTS,
32
+ });
33
+
34
+ /**
35
+ * Cambia las opciones de todos los toasts. Lo que no definas queda como está:
36
+ *
37
+ * ```ts
38
+ * provideMimiToast({ position: 'top-center' })
39
+ * ```
40
+ *
41
+ * Va en `app.config.ts`: los toasts viven en la raíz de la aplicación.
42
+ */
43
+ export function provideMimiToast(config: Partial<MimiToastConfig>): Provider {
44
+ return { provide: MIMI_TOAST_CONFIG, useValue: { ...MIMI_TOAST_DEFAULTS, ...config } };
45
+ }
@@ -0,0 +1,46 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /*
4
+ * Textos de Toast, en archivo propio y sin el componente ni @angular/cdk: así se pueden proveer
5
+ * en `app.config.ts` sin llevar Toast al chunk inicial.
6
+ */
7
+
8
+ /** Textos que Toast anuncia. */
9
+ export interface MimiToastText {
10
+ /** Nombre de la región de los toasts, con la tecla que lleva el foco a ellos. */
11
+ region: (hotkey: string) => string;
12
+ /** Nombre accesible del botón de cerrar (la X). */
13
+ close: string;
14
+ }
15
+
16
+ /** Textos predeterminados (inglés). */
17
+ export const MIMI_TOAST_TEXT_EN: MimiToastText = {
18
+ region: (hotkey) => `Notifications (${hotkey})`,
19
+ close: 'Close notification',
20
+ };
21
+
22
+ /** Textos en español: `provideMimiToastText(MIMI_TOAST_TEXT_ES)`. */
23
+ export const MIMI_TOAST_TEXT_ES: MimiToastText = {
24
+ region: (hotkey) => `Notificaciones (${hotkey})`,
25
+ close: 'Cerrar notificación',
26
+ };
27
+
28
+ /** Textos activos. Sin `provideMimiToastText`, los predeterminados en inglés. */
29
+ export const MIMI_TOAST_TEXT = new InjectionToken<MimiToastText>('MIMI_TOAST_TEXT', {
30
+ providedIn: 'root',
31
+ factory: () => MIMI_TOAST_TEXT_EN,
32
+ });
33
+
34
+ /**
35
+ * Cambia los textos de Toast. Lo que no definas sigue en inglés:
36
+ *
37
+ * ```ts
38
+ * provideMimiToastText(MIMI_TOAST_TEXT_ES)
39
+ * ```
40
+ *
41
+ * El nombre de la región se lee en la raíz: ponlo en `app.config.ts`. El de la X se lee donde se
42
+ * llama a `injectMimiToast()`, así que también sirve en los `providers` de una ruta o componente.
43
+ */
44
+ export function provideMimiToastText(text: Partial<MimiToastText>): Provider {
45
+ return { provide: MIMI_TOAST_TEXT, useValue: { ...MIMI_TOAST_TEXT_EN, ...text } };
46
+ }