@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 @@
|
|
|
1
|
+
export * from './toggle-group';
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { FocusKeyManager, type FocusableOption } from '@angular/cdk/a11y';
|
|
2
|
+
import { Directionality } from '@angular/cdk/bidi';
|
|
3
|
+
import {
|
|
4
|
+
ChangeDetectionStrategy,
|
|
5
|
+
Component,
|
|
6
|
+
DestroyRef,
|
|
7
|
+
Directive,
|
|
8
|
+
ElementRef,
|
|
9
|
+
Injector,
|
|
10
|
+
afterRenderEffect,
|
|
11
|
+
booleanAttribute,
|
|
12
|
+
computed,
|
|
13
|
+
contentChildren,
|
|
14
|
+
effect,
|
|
15
|
+
inject,
|
|
16
|
+
input,
|
|
17
|
+
isDevMode,
|
|
18
|
+
model,
|
|
19
|
+
output,
|
|
20
|
+
signal,
|
|
21
|
+
untracked,
|
|
22
|
+
} from '@angular/core';
|
|
23
|
+
import type { FormValueControl } from '@angular/forms/signals';
|
|
24
|
+
import { cva } from 'class-variance-authority';
|
|
25
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
26
|
+
import { buttonFocusStyles } from '@/components/ui/utils/control-styles';
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* Toggle Group (docs/design/Mimi F3 Archivos Tags Toggle Color.dc.html, «11 · Toggle Group ·
|
|
30
|
+
* control segmentado»; prefijo de tokens `toggle-group`, spec 13).
|
|
31
|
+
*
|
|
32
|
+
* Simple: patrón «Radio Group» de la APG (`radiogroup` y `radio`, una sola parada de Tab, las
|
|
33
|
+
* flechas mueven el foco y eligen, con vuelta). Múltiple: botones de alternancia (`aria-pressed`),
|
|
34
|
+
* cada uno con su parada de Tab. El foco itinerante usa el FocusKeyManager de @angular/cdk/a11y
|
|
35
|
+
* (como mimi-toolbar, G1.10): @angular/aria no tiene un grupo de radio propio (plan, G2.1).
|
|
36
|
+
*
|
|
37
|
+
* Un solo `value` (spec 8, «Valor simple o múltiple»): `T | null` en simple y `T[]` con
|
|
38
|
+
* `multiple`. Los botones son del usuario y nativos: Espacio y el clic funcionan sin rodeos.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** `default`: el elegido sobre `card`. `solid`: el elegido en `primary` (el diseño la llama así). */
|
|
42
|
+
export type MimiToggleGroupVariant = 'default' | 'solid';
|
|
43
|
+
|
|
44
|
+
const groupVariants = cva(
|
|
45
|
+
[
|
|
46
|
+
'relative isolate items-stretch align-middle font-sans',
|
|
47
|
+
'gap-[var(--mimi-toggle-group-gap,2px)] p-[var(--mimi-toggle-group-padding,3px)]',
|
|
48
|
+
'rounded-[calc(var(--mimi-toggle-group-radius,var(--mimi-radius))_+_var(--mimi-toggle-group-padding,3px))]',
|
|
49
|
+
'bg-[color:var(--mimi-toggle-group-bg,var(--mimi-muted))] mimi-transition',
|
|
50
|
+
// Deshabilitado: el grupo entero (el diseño no tiene ítems deshabilitados de a uno).
|
|
51
|
+
'data-disabled:cursor-not-allowed data-disabled:opacity-(--mimi-disabled-opacity)',
|
|
52
|
+
// Error del formulario (sin diseño, plan D6): un borde de 1px como el de Input.
|
|
53
|
+
'data-invalid:ring-1 data-invalid:ring-destructive',
|
|
54
|
+
],
|
|
55
|
+
{
|
|
56
|
+
variants: {
|
|
57
|
+
// Simple: columnas iguales (el indicador se desplaza su propio ancho). Múltiple: a lo ancho
|
|
58
|
+
// de cada ítem, como el diseño («Texto e ícono · múltiple»).
|
|
59
|
+
multiple: {
|
|
60
|
+
false: 'inline-grid grid-flow-col auto-cols-fr',
|
|
61
|
+
true: 'inline-flex',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
defaultVariants: { multiple: false },
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/*
|
|
69
|
+
* Las clases de cada estado se eligen en TypeScript (no con `data-[…]:` encadenados) y la
|
|
70
|
+
* geometría del indicador va en `style`: el mismo aspecto con menos CSS global (G2.1, spec 10).
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/** Fondo y sombra del elegido: el indicador (simple) o cada botón (múltiple). */
|
|
74
|
+
const SELECTED_SURFACE: Record<MimiToggleGroupVariant, string> = {
|
|
75
|
+
default:
|
|
76
|
+
'bg-[color:var(--mimi-toggle-group-indicator,var(--mimi-card))] shadow-[shadow:var(--mimi-toggle-group-indicator-shadow,var(--mimi-shadow-neutral))]',
|
|
77
|
+
solid: 'bg-primary shadow-primary',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** El indicador de la selección simple (su posición y su ancho van en `style`). */
|
|
81
|
+
const INDICATOR_CLASSES =
|
|
82
|
+
'pointer-events-none absolute -z-1 rounded-[var(--mimi-toggle-group-radius,var(--mimi-radius))] transition-[translate,background-color] duration-[450ms] motion-reduce:transition-none';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Posición del indicador: a un relleno del borde; ancho de una columna, (100% − 2 × relleno −
|
|
86
|
+
* (n − 1) × separación) / n; se desplaza un ancho más una separación por ítem (en RTL, al revés).
|
|
87
|
+
*/
|
|
88
|
+
const PAD = 'var(--mimi-toggle-group-padding, 3px)';
|
|
89
|
+
const GAP = 'var(--mimi-toggle-group-gap, 2px)';
|
|
90
|
+
const INDICATOR_STYLE = {
|
|
91
|
+
top: PAD,
|
|
92
|
+
bottom: PAD,
|
|
93
|
+
start: PAD,
|
|
94
|
+
width: `calc((100% - 2 * ${PAD} - (var(--mimi-toggle-group-count) - 1) * ${GAP}) / var(--mimi-toggle-group-count))`,
|
|
95
|
+
easing: 'var(--mimi-toggle-group-spring, cubic-bezier(.3, 1.35, .5, 1))',
|
|
96
|
+
} as const;
|
|
97
|
+
|
|
98
|
+
const ITEM_CLASSES = cn(
|
|
99
|
+
'relative inline-flex min-w-[var(--mimi-toggle-group-icon-width,44px)] cursor-pointer items-center justify-center gap-1.5 border-0 bg-transparent whitespace-nowrap',
|
|
100
|
+
'h-[var(--mimi-toggle-group-item-height,32px)] px-[var(--mimi-toggle-group-item-px,12px)]',
|
|
101
|
+
'rounded-[var(--mimi-toggle-group-radius,var(--mimi-radius))]',
|
|
102
|
+
'text-[length:var(--mimi-toggle-group-font-size,13.5px)] font-medium text-[color:var(--mimi-toggle-group-fg,var(--mimi-muted-foreground))]',
|
|
103
|
+
'[&_svg]:size-4 [&_svg]:shrink-0',
|
|
104
|
+
'mimi-transition enabled:hover:text-[color:var(--mimi-toggle-group-active-fg,var(--mimi-foreground))]',
|
|
105
|
+
// 0,9 (con movimiento reducido, 1); el diseño usa 0,93 sin token: plan D6.
|
|
106
|
+
'enabled:active:scale-(--mimi-press-scale-sm) disabled:cursor-not-allowed',
|
|
107
|
+
buttonFocusStyles,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/** Elegido: más peso y el color activo; en `solid`, el texto sobre primary. */
|
|
111
|
+
const ON_TEXT: Record<MimiToggleGroupVariant, string> = {
|
|
112
|
+
default: 'font-semibold text-[color:var(--mimi-toggle-group-active-fg,var(--mimi-foreground))]',
|
|
113
|
+
solid: 'font-semibold text-primary-foreground enabled:hover:text-primary-foreground',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Un botón del grupo: `<button mimiToggleGroupItem="semanal">Semanal</button>`. El contenido
|
|
118
|
+
* (texto, ícono o los dos) y el `aria-label` (si no tiene texto) son del usuario.
|
|
119
|
+
*/
|
|
120
|
+
@Directive({
|
|
121
|
+
selector: 'button[mimiToggleGroupItem]',
|
|
122
|
+
host: {
|
|
123
|
+
type: 'button',
|
|
124
|
+
'[attr.role]': 'group.multiple() ? null : "radio"',
|
|
125
|
+
'[attr.aria-checked]': 'group.multiple() ? null : on()',
|
|
126
|
+
'[attr.aria-pressed]': 'group.multiple() ? on() : null',
|
|
127
|
+
'[attr.tabindex]': 'tabIndex()',
|
|
128
|
+
'[disabled]': 'group.disabled()',
|
|
129
|
+
'[attr.data-state]': 'on() ? "on" : "off"',
|
|
130
|
+
'[attr.data-variant]': 'group.variant()',
|
|
131
|
+
'[attr.data-multiple]': 'group.multiple() ? "" : null',
|
|
132
|
+
'[attr.data-disabled]': 'group.disabled() ? "" : null',
|
|
133
|
+
'[class]': 'classes()',
|
|
134
|
+
'(click)': 'group.select(this)',
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
export class MimiToggleGroupItem implements FocusableOption {
|
|
138
|
+
/** El valor del botón (único en el grupo). */
|
|
139
|
+
readonly value = input<unknown>(undefined, { alias: 'mimiToggleGroupItem' });
|
|
140
|
+
/** Clases del botón, mezcladas con `cn()` (gana la del usuario). */
|
|
141
|
+
readonly userClass = input('', { alias: 'class' });
|
|
142
|
+
|
|
143
|
+
/** Uso interno. */
|
|
144
|
+
readonly element = inject<ElementRef<HTMLButtonElement>>(ElementRef).nativeElement;
|
|
145
|
+
protected readonly group = inject(MimiToggleGroup);
|
|
146
|
+
|
|
147
|
+
/** Para el FocusKeyManager. */
|
|
148
|
+
get disabled(): boolean {
|
|
149
|
+
return this.group.disabled();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
protected readonly on = computed(() => this.group.isOn(this.value()));
|
|
153
|
+
protected readonly classes = computed(() => {
|
|
154
|
+
const variant = this.group.variant();
|
|
155
|
+
const on = this.on();
|
|
156
|
+
return cn(
|
|
157
|
+
ITEM_CLASSES,
|
|
158
|
+
on && ON_TEXT[variant],
|
|
159
|
+
// Múltiple: cada elegido lleva su fondo (no hay indicador que se desliza).
|
|
160
|
+
on && this.group.multiple() && SELECTED_SURFACE[variant],
|
|
161
|
+
this.userClass(),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
/** Simple: solo el elegido (o el primero) es parada de Tab. Múltiple: todos. */
|
|
165
|
+
protected readonly tabIndex = computed(() =>
|
|
166
|
+
this.group.multiple() ? null : this.group.isTabStop(this) ? 0 : -1,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
focus(): void {
|
|
170
|
+
this.element.focus();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Toggle Group:
|
|
176
|
+
*
|
|
177
|
+
* ```html
|
|
178
|
+
* <mimi-toggle-group aria-label="Periodo" [(value)]="periodo">
|
|
179
|
+
* <button mimiToggleGroupItem="dia">Diario</button>
|
|
180
|
+
* <button mimiToggleGroupItem="semana">Semanal</button>
|
|
181
|
+
* </mimi-toggle-group>
|
|
182
|
+
*
|
|
183
|
+
* <mimi-toggle-group aria-label="Formato" multiple [(value)]="estilos">
|
|
184
|
+
* <button mimiToggleGroupItem="negrita" aria-label="Negrita"><svg lucideBold></svg></button>
|
|
185
|
+
* </mimi-toggle-group>
|
|
186
|
+
* ```
|
|
187
|
+
*
|
|
188
|
+
* Implementa `FormValueControl`: funciona con `[formField]`, `formControlName` y `[(ngModel)]`.
|
|
189
|
+
* Un valor sin botón se conserva (no se cambia por `null`).
|
|
190
|
+
*/
|
|
191
|
+
@Component({
|
|
192
|
+
selector: 'mimi-toggle-group',
|
|
193
|
+
template: `
|
|
194
|
+
@if (!multiple()) {
|
|
195
|
+
<span
|
|
196
|
+
aria-hidden="true"
|
|
197
|
+
data-slot="indicator"
|
|
198
|
+
[class]="indicatorClasses()"
|
|
199
|
+
[style.top]="indicatorStyle.top"
|
|
200
|
+
[style.bottom]="indicatorStyle.bottom"
|
|
201
|
+
[style.inset-inline-start]="indicatorStyle.start"
|
|
202
|
+
[style.width]="indicatorStyle.width"
|
|
203
|
+
[style.transition-timing-function]="indicatorStyle.easing"
|
|
204
|
+
[style.translate]="indicatorTranslate()"
|
|
205
|
+
[attr.data-variant]="variant()"
|
|
206
|
+
[hidden]="paintedIndex() < 0"
|
|
207
|
+
></span>
|
|
208
|
+
}
|
|
209
|
+
<ng-content />
|
|
210
|
+
`,
|
|
211
|
+
host: {
|
|
212
|
+
'[attr.role]': 'multiple() ? "group" : "radiogroup"',
|
|
213
|
+
'[attr.aria-label]': 'ariaLabel() || null',
|
|
214
|
+
'[attr.aria-labelledby]': 'ariaLabelledby() || null',
|
|
215
|
+
'[attr.aria-disabled]': 'disabled() ? "true" : null',
|
|
216
|
+
'[attr.aria-invalid]': 'showInvalid() ? "true" : null',
|
|
217
|
+
'[attr.aria-required]': 'required() && !multiple() ? "true" : null',
|
|
218
|
+
'[attr.data-variant]': 'variant()',
|
|
219
|
+
'[attr.data-multiple]': 'multiple() ? "" : null',
|
|
220
|
+
'[attr.data-disabled]': 'disabled() ? "" : null',
|
|
221
|
+
'[attr.data-invalid]': 'showInvalid() ? "" : null',
|
|
222
|
+
'[style.--mimi-toggle-group-count]': 'items().length',
|
|
223
|
+
'[style.--mimi-toggle-group-index]': 'paintedIndex()',
|
|
224
|
+
'[class]': 'classes()',
|
|
225
|
+
'(keydown)': 'onKeydown($event)',
|
|
226
|
+
'(focusin)': 'onFocusIn($event)',
|
|
227
|
+
'(focusout)': 'onFocusOut($event)',
|
|
228
|
+
},
|
|
229
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
230
|
+
})
|
|
231
|
+
export class MimiToggleGroup<T = unknown> implements FormValueControl<T | null | T[]> {
|
|
232
|
+
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
233
|
+
|
|
234
|
+
/** El elegido (`T | null`) o, con `multiple`, los elegidos (`T[]`). Admite `[(value)]`. */
|
|
235
|
+
readonly value = model<T | null | T[]>(null);
|
|
236
|
+
/** Varios elegidos a la vez (botones de alternancia). Sin él, uno solo (como una radio). */
|
|
237
|
+
readonly multiple = input(false, { transform: booleanAttribute });
|
|
238
|
+
/** `solid`: el elegido en `primary`. */
|
|
239
|
+
readonly variant = input<MimiToggleGroupVariant>('default');
|
|
240
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
241
|
+
/** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
|
|
242
|
+
readonly invalid = input(false, { transform: booleanAttribute });
|
|
243
|
+
readonly touched = input(false, { transform: booleanAttribute });
|
|
244
|
+
readonly dirty = input(false, { transform: booleanAttribute });
|
|
245
|
+
readonly required = input(false, { transform: booleanAttribute });
|
|
246
|
+
/** Nombre accesible (obligatorio, o `aria-labelledby`). */
|
|
247
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
248
|
+
readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
|
|
249
|
+
/** Clases del grupo, mezcladas con `cn()` (gana la del usuario). */
|
|
250
|
+
readonly userClass = input('', { alias: 'class' });
|
|
251
|
+
|
|
252
|
+
/** Se emite cuando el foco sale del grupo entero: marca el control como tocado. */
|
|
253
|
+
readonly touch = output<void>();
|
|
254
|
+
|
|
255
|
+
private readonly found = contentChildren(MimiToggleGroupItem, { descendants: true });
|
|
256
|
+
/** En el orden del documento. 4 es `Node.DOCUMENT_POSITION_FOLLOWING` (sin `Node` en el servidor). */
|
|
257
|
+
protected readonly items = computed(() =>
|
|
258
|
+
[...this.found()].sort((a, b) => (a.element.compareDocumentPosition(b.element) & 4 ? -1 : 1)),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
private readonly dir = inject(Directionality).valueSignal;
|
|
262
|
+
private readonly manager = new FocusKeyManager(this.items, inject(Injector))
|
|
263
|
+
.withWrap()
|
|
264
|
+
.withVerticalOrientation(true);
|
|
265
|
+
/** El último botón enfocado (por teclado, clic o código). */
|
|
266
|
+
private readonly active = signal<MimiToggleGroupItem | null>(null);
|
|
267
|
+
|
|
268
|
+
/** Los elegidos, como arreglo en los dos modos. */
|
|
269
|
+
private readonly selected = computed<readonly unknown[]>(() => {
|
|
270
|
+
const value = this.value();
|
|
271
|
+
if (this.multiple()) return Array.isArray(value) ? value : [];
|
|
272
|
+
return value === null || value === undefined || Array.isArray(value) ? [] : [value];
|
|
273
|
+
});
|
|
274
|
+
/** Índice del elegido (simple), para el indicador; −1 sin elegido o con un valor sin botón. */
|
|
275
|
+
protected readonly paintedIndex = computed(() => {
|
|
276
|
+
const [value] = this.selected();
|
|
277
|
+
return this.selected().length ? this.items().findIndex((i) => Object.is(i.value(), value)) : -1;
|
|
278
|
+
});
|
|
279
|
+
/** La parada de Tab en simple: el elegido; si no hay, el último enfocado o el primero. */
|
|
280
|
+
private readonly tabStop = computed(() => {
|
|
281
|
+
const items = this.items();
|
|
282
|
+
const index = this.paintedIndex();
|
|
283
|
+
if (index >= 0) return items[index];
|
|
284
|
+
const active = this.active();
|
|
285
|
+
return active && items.includes(active) ? active : items[0];
|
|
286
|
+
});
|
|
287
|
+
/** Error visible (inválido y tocado o modificado). */
|
|
288
|
+
protected readonly showInvalid = computed(
|
|
289
|
+
() => this.invalid() && (this.touched() || this.dirty()),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
protected readonly indicatorClasses = computed(() =>
|
|
293
|
+
cn(INDICATOR_CLASSES, SELECTED_SURFACE[this.variant()]),
|
|
294
|
+
);
|
|
295
|
+
// Bindings `[style.x]` sueltas: un `[style]` de objeto traería otra instrucción de
|
|
296
|
+
// @angular/core al JS inicial del showcase (spec 10).
|
|
297
|
+
protected readonly indicatorStyle = INDICATOR_STYLE;
|
|
298
|
+
protected readonly indicatorTranslate = computed(
|
|
299
|
+
() =>
|
|
300
|
+
`calc(${this.dir() === 'rtl' ? -1 : 1} * var(--mimi-toggle-group-index) * (100% + ${GAP})) 0`,
|
|
301
|
+
);
|
|
302
|
+
protected readonly classes = computed(() =>
|
|
303
|
+
cn(groupVariants({ multiple: this.multiple() }), this.userClass()),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
constructor() {
|
|
307
|
+
effect(() => this.manager.withHorizontalOrientation(this.dir()));
|
|
308
|
+
inject(DestroyRef).onDestroy(() => this.manager.destroy());
|
|
309
|
+
if (isDevMode()) afterRenderEffect({ read: () => this.warn(this.items(), this.value()) });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Uso interno: ¿está elegido este valor? */
|
|
313
|
+
isOn(value: unknown): boolean {
|
|
314
|
+
return this.selected().some((v) => Object.is(v, value));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Uso interno: ¿lleva `tabindex="0"` (simple)? */
|
|
318
|
+
isTabStop(item: MimiToggleGroupItem): boolean {
|
|
319
|
+
return this.tabStop() === item;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Uso interno: el clic (y Espacio o Enter, que el navegador convierte en clic). */
|
|
323
|
+
select(item: MimiToggleGroupItem): void {
|
|
324
|
+
if (this.disabled()) return;
|
|
325
|
+
const value = item.value() as T;
|
|
326
|
+
if (this.multiple()) {
|
|
327
|
+
const current = this.selected() as T[];
|
|
328
|
+
this.value.set(
|
|
329
|
+
current.some((v) => Object.is(v, value))
|
|
330
|
+
? current.filter((v) => !Object.is(v, value))
|
|
331
|
+
: [...current, value],
|
|
332
|
+
);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// Como una radio: volver a pulsar la elegida no la quita (decidido por defecto, G2.1).
|
|
336
|
+
if (!this.isOn(value)) this.value.set(value);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Simple: las flechas mueven el foco y eligen (con vuelta; en RTL, ← y → al revés). */
|
|
340
|
+
protected onKeydown(event: KeyboardEvent): void {
|
|
341
|
+
if (this.multiple() || this.disabled()) return;
|
|
342
|
+
if (!this.items().some((item) => item.element === event.target)) return;
|
|
343
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
|
344
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
345
|
+
this.manager.onKeydown(event);
|
|
346
|
+
const item = this.manager.activeItem;
|
|
347
|
+
if (item) {
|
|
348
|
+
this.active.set(item);
|
|
349
|
+
this.select(item);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** El foco llegó a un botón (Tab, clic o código): el gestor sigue desde ahí. */
|
|
354
|
+
protected onFocusIn(event: FocusEvent): void {
|
|
355
|
+
const item = this.items().find((i) => i.element === event.target);
|
|
356
|
+
if (!item) return;
|
|
357
|
+
this.manager.updateActiveItem(item);
|
|
358
|
+
this.active.set(item);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** El foco salió del grupo entero. */
|
|
362
|
+
protected onFocusOut(event: FocusEvent): void {
|
|
363
|
+
const next = event.relatedTarget as Node | null;
|
|
364
|
+
if (next && this.host.contains(next)) return;
|
|
365
|
+
this.touch.emit();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private warned = false;
|
|
369
|
+
private warn(items: readonly MimiToggleGroupItem[], value: unknown): void {
|
|
370
|
+
if (this.warned) return;
|
|
371
|
+
const multiple = untracked(this.multiple);
|
|
372
|
+
const values = items.map((item) => untracked(item.value));
|
|
373
|
+
const problems = [
|
|
374
|
+
!this.host.getAttribute('aria-label')?.trim() &&
|
|
375
|
+
!this.host.getAttribute('aria-labelledby')?.trim() &&
|
|
376
|
+
'el grupo no tiene aria-label (su nombre accesible)',
|
|
377
|
+
values.some((v, i) => values.findIndex((w) => Object.is(v, w)) !== i) &&
|
|
378
|
+
'hay valores repetidos',
|
|
379
|
+
items.some((item) => !accessibleName(item.element)) &&
|
|
380
|
+
'un botón sin texto visible no tiene aria-label',
|
|
381
|
+
multiple &&
|
|
382
|
+
value !== null &&
|
|
383
|
+
!Array.isArray(value) &&
|
|
384
|
+
'con multiple, value tiene que ser un arreglo',
|
|
385
|
+
!multiple && Array.isArray(value) && 'sin multiple, value no puede ser un arreglo',
|
|
386
|
+
].filter(Boolean);
|
|
387
|
+
if (!problems.length) return;
|
|
388
|
+
this.warned = true;
|
|
389
|
+
console.warn(`[Mimi] Toggle Group: ${problems.join('; ')}.`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Todas las piezas del Toggle Group, para importarlas juntas. */
|
|
394
|
+
export const MimiToggleGroupImports = [MimiToggleGroup, MimiToggleGroupItem] as const;
|
|
395
|
+
|
|
396
|
+
/** Nombre del botón: aria-labelledby, aria-label o el texto visible. */
|
|
397
|
+
function accessibleName(button: HTMLElement): string {
|
|
398
|
+
const label = button.getAttribute('aria-label') ?? button.getAttribute('aria-labelledby');
|
|
399
|
+
return label?.trim() || visibleText(button).trim();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function visibleText(node: Node): string {
|
|
403
|
+
if (node.nodeType === 3) return node.textContent ?? '';
|
|
404
|
+
if (!(node instanceof Element) || node.getAttribute('aria-hidden') === 'true') return '';
|
|
405
|
+
return Array.from(node.childNodes).map(visibleText).join('');
|
|
406
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './toolbar';
|