@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,425 @@
1
+ import { Directionality } from '@angular/cdk/bidi';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ ElementRef,
6
+ booleanAttribute,
7
+ computed,
8
+ inject,
9
+ input,
10
+ model,
11
+ numberAttribute,
12
+ output,
13
+ signal,
14
+ viewChildren,
15
+ } from '@angular/core';
16
+ import type { FormValueControl } from '@angular/forms/signals';
17
+ import { cn } from '@/components/ui/utils/cn';
18
+ import { buttonFocusStyles } from '@/components/ui/utils/control-styles';
19
+ import {
20
+ MIMI_FIELD_CONTROL,
21
+ injectFieldState,
22
+ type MimiFieldControl,
23
+ } from '@/components/ui/utils/field-state';
24
+ import { MIMI_SLIDER_TEXT } from './slider-texts';
25
+
26
+ /*
27
+ * Slider (docs/design/Mimi F3 Slider OTP Numero Mascara.dc.html, «4 · Slider»; prefijo de tokens
28
+ * `slider`, spec 13). Pulgares propios con `role="slider"` (patrones «Slider» y «Slider
29
+ * (Multi-Thumb)» de la APG), como el diseño: decidido por defecto en la G2.7 (opción (c)), porque
30
+ * el spike de la opción (d) —inputs nativos invisibles— necesitaba Firefox y Safari para
31
+ * confirmarse. Teclado, arrastre, RTL y el rango sin cruce los programa Mimi.
32
+ *
33
+ * Un solo `value` (spec 8): un número o, con `range`, `[desde, hasta]`.
34
+ */
35
+
36
+ const optionalNumber = (v: unknown): number | undefined =>
37
+ v === null || v === undefined || v === '' || Number.isNaN(Number(v)) ? undefined : Number(v);
38
+
39
+ const first = (v: MimiSliderValue | undefined): number | undefined =>
40
+ Array.isArray(v) ? v[0] : (v ?? undefined);
41
+
42
+ /** Una marca: un valor y, si se quiere, su texto debajo de la pista. */
43
+ export type MimiSliderMark = number | { value: number; label?: string };
44
+ /** Cuándo se ve la burbuja con el valor. `auto`: al arrastrar y con el foco del teclado. */
45
+ export type MimiSliderShowValue = 'auto' | 'always' | 'never';
46
+ export type MimiSliderValue = number | [number, number] | null;
47
+
48
+ const THUMB_BASE = cn(
49
+ 'absolute top-1/2 box-border -translate-y-1/2 cursor-grab rounded-full border-2',
50
+ 'size-[var(--mimi-slider-thumb-size,20px)] bg-[color:var(--mimi-slider-thumb,var(--mimi-thumb,var(--mimi-background)))]',
51
+ 'shadow-[shadow:var(--mimi-slider-thumb-shadow,var(--mimi-shadow-neutral))]',
52
+ '[transition:box-shadow_.2s_ease,scale_.15s_cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none',
53
+ // Hover y arrastre suman el halo a la sombra del pulgar (su token), con tipo (estilos 8).
54
+ 'not-aria-disabled:hover:shadow-[shadow:0_0_0_6px_var(--mimi-ring-soft),var(--mimi-slider-thumb-shadow,var(--mimi-shadow-neutral))]',
55
+ 'aria-disabled:cursor-not-allowed',
56
+ buttonFocusStyles,
57
+ );
58
+ const DRAGGING =
59
+ 'scale-[1.12] cursor-grabbing shadow-[shadow:0_0_0_8px_var(--mimi-ring-soft),var(--mimi-slider-thumb-shadow,var(--mimi-shadow-neutral))]';
60
+ /** Borde del pulgar y color de la parte llena: el relleno o, con error, `destructive`. */
61
+ const FILL = {
62
+ ok: {
63
+ bg: 'bg-[color:var(--mimi-slider-fill,var(--mimi-track-fill,var(--mimi-primary)))]',
64
+ border: 'border-[color:var(--mimi-slider-fill,var(--mimi-track-fill,var(--mimi-primary)))]',
65
+ },
66
+ error: { bg: 'bg-destructive', border: 'border-destructive' },
67
+ } as const;
68
+
69
+ /**
70
+ * Slider:
71
+ *
72
+ * ```html
73
+ * <mimi-slider aria-label="Volumen" [(value)]="volumen" />
74
+ * <mimi-slider aria-labelledby="precio" range [min]="0" [max]="1000" [step]="10"
75
+ * [format]="moneda" formControlName="precio" /> <!-- [300, 700] -->
76
+ * ```
77
+ */
78
+ @Component({
79
+ selector: 'mimi-slider',
80
+ template: `
81
+ <span
82
+ aria-hidden="true"
83
+ data-slot="track"
84
+ class="absolute inset-x-0 h-[var(--mimi-slider-height,6px)] rounded-full bg-[color:var(--mimi-slider-track,var(--mimi-track,var(--mimi-input)))]"
85
+ ></span>
86
+ <span
87
+ aria-hidden="true"
88
+ data-slot="fill"
89
+ class="absolute h-[var(--mimi-slider-height,6px)] rounded-full"
90
+ [class]="fillClass()"
91
+ [style.inset-inline-start.%]="fillStart()"
92
+ [style.width.%]="fillWidth()"
93
+ ></span>
94
+ @for (mark of markList(); track mark.value) {
95
+ <span
96
+ aria-hidden="true"
97
+ data-slot="mark"
98
+ class="absolute top-1/2 size-1.5 -translate-y-1/2 rounded-full ltr:-translate-x-1/2 rtl:translate-x-1/2"
99
+ [class]="
100
+ mark.inside
101
+ ? 'bg-primary-foreground'
102
+ : 'bg-[color:color-mix(in_oklab,var(--mimi-foreground)_30%,transparent)]'
103
+ "
104
+ [style.inset-inline-start.%]="mark.percent"
105
+ ></span>
106
+ @if (mark.label) {
107
+ <span
108
+ aria-hidden="true"
109
+ data-slot="mark-label"
110
+ class="absolute top-[26px] text-xs tabular-nums ltr:-translate-x-1/2 rtl:translate-x-1/2"
111
+ [class]="mark.current ? 'font-semibold text-foreground' : 'text-muted-foreground'"
112
+ [style.inset-inline-start.%]="mark.percent"
113
+ >{{ mark.label }}</span
114
+ >
115
+ }
116
+ }
117
+ @for (thumb of thumbs(); track $index) {
118
+ <span
119
+ #thumb
120
+ role="slider"
121
+ data-slot="thumb"
122
+ [class]="thumb.classes"
123
+ [style.inset-inline-start.%]="thumb.percent"
124
+ [attr.tabindex]="disabled() ? -1 : 0"
125
+ [attr.aria-label]="thumb.label"
126
+ [attr.aria-labelledby]="range() ? null : ariaLabelledby() || null"
127
+ [attr.aria-valuemin]="thumb.min"
128
+ [attr.aria-valuemax]="thumb.max"
129
+ [attr.aria-valuenow]="thumb.value"
130
+ [attr.aria-valuetext]="thumb.text"
131
+ [attr.aria-orientation]="'horizontal'"
132
+ [attr.aria-disabled]="disabled() || null"
133
+ [attr.aria-invalid]="showInvalid() || null"
134
+ [attr.data-dragging]="dragging() === $index ? '' : null"
135
+ (keydown)="onKeydown($event, $index)"
136
+ (focus)="focused.set($index)"
137
+ (blur)="onThumbBlur()"
138
+ >
139
+ @if (thumb.bubble) {
140
+ <span
141
+ aria-hidden="true"
142
+ data-slot="bubble"
143
+ class="pointer-events-none absolute bottom-[calc(100%+10px)] left-1/2 -translate-x-1/2 rounded-[7px] bg-tooltip px-2 py-1 text-xs font-semibold whitespace-nowrap text-tooltip-foreground tabular-nums"
144
+ >{{ thumb.text
145
+ }}<span
146
+ class="absolute -bottom-[3px] left-1/2 -ml-[3.5px] size-[7px] rotate-45 bg-tooltip"
147
+ ></span
148
+ ></span>
149
+ }
150
+ </span>
151
+ }
152
+ `,
153
+ host: {
154
+ '[class]': 'classes()',
155
+ '[attr.data-range]': 'range() ? "" : null',
156
+ '[attr.data-disabled]': 'disabled() ? "" : null',
157
+ '[attr.data-invalid]': 'showInvalid() ? "" : null',
158
+ '[attr.aria-label]': 'null',
159
+ '[attr.aria-labelledby]': 'null',
160
+ '[attr.role]': 'range() ? "group" : null',
161
+ '(pointerdown)': 'onPointerDown($event)',
162
+ '(pointermove)': 'onPointerMove($event)',
163
+ '(pointerup)': 'onPointerUp($event)',
164
+ '(pointercancel)': 'onPointerUp($event)',
165
+ },
166
+ providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiSlider }],
167
+ changeDetection: ChangeDetectionStrategy.OnPush,
168
+ })
169
+ export class MimiSlider implements FormValueControl<MimiSliderValue>, MimiFieldControl {
170
+ private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
171
+ private readonly dir = inject(Directionality).valueSignal;
172
+ protected readonly text = inject(MIMI_SLIDER_TEXT);
173
+
174
+ /** Un número o, con `range`, `[desde, hasta]`. Con `[(value)]` o un formulario. */
175
+ readonly value = model<MimiSliderValue>(null);
176
+ /** Dos pulgares que no se cruzan; el valor es `[desde, hasta]`. */
177
+ readonly range = input(false, { transform: booleanAttribute });
178
+ /** Mínimo (0 por defecto). También lo escribe Signal Forms desde el esquema. */
179
+ readonly min = input<NonNullable<MimiSliderValue> | undefined, unknown>(undefined, {
180
+ transform: optionalNumber,
181
+ });
182
+ /** Máximo (100 por defecto). */
183
+ readonly max = input<NonNullable<MimiSliderValue> | undefined, unknown>(undefined, {
184
+ transform: optionalNumber,
185
+ });
186
+ readonly step = input(1, { transform: numberAttribute });
187
+ /** Marcas sobre la pista, con su texto debajo si lo tienen. */
188
+ readonly marks = input<readonly MimiSliderMark[]>([]);
189
+ /** El texto del valor: `aria-valuetext` y la burbuja. Por defecto, el número. */
190
+ readonly format = input<(value: number) => string>((value) => String(value));
191
+ readonly showValue = input<MimiSliderShowValue>('auto');
192
+ readonly disabled = input(false, { transform: booleanAttribute });
193
+ /** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
194
+ readonly invalid = input(false, { transform: booleanAttribute });
195
+ readonly touched = input(false, { transform: booleanAttribute });
196
+ readonly dirty = input(false, { transform: booleanAttribute });
197
+ readonly required = input(false, { transform: booleanAttribute });
198
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
199
+ readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
200
+ /** Clases del contenedor, mezcladas con `cn()` (gana la del usuario). */
201
+ readonly userClass = input('', { alias: 'class' });
202
+
203
+ /** Al soltar el pulgar o al salir el foco: marca el control como tocado. */
204
+ readonly touch = output<void>();
205
+
206
+ private readonly thumbEls = viewChildren<ElementRef<HTMLElement>>('thumb');
207
+ readonly fieldState = injectFieldState();
208
+ get controlElement(): HTMLElement {
209
+ return this.thumbEls()[0]?.nativeElement ?? this.host;
210
+ }
211
+ readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
212
+
213
+ protected readonly focused = signal<number | null>(null);
214
+ protected readonly dragging = signal<number | null>(null);
215
+
216
+ // El tipo de `min` y `max` es el del valor (lo exige `FormValueControl`); se usa el número.
217
+ private readonly lo = computed(() => Math.min(first(this.min()) ?? 0, first(this.max()) ?? 100));
218
+ private readonly hi = computed(() => Math.max(first(this.min()) ?? 0, first(this.max()) ?? 100));
219
+ /** Los valores que se pintan (acotados y en orden), sin tocar el modelo. */
220
+ private readonly values = computed<number[]>(() => {
221
+ const value = this.value();
222
+ if (this.range()) {
223
+ const [a, b] = Array.isArray(value) ? value : [this.lo(), this.hi()];
224
+ const x = this.clamp(a);
225
+ const y = this.clamp(b);
226
+ return [Math.min(x, y), Math.max(x, y)];
227
+ }
228
+ const n = Array.isArray(value) ? value[0] : value;
229
+ return [this.clamp(n ?? this.lo())];
230
+ });
231
+ private percentOf(value: number): number {
232
+ const span = this.hi() - this.lo();
233
+ return span > 0 ? ((value - this.lo()) / span) * 100 : 0;
234
+ }
235
+
236
+ /** Nombre del grupo (el aria-label o el texto de la etiqueta), para «Precio, Mínimo». */
237
+ private readonly groupLabel = computed(() => {
238
+ const label = this.ariaLabel()?.trim();
239
+ if (label) return label;
240
+ const ids = this.ariaLabelledby()?.trim();
241
+ if (!ids) return '';
242
+ // Desde la raíz del componente: también funciona fuera del documento (en pruebas).
243
+ const root = this.host.getRootNode() as ParentNode;
244
+ return ids
245
+ .split(/\s+/)
246
+ .map(
247
+ (id) =>
248
+ root.querySelector?.(`[id="${id.replace(/["\\]/g, '\\$&')}"]`)?.textContent?.trim() ?? '',
249
+ )
250
+ .join(' ')
251
+ .trim();
252
+ });
253
+
254
+ protected readonly fillStart = computed(() =>
255
+ this.range() ? this.percentOf(this.values()[0]) : 0,
256
+ );
257
+ protected readonly fillWidth = computed(() => {
258
+ const values = this.values();
259
+ return this.range()
260
+ ? this.percentOf(values[1]) - this.percentOf(values[0])
261
+ : this.percentOf(values[0]);
262
+ });
263
+ private readonly tone = computed(() => FILL[this.showInvalid() ? 'error' : 'ok']);
264
+ protected readonly fillClass = computed(() => this.tone().bg);
265
+
266
+ protected readonly thumbs = computed(() => {
267
+ const values = this.values();
268
+ const range = this.range();
269
+ const show = this.showValue();
270
+ const group = this.groupLabel();
271
+ return values.map((value, i) => {
272
+ const own = range ? (i === 0 ? this.text.minimum : this.text.maximum) : null;
273
+ const active = this.dragging() === i || this.focused() === i;
274
+ return {
275
+ value,
276
+ percent: this.percentOf(value),
277
+ // En el rango, cada pulgar no puede pasar al otro.
278
+ min: range && i === 1 ? values[0] : this.lo(),
279
+ max: range && i === 0 ? values[1] : this.hi(),
280
+ text: this.format()(value),
281
+ label: own ? (group ? `${group}, ${own}` : own) : this.ariaLabel() || null,
282
+ bubble: show === 'always' || (show === 'auto' && active && !this.disabled()),
283
+ classes: cn(
284
+ THUMB_BASE,
285
+ 'ltr:-translate-x-1/2 rtl:translate-x-1/2',
286
+ this.tone().border,
287
+ this.dragging() === i && DRAGGING,
288
+ ),
289
+ };
290
+ });
291
+ });
292
+
293
+ protected readonly markList = computed(() => {
294
+ const values = this.values();
295
+ const range = this.range();
296
+ return this.marks().map((mark) => {
297
+ const value = typeof mark === 'number' ? mark : mark.value;
298
+ const label = typeof mark === 'number' ? '' : (mark.label ?? '');
299
+ const inside = range ? value >= values[0] && value <= values[1] : value <= values[0];
300
+ return {
301
+ value,
302
+ label,
303
+ percent: this.percentOf(this.clamp(value)),
304
+ inside,
305
+ current: values.includes(value),
306
+ };
307
+ });
308
+ });
309
+
310
+ protected readonly classes = computed(() =>
311
+ cn(
312
+ // 24px de alto: todo el anfitrión recibe el puntero (va el pulgar más cercano), así el
313
+ // objetivo táctil cumple 2.5.8 aunque los dos pulgares estén juntos (5.17b).
314
+ 'relative flex h-6 w-full touch-none items-center select-none',
315
+ this.markList().some((m) => m.label) && 'mb-6',
316
+ this.disabled()
317
+ ? 'cursor-not-allowed opacity-[var(--mimi-disabled-opacity,0.5)]'
318
+ : 'cursor-pointer',
319
+ this.userClass(),
320
+ ),
321
+ );
322
+
323
+ protected onKeydown(event: KeyboardEvent, index: number): void {
324
+ if (this.disabled()) return;
325
+ const step = this.step() > 0 ? this.step() : 1;
326
+ const rtl = this.dir() === 'rtl';
327
+ const current = this.values()[index];
328
+ const next: Record<string, number> = {
329
+ ArrowRight: current + (rtl ? -step : step),
330
+ ArrowLeft: current + (rtl ? step : -step),
331
+ ArrowUp: current + step,
332
+ ArrowDown: current - step,
333
+ PageUp: current + step * 10,
334
+ PageDown: current - step * 10,
335
+ Home: this.lo(),
336
+ End: this.hi(),
337
+ };
338
+ if (!(event.key in next)) return;
339
+ event.preventDefault();
340
+ this.setThumb(index, next[event.key]);
341
+ }
342
+
343
+ /** Puntero en el contenedor: mueve el pulgar más cercano, le da el foco y lo arrastra. */
344
+ protected onPointerDown(event: PointerEvent): void {
345
+ if (this.disabled() || event.button !== 0) return;
346
+ const value = this.valueAt(event.clientX);
347
+ if (value === null) return;
348
+ const values = this.values();
349
+ let index = 0;
350
+ if (this.range()) {
351
+ const [a, b] = values;
352
+ index =
353
+ Math.abs(value - a) < Math.abs(value - b)
354
+ ? 0
355
+ : Math.abs(value - a) > Math.abs(value - b)
356
+ ? 1
357
+ : value > b
358
+ ? 1
359
+ : 0;
360
+ }
361
+ event.preventDefault();
362
+ this.dragging.set(index);
363
+ this.host.setPointerCapture?.(event.pointerId);
364
+ this.thumbEls()[index]?.nativeElement.focus();
365
+ this.setThumb(index, value);
366
+ }
367
+
368
+ protected onPointerMove(event: PointerEvent): void {
369
+ const index = this.dragging();
370
+ if (index === null) return;
371
+ const value = this.valueAt(event.clientX);
372
+ if (value !== null) this.setThumb(index, value);
373
+ }
374
+
375
+ protected onPointerUp(event: PointerEvent): void {
376
+ if (this.dragging() === null) return;
377
+ this.dragging.set(null);
378
+ if (this.host.hasPointerCapture?.(event.pointerId)) {
379
+ this.host.releasePointerCapture(event.pointerId);
380
+ }
381
+ this.touch.emit();
382
+ }
383
+
384
+ protected onThumbBlur(): void {
385
+ this.focused.set(null);
386
+ if (this.dragging() === null) this.touch.emit();
387
+ }
388
+
389
+ /** El valor bajo una posición del puntero (con el paso; en RTL, de derecha a izquierda). */
390
+ private valueAt(clientX: number): number | null {
391
+ const rect = this.host.getBoundingClientRect();
392
+ if (!rect.width) return null;
393
+ let ratio = (clientX - rect.left) / rect.width;
394
+ if (this.dir() === 'rtl') ratio = 1 - ratio;
395
+ ratio = Math.min(1, Math.max(0, ratio));
396
+ return this.lo() + ratio * (this.hi() - this.lo());
397
+ }
398
+
399
+ /** Pone un pulgar: redondea al paso, acota y, en el rango, no deja que cruce al otro. */
400
+ private setThumb(index: number, raw: number): void {
401
+ let value = this.clamp(this.roundToStep(raw));
402
+ if (this.range()) {
403
+ const values = [...this.values()] as [number, number];
404
+ value = index === 0 ? Math.min(value, values[1]) : Math.max(value, values[0]);
405
+ values[index] = value;
406
+ const current = this.value();
407
+ if (!Array.isArray(current) || current[0] !== values[0] || current[1] !== values[1]) {
408
+ this.value.set(values);
409
+ }
410
+ return;
411
+ }
412
+ if (this.value() !== value) this.value.set(value);
413
+ }
414
+
415
+ private clamp(value: number): number {
416
+ return Math.min(this.hi(), Math.max(this.lo(), Number.isFinite(value) ? value : this.lo()));
417
+ }
418
+
419
+ private roundToStep(value: number): number {
420
+ const step = this.step() > 0 ? this.step() : 1;
421
+ const decimals = (String(step).split('.')[1] ?? '').length;
422
+ const rounded = this.lo() + Math.round((value - this.lo()) / step) * step;
423
+ return Number(rounded.toFixed(decimals));
424
+ }
425
+ }
@@ -97,37 +97,75 @@ export class MimiTabsOutlet {
97
97
  });
98
98
  }
99
99
 
100
+ /**
101
+ * Orientación de la fila de pestañas (B.10): horizontal (por defecto) o vertical, para una
102
+ * navegación lateral. En vertical, las flechas son ↑/↓ (las pone aria) y el panel va al lado.
103
+ */
104
+ export type MimiTabsOrientation = 'horizontal' | 'vertical';
105
+
100
106
  /** El contenedor visible de las pestañas (recibe el `class` del usuario). */
101
107
  const containerVariants = cva('relative font-sans', {
102
108
  variants: {
103
109
  variant: {
104
- underline: 'border-b border-border',
110
+ underline: 'border-border',
105
111
  // 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]',
112
+ pill: 'rounded-[calc(var(--mimi-radius)_+_4px)] bg-[color:var(--mimi-tabs-pill-bg,var(--mimi-muted))] p-[4px]',
107
113
  },
114
+ orientation: { horizontal: '', vertical: 'self-start' },
108
115
  },
109
- defaultVariants: { variant: 'underline' },
116
+ compoundVariants: [
117
+ { variant: 'underline', orientation: 'horizontal', class: 'border-b' },
118
+ { variant: 'underline', orientation: 'vertical', class: 'border-s' },
119
+ { variant: 'pill', orientation: 'horizontal', class: 'max-w-[520px]' },
120
+ ],
121
+ defaultVariants: { variant: 'underline', orientation: 'horizontal' },
110
122
  });
111
123
 
112
124
  /**
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.
125
+ * El indicador: columnas (o filas) iguales, así que su tamaño es el de una pestaña y se desplaza
126
+ * su propio ancho (o alto) por cada una (sin medir nada). En derecha a izquierda, al revés.
115
127
  */
116
128
  const indicatorVariants = cva(
117
129
  [
118
130
  'pointer-events-none absolute',
119
- 'translate-x-[calc(100%_*_var(--mimi-tabs-index))] rtl:translate-x-[calc(-100%_*_var(--mimi-tabs-index))]',
120
131
  'transition-[translate] duration-[450ms] ease-[var(--mimi-tabs-spring,cubic-bezier(0.3,1.35,0.5,1))] motion-reduce:transition-none',
121
132
  ],
122
133
  {
123
134
  variants: {
124
135
  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',
136
+ underline: 'rounded-[2px] bg-[color:var(--mimi-tabs-indicator,var(--mimi-foreground))]',
137
+ pill: 'rounded-[var(--mimi-radius)] bg-[color:var(--mimi-tabs-pill-active,var(--mimi-card))] shadow-neutral',
138
+ },
139
+ orientation: {
140
+ horizontal:
141
+ 'translate-x-[calc(100%_*_var(--mimi-tabs-index))] rtl:translate-x-[calc(-100%_*_var(--mimi-tabs-index))]',
142
+ vertical: 'translate-y-[calc(100%_*_var(--mimi-tabs-index))]',
128
143
  },
129
144
  },
130
- defaultVariants: { variant: 'underline' },
145
+ compoundVariants: [
146
+ {
147
+ variant: 'underline',
148
+ orientation: 'horizontal',
149
+ class: '-bottom-px start-0 h-0.5 w-[calc(100%_/_var(--mimi-tabs-count))]',
150
+ },
151
+ {
152
+ // La barra va a la izquierda (al inicio), sobre el borde del contenedor.
153
+ variant: 'underline',
154
+ orientation: 'vertical',
155
+ class: '-start-px top-0 w-0.5 h-[calc(100%_/_var(--mimi-tabs-count))]',
156
+ },
157
+ {
158
+ variant: 'pill',
159
+ orientation: 'horizontal',
160
+ class: 'start-[4px] top-[4px] bottom-[4px] w-[calc((100%_-_8px)_/_var(--mimi-tabs-count))]',
161
+ },
162
+ {
163
+ variant: 'pill',
164
+ orientation: 'vertical',
165
+ class: 'start-[4px] end-[4px] top-[4px] h-[calc((100%_-_8px)_/_var(--mimi-tabs-count))]',
166
+ },
167
+ ],
168
+ defaultVariants: { variant: 'underline', orientation: 'horizontal' },
131
169
  },
132
170
  );
133
171
 
@@ -148,8 +186,10 @@ const tabVariants = cva(
148
186
  // 34px sin token (el token de alto es el de la subrayada): plan D6.
149
187
  pill: 'h-[34px] gap-1.5 rounded-[var(--mimi-radius)] text-[13.5px] [&_svg]:size-[15px] [&_svg]:shrink-0',
150
188
  },
189
+ // En vertical, el texto al inicio, como una navegación lateral.
190
+ orientation: { horizontal: '', vertical: 'justify-start px-3 text-start' },
151
191
  },
152
- defaultVariants: { variant: 'underline' },
192
+ defaultVariants: { variant: 'underline', orientation: 'horizontal' },
153
193
  },
154
194
  );
155
195
 
@@ -196,11 +236,12 @@ const COUNT_CLASSES =
196
236
  <div
197
237
  ngTabList
198
238
  data-slot="tablist"
239
+ [orientation]="orientation()"
199
240
  [selectedTab]="painted()"
200
241
  (selectedTabChange)="fromAria($event)"
201
242
  [attr.aria-label]="ariaLabel() || null"
202
243
  [attr.aria-labelledby]="ariaLabelledby() || null"
203
- [class]="tablistClasses"
244
+ [class]="tablistClasses()"
204
245
  (click)="choose($event)"
205
246
  (keydown.enter)="choose($event)"
206
247
  (keydown.space)="choose($event)"
@@ -248,12 +289,19 @@ const COUNT_CLASSES =
248
289
  </div>
249
290
  `,
250
291
  host: {
251
- style: 'display: flex; flex-direction: column; gap: 14px',
292
+ // flex-direction va en su binding (B.10), no aquí.
293
+ style: 'display: flex; gap: 14px',
252
294
  // El class es del contenedor de las pestañas; aria-label y aria-labelledby, del tablist.
253
295
  '[attr.class]': 'null',
254
296
  '[attr.aria-label]': 'null',
255
297
  '[attr.aria-labelledby]': 'null',
256
298
  '[attr.data-variant]': 'variant()',
299
+ '[attr.data-orientation]': 'orientation()',
300
+ // En vertical, las pestañas a un lado y el panel al otro. Bindings sueltos (no [style] con
301
+ // un objeto, que sumaba ɵɵstyleMap al JS inicial: spec 10).
302
+ // Siempre con valor: con null, Angular quita también el flex-direction del style estático.
303
+ '[style.flex-direction]': 'orientation() === "vertical" ? "row" : "column"',
304
+ '[style.align-items]': 'orientation() === "vertical" ? "flex-start" : null',
257
305
  },
258
306
  changeDetection: ChangeDetectionStrategy.OnPush,
259
307
  })
@@ -264,6 +312,8 @@ export class MimiTabs {
264
312
  readonly value = model<string | undefined>(undefined);
265
313
  /** Por defecto, `underline`. */
266
314
  readonly variant = input<MimiTabsVariant>('underline');
315
+ /** Por defecto, `horizontal`. Puede cambiar en vivo (por ejemplo, con el ancho). */
316
+ readonly orientation = input<MimiTabsOrientation>('horizontal');
267
317
  /** Conserva el contenido de los paneles al cambiar de pestaña (por defecto se destruye). */
268
318
  readonly preserveContent = input(false, { transform: booleanAttribute });
269
319
  /** Nombre accesible de la fila de pestañas. */
@@ -298,22 +348,37 @@ export class MimiTabs {
298
348
  this.tabs().findIndex((tab) => tab.value() === this.painted()),
299
349
  );
300
350
 
351
+ private readonly variants = computed(() => ({
352
+ variant: this.variant(),
353
+ orientation: this.orientation(),
354
+ }));
301
355
  protected readonly containerClasses = computed(() =>
302
- cn(containerVariants({ variant: this.variant() }), this.userClass()),
356
+ cn(containerVariants(this.variants()), this.userClass()),
303
357
  );
304
- protected readonly indicatorClasses = computed(() =>
305
- indicatorVariants({ variant: this.variant() }),
306
- );
307
- protected readonly tabClasses = computed(() => tabVariants({ variant: this.variant() }));
358
+ protected readonly indicatorClasses = computed(() => indicatorVariants(this.variants()));
359
+ // Con cn(): la vertical cambia `justify-center` por `justify-start` (sin él, quedaban las dos y
360
+ // ganaba la que va después en el CSS).
361
+ protected readonly tabClasses = computed(() => cn(tabVariants(this.variants())));
308
362
  // 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,
363
+ // Las clases de cada orientación, completas (no variantes encadenadas: selectores largos, G2.1).
364
+ protected readonly tablistClasses = computed(() =>
365
+ cn(
366
+ this.orientation() === 'vertical'
367
+ ? 'grid grid-rows-[repeat(var(--mimi-tabs-count),minmax(0,1fr))]'
368
+ : 'grid grid-cols-[repeat(var(--mimi-tabs-count),minmax(0,1fr))]',
369
+ 'rounded-[inherit]',
370
+ // Con todas deshabilitadas, el foco queda en la fila (aria).
371
+ buttonFocusStyles,
372
+ ),
313
373
  );
314
374
  protected readonly countClasses = COUNT_CLASSES;
315
375
  protected readonly panelClasses = computed(() =>
316
- cn('px-0.5 py-1', buttonFocusStyles, this.panelClass()),
376
+ cn(
377
+ 'px-0.5 py-1',
378
+ this.orientation() === 'vertical' && 'min-w-0 flex-1',
379
+ buttonFocusStyles,
380
+ this.panelClass(),
381
+ ),
317
382
  );
318
383
 
319
384
  /** El último valor que puso Mimi (no viene de fuera). */
@@ -0,0 +1,2 @@
1
+ export * from './tags-input';
2
+ export * from './tags-input-texts';
@@ -0,0 +1,44 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /*
4
+ * Textos de Tags Input, en archivo propio (como Select, G1.5). Los del chip (quitar y los
5
+ * anuncios) están en `chip-texts.ts`.
6
+ */
7
+
8
+ export interface MimiTagsInputText {
9
+ /** Ayuda debajo del campo. */
10
+ help: string;
11
+ /** La etiqueta ya está en la lista. */
12
+ duplicate: (tag: string) => string;
13
+ /** Se llegó al máximo. */
14
+ full: (max: number) => string;
15
+ /** Placeholder con el máximo alcanzado. */
16
+ fullPlaceholder: string;
17
+ }
18
+
19
+ /** Textos predeterminados (inglés). */
20
+ export const MIMI_TAGS_INPUT_TEXT_EN: MimiTagsInputText = {
21
+ help: 'Enter or comma to add · Backspace removes the last one',
22
+ duplicate: (tag) => `“${tag}” is already in the list.`,
23
+ full: (max) => `You reached the limit of ${max}. Remove one to add another.`,
24
+ fullPlaceholder: 'Limit reached',
25
+ };
26
+
27
+ /** Textos en español: `provideMimiTagsInputText(MIMI_TAGS_INPUT_TEXT_ES)`. */
28
+ export const MIMI_TAGS_INPUT_TEXT_ES: MimiTagsInputText = {
29
+ help: 'Enter o coma para agregar · Backspace quita la última',
30
+ duplicate: (tag) => `«${tag}» ya está en la lista.`,
31
+ full: (max) => `Llegaste al límite de ${max} etiquetas. Quita una para agregar otra.`,
32
+ fullPlaceholder: 'Límite alcanzado',
33
+ };
34
+
35
+ /** Textos activos. Sin `provideMimiTagsInputText`, los predeterminados en inglés. */
36
+ export const MIMI_TAGS_INPUT_TEXT = new InjectionToken<MimiTagsInputText>('MIMI_TAGS_INPUT_TEXT', {
37
+ providedIn: 'root',
38
+ factory: () => MIMI_TAGS_INPUT_TEXT_EN,
39
+ });
40
+
41
+ /** Cambia los textos de Tags Input. Lo que no definas sigue en inglés. */
42
+ export function provideMimiTagsInputText(text: Partial<MimiTagsInputText>): Provider {
43
+ return { provide: MIMI_TAGS_INPUT_TEXT, useValue: { ...MIMI_TAGS_INPUT_TEXT_EN, ...text } };
44
+ }