@mimi-ng/cli 0.1.1 → 0.3.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 +28 -1
- package/dist/init/css.js +17 -0
- package/dist/init/index.js +7 -5
- package/dist/init/items.js +9 -0
- package/dist/registry.json +482 -1
- package/dist/templates/components/avatar/avatar.ts +1 -1
- package/dist/templates/components/avatar/avatar.variants.ts +4 -4
- package/dist/templates/components/badge/badge.ts +41 -6
- package/dist/templates/components/badge/badge.variants.ts +105 -11
- package/dist/templates/components/button/button.ts +74 -7
- package/dist/templates/components/button/button.variants.ts +145 -21
- 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/card/card.ts +8 -5
- package/dist/templates/components/checkbox/checkbox.ts +3 -3
- 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/confirm/confirm-texts.ts +46 -0
- package/dist/templates/components/confirm/confirm.ts +190 -0
- package/dist/templates/components/confirm/index.ts +2 -0
- package/dist/templates/components/context-menu/context-menu.ts +378 -0
- package/dist/templates/components/context-menu/index.ts +14 -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/dialog/dialog-texts.ts +41 -0
- package/dist/templates/components/dialog/dialog.ts +701 -0
- package/dist/templates/components/dialog/index.ts +18 -0
- package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
- package/dist/templates/components/dropdown-menu/index.ts +14 -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/form-field/form-error.ts +1 -1
- package/dist/templates/components/form-field/form-field.ts +3 -2
- package/dist/templates/components/input/input.variants.ts +6 -4
- 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/menu/index.ts +15 -0
- package/dist/templates/components/menu/menu.ts +656 -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/pagination/index.ts +2 -0
- package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
- package/dist/templates/components/pagination/pagination-texts.ts +69 -0
- package/dist/templates/components/pagination/pagination.ts +443 -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/index.ts +12 -0
- package/dist/templates/components/popover/popover.ts +407 -0
- package/dist/templates/components/progress/index.ts +1 -0
- package/dist/templates/components/progress/progress.ts +217 -0
- package/dist/templates/components/select/index.ts +12 -0
- package/dist/templates/components/select/select-texts.ts +62 -0
- package/dist/templates/components/select/select.ts +1086 -0
- package/dist/templates/components/separator/separator.variants.ts +10 -7
- package/dist/templates/components/skeleton/skeleton.ts +4 -1
- 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/switch/switch.ts +6 -6
- package/dist/templates/components/tabs/index.ts +2 -0
- package/dist/templates/components/tabs/tabs.ts +426 -0
- 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/textarea/textarea.variants.ts +14 -11
- package/dist/templates/components/toast/index.ts +3 -0
- package/dist/templates/components/toast/toast-config.ts +45 -0
- package/dist/templates/components/toast/toast-texts.ts +46 -0
- package/dist/templates/components/toast/toast.ts +1000 -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/components/toolbar/index.ts +1 -0
- package/dist/templates/components/toolbar/toolbar.ts +377 -0
- package/dist/templates/components/tooltip/index.ts +7 -0
- package/dist/templates/components/tooltip/tooltip.ts +407 -0
- package/dist/templates/theme/provider.ts +75 -6
- package/dist/templates/theme/theme-base.css +410 -9
- package/dist/templates/theme/types.ts +494 -4
- package/dist/templates/utils/cn.ts +15 -1
- package/dist/templates/utils/control-styles.ts +2 -2
- package/dist/templates/utils/overlay.ts +153 -0
- package/dist/ui/index.js +184 -8
- package/dist/ui/schema.json +100 -0
- package/dist/utils/write-files.js +166 -59
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -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
|
+
}
|