@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.
- package/README.md +35 -2
- package/dist/init/css.js +1 -1
- package/dist/init/index.js +25 -0
- package/dist/init/paths.js +20 -0
- package/dist/registry.json +385 -14
- package/dist/templates/blocks/login-01/index.ts +1 -0
- package/dist/templates/blocks/login-01/login-01.html +174 -0
- package/dist/templates/blocks/login-01/login-01.ts +105 -0
- package/dist/templates/blocks/login-02/index.ts +1 -0
- package/dist/templates/blocks/login-02/login-02.html +186 -0
- package/dist/templates/blocks/login-02/login-02.ts +92 -0
- package/dist/templates/blocks/otp-01/index.ts +1 -0
- package/dist/templates/blocks/otp-01/otp-01.html +166 -0
- package/dist/templates/blocks/otp-01/otp-01.ts +212 -0
- package/dist/templates/blocks/pricing-01/index.ts +1 -0
- package/dist/templates/blocks/pricing-01/pricing-01.html +173 -0
- package/dist/templates/blocks/pricing-01/pricing-01.ts +178 -0
- package/dist/templates/blocks/settings-01/index.ts +1 -0
- package/dist/templates/blocks/settings-01/settings-01.html +424 -0
- package/dist/templates/blocks/settings-01/settings-01.ts +382 -0
- package/dist/templates/blocks/signup-01/index.ts +1 -0
- package/dist/templates/blocks/signup-01/signup-01.html +332 -0
- package/dist/templates/blocks/signup-01/signup-01.ts +176 -0
- package/dist/templates/components/calendar/calendar-texts.ts +72 -0
- package/dist/templates/components/calendar/calendar.ts +588 -0
- package/dist/templates/components/calendar/dates.ts +181 -0
- package/dist/templates/components/calendar/index.ts +3 -0
- package/dist/templates/components/chip/chip-texts.ts +40 -0
- package/dist/templates/components/chip/chip.ts +116 -0
- package/dist/templates/components/chip/index.ts +2 -0
- package/dist/templates/components/color-picker/color-picker-texts.ts +57 -0
- package/dist/templates/components/color-picker/color-picker.ts +718 -0
- package/dist/templates/components/color-picker/color.ts +157 -0
- package/dist/templates/components/color-picker/index.ts +3 -0
- package/dist/templates/components/combobox/combobox-texts.ts +70 -0
- package/dist/templates/components/combobox/combobox.ts +798 -0
- package/dist/templates/components/combobox/index.ts +2 -0
- package/dist/templates/components/date-picker/date-picker.ts +240 -0
- package/dist/templates/components/date-picker/index.ts +3 -0
- package/dist/templates/components/date-range/date-range.ts +308 -0
- package/dist/templates/components/date-range/index.ts +1 -0
- package/dist/templates/components/file-upload/file-upload-texts.ts +77 -0
- package/dist/templates/components/file-upload/file-upload.ts +698 -0
- package/dist/templates/components/file-upload/index.ts +2 -0
- package/dist/templates/components/input-mask/index.ts +1 -0
- package/dist/templates/components/input-mask/input-mask.ts +319 -0
- package/dist/templates/components/input-otp/index.ts +1 -0
- package/dist/templates/components/input-otp/input-otp.ts +212 -0
- package/dist/templates/components/number-input/index.ts +2 -0
- package/dist/templates/components/number-input/number-input-texts.ts +46 -0
- package/dist/templates/components/number-input/number-input.ts +450 -0
- package/dist/templates/components/option-list/index.ts +11 -0
- package/dist/templates/components/option-list/option-list.ts +518 -0
- package/dist/templates/components/password/index.ts +2 -0
- package/dist/templates/components/password/password-texts.ts +68 -0
- package/dist/templates/components/password/password.ts +363 -0
- package/dist/templates/components/popover/popover.ts +12 -4
- package/dist/templates/components/progress/index.ts +1 -0
- package/dist/templates/components/progress/progress.ts +217 -0
- package/dist/templates/components/select/select.ts +3 -2
- package/dist/templates/components/slider/index.ts +2 -0
- package/dist/templates/components/slider/slider-texts.ts +26 -0
- package/dist/templates/components/slider/slider.ts +425 -0
- package/dist/templates/components/tabs/tabs.ts +88 -23
- package/dist/templates/components/tags-input/index.ts +2 -0
- package/dist/templates/components/tags-input/tags-input-texts.ts +44 -0
- package/dist/templates/components/tags-input/tags-input.ts +303 -0
- package/dist/templates/components/toggle-group/index.ts +1 -0
- package/dist/templates/components/toggle-group/toggle-group.ts +406 -0
- package/dist/templates/theme/provider.ts +13 -0
- package/dist/templates/theme/theme-base.css +103 -0
- package/dist/templates/theme/types.ts +216 -0
- package/dist/templates/utils/overlay.ts +17 -4
- package/dist/ui/index.js +232 -10
- package/dist/ui/schema.json +82 -2
- package/dist/utils/project.js +35 -0
- package/dist/utils/tailwind.js +80 -0
- package/dist/utils/write-files.js +49 -9
- 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-
|
|
110
|
+
underline: 'border-border',
|
|
105
111
|
// 4px fijos (no rem): el indicador resta los mismos 8px.
|
|
106
|
-
pill: '
|
|
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
|
-
|
|
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
|
|
114
|
-
* ancho por cada
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
356
|
+
cn(containerVariants(this.variants()), this.userClass()),
|
|
303
357
|
);
|
|
304
|
-
protected readonly indicatorClasses = computed(() =>
|
|
305
|
-
|
|
306
|
-
)
|
|
307
|
-
protected readonly tabClasses = computed(() => tabVariants(
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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(
|
|
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,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
|
+
}
|