@mimi-ng/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/dist/collection.json +1 -1
- package/dist/init/css.js +17 -0
- package/dist/init/index.js +7 -5
- package/dist/init/items.js +9 -0
- package/dist/ng-add/index.js +17 -1
- package/dist/ng-add/schema.json +27 -0
- package/dist/registry.json +222 -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/card/card.ts +8 -5
- package/dist/templates/components/checkbox/checkbox.ts +3 -3
- 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/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/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/menu/index.ts +15 -0
- package/dist/templates/components/menu/menu.ts +656 -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/popover/index.ts +12 -0
- package/dist/templates/components/popover/popover.ts +399 -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 +1085 -0
- package/dist/templates/components/separator/separator.variants.ts +10 -7
- package/dist/templates/components/skeleton/skeleton.ts +4 -1
- 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/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/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 +62 -6
- package/dist/templates/theme/theme-base.css +307 -9
- package/dist/templates/theme/types.ts +278 -4
- package/dist/templates/utils/cn.ts +15 -1
- package/dist/templates/utils/control-styles.ts +2 -2
- package/dist/templates/utils/overlay.ts +140 -0
- package/dist/ui/index.js +80 -8
- package/dist/ui/schema.json +44 -0
- package/dist/utils/write-files.js +156 -59
- package/package.json +6 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './toolbar';
|
|
@@ -0,0 +1,377 @@
|
|
|
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
|
+
Renderer2,
|
|
11
|
+
afterRenderEffect,
|
|
12
|
+
booleanAttribute,
|
|
13
|
+
computed,
|
|
14
|
+
contentChildren,
|
|
15
|
+
effect,
|
|
16
|
+
inject,
|
|
17
|
+
input,
|
|
18
|
+
isDevMode,
|
|
19
|
+
model,
|
|
20
|
+
output,
|
|
21
|
+
signal,
|
|
22
|
+
untracked,
|
|
23
|
+
} from '@angular/core';
|
|
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
|
+
* mimi-toolbar (docs/design/Mimi F2 Toast Tabs Toolbar.dc.html, «4 · Toolbar»; prefijo de tokens
|
|
30
|
+
* `toolbar`, spec 13): píldora translúcida con botones redondos, horizontal o vertical.
|
|
31
|
+
*
|
|
32
|
+
* Patrón toolbar de WAI-ARIA con el FocusKeyManager de @angular/cdk/a11y (no @angular/aria: spec 4,
|
|
33
|
+
* excepción de la G1.10). Los botones son del usuario y son nativos: Enter, Espacio y el clic
|
|
34
|
+
* funcionan sin rodeos. Mimi pone el tabindex itinerante (una sola parada de Tab) y le avisa al
|
|
35
|
+
* gestor dónde está el foco (`focusin`), también si se movió por código.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Horizontal (por defecto) o vertical. */
|
|
39
|
+
export type MimiToolbarOrientation = 'horizontal' | 'vertical';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Qué hace el botón: `tool` es la herramienta activa (`value`, una a la vez), `toggle` un
|
|
43
|
+
* interruptor (`pressed`) y `action` (por defecto) solo avisa con `activate`.
|
|
44
|
+
*/
|
|
45
|
+
export type MimiToolbarItemKind = 'tool' | 'toggle' | 'action';
|
|
46
|
+
|
|
47
|
+
const toolbarVariants = cva(
|
|
48
|
+
[
|
|
49
|
+
'inline-flex items-center gap-[6px] rounded-full border p-[6px] font-sans',
|
|
50
|
+
'border-[color:var(--mimi-toolbar-border,var(--mimi-glass-border))] bg-[color:var(--mimi-toolbar-bg,var(--mimi-glass))]',
|
|
51
|
+
'backdrop-blur-[var(--mimi-toolbar-blur,var(--mimi-glass-blur))] backdrop-saturate-[1.4]',
|
|
52
|
+
'shadow-[shadow:var(--mimi-toolbar-shadow,var(--mimi-shadow-popover))]',
|
|
53
|
+
],
|
|
54
|
+
{
|
|
55
|
+
variants: {
|
|
56
|
+
orientation: { horizontal: 'flex-row', vertical: 'flex-col' },
|
|
57
|
+
},
|
|
58
|
+
defaultVariants: { orientation: 'horizontal' },
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const ITEM_CLASSES = cn(
|
|
63
|
+
'relative inline-flex size-[var(--mimi-toolbar-item-size,40px)] shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-foreground',
|
|
64
|
+
'[&_svg]:size-[18px] [&_svg]:shrink-0',
|
|
65
|
+
// Las transiciones del diseño; con movimiento reducido, solo los colores.
|
|
66
|
+
'[transition:background-color_.2s_ease,color_.2s_ease,box-shadow_.35s_cubic-bezier(.2,.8,.2,1),scale_.15s_cubic-bezier(.2,.8,.2,1)]',
|
|
67
|
+
'motion-reduce:[transition:background-color_.2s_ease,color_.2s_ease]',
|
|
68
|
+
// Hover: anillo de 2px (info al 60 %). Herramienta activa: disco y anillo doble.
|
|
69
|
+
'not-aria-disabled:not-data-[state=active]:hover:shadow-[0_0_0_2px_color-mix(in_oklch,var(--mimi-toolbar-ring,var(--mimi-info))_60%,transparent)]',
|
|
70
|
+
'data-[state=active]:bg-[color:var(--mimi-toolbar-active-bg,var(--mimi-primary))] data-[state=active]:text-[color:var(--mimi-toolbar-active-fg,var(--mimi-primary-foreground))]',
|
|
71
|
+
'data-[state=active]:shadow-[0_0_0_2px_var(--mimi-background),0_0_0_4px_var(--mimi-toolbar-ring,var(--mimi-info))]',
|
|
72
|
+
'data-[state=pressed]:bg-[color:var(--mimi-toolbar-pressed-bg,var(--mimi-accent))]',
|
|
73
|
+
// 0,9 (con movimiento reducido, 1); el diseño usa 0,88 sin token: plan D6.
|
|
74
|
+
'not-aria-disabled:active:scale-(--mimi-press-scale-sm)',
|
|
75
|
+
// Deshabilitado (sin diseño, plan D6): enfocable con las flechas, pero no se activa.
|
|
76
|
+
'aria-disabled:cursor-not-allowed aria-disabled:opacity-(--mimi-disabled-opacity)',
|
|
77
|
+
// Foco de teclado (el diseño solo muestra el anillo del hover, que no basta): por fuera del
|
|
78
|
+
// anillo del activo, sin pisar al vecino (6px de separación). Plan D6.
|
|
79
|
+
buttonFocusStyles,
|
|
80
|
+
'focus-visible:outline-offset-4',
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const COUNT_CLASSES =
|
|
84
|
+
'pointer-events-none absolute -top-[3px] -end-[3px] inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-info px-[5px] text-[10.5px] font-bold text-info-foreground shadow-[0_0_0_2px_var(--mimi-background)]';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Un botón de la toolbar: `<button mimiToolbarItem="negrita" kind="toggle" aria-label="Negrita">`.
|
|
88
|
+
* El contenido y el `aria-label` son del usuario; un `mimiTooltip` puede ir en el mismo botón.
|
|
89
|
+
*/
|
|
90
|
+
@Directive({
|
|
91
|
+
selector: 'button[mimiToolbarItem]',
|
|
92
|
+
host: {
|
|
93
|
+
type: 'button',
|
|
94
|
+
// Un `disabled` estático deshabilitaría el botón nativo y lo sacaría del recorrido.
|
|
95
|
+
'[attr.disabled]': 'null',
|
|
96
|
+
'[attr.tabindex]': 'tabIndex()',
|
|
97
|
+
'[attr.aria-pressed]': 'pressedAttr()',
|
|
98
|
+
'[attr.aria-disabled]': 'disabledInput() ? "true" : null',
|
|
99
|
+
'[attr.data-state]': 'state()',
|
|
100
|
+
'[attr.data-kind]': 'kind()',
|
|
101
|
+
'[attr.data-disabled]': 'disabledInput() ? "" : null',
|
|
102
|
+
'[attr.data-value]': 'value()',
|
|
103
|
+
'[class]': 'classes()',
|
|
104
|
+
'(click)': 'onClick()',
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
export class MimiToolbarItem implements FocusableOption {
|
|
108
|
+
/** El valor del botón (único en la toolbar). */
|
|
109
|
+
readonly value = input('', { alias: 'mimiToolbarItem' });
|
|
110
|
+
readonly kind = input<MimiToolbarItemKind>('action');
|
|
111
|
+
/** Se puede enfocar con las flechas, pero no se activa (`aria-disabled`). */
|
|
112
|
+
readonly disabledInput = input(false, { alias: 'disabled', transform: booleanAttribute });
|
|
113
|
+
/** Clases del botón, mezcladas con `cn()` (gana la del usuario). */
|
|
114
|
+
readonly userClass = input('', { alias: 'class' });
|
|
115
|
+
|
|
116
|
+
/** Uso interno. */
|
|
117
|
+
readonly element = inject<ElementRef<HTMLButtonElement>>(ElementRef).nativeElement;
|
|
118
|
+
protected readonly toolbar = inject(MimiToolbar);
|
|
119
|
+
|
|
120
|
+
/** Para el FocusKeyManager. */
|
|
121
|
+
get disabled(): boolean {
|
|
122
|
+
return this.disabledInput();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
protected readonly state = computed(() => {
|
|
126
|
+
const value = this.value();
|
|
127
|
+
if (this.kind() === 'tool' && this.toolbar.value() === value) return 'active';
|
|
128
|
+
if (this.kind() === 'toggle' && this.toolbar.pressed().includes(value)) return 'pressed';
|
|
129
|
+
return 'idle';
|
|
130
|
+
});
|
|
131
|
+
protected readonly pressedAttr = computed(() =>
|
|
132
|
+
this.kind() === 'action' ? null : String(this.state() !== 'idle'),
|
|
133
|
+
);
|
|
134
|
+
protected readonly classes = computed(() => cn(ITEM_CLASSES, this.userClass()));
|
|
135
|
+
protected readonly tabIndex = computed(() => (this.toolbar.isTabStop(this) ? 0 : -1));
|
|
136
|
+
|
|
137
|
+
constructor() {
|
|
138
|
+
// En un deshabilitado, el clic se frena en la fase de captura: no llega ni al `(click)` del
|
|
139
|
+
// usuario ni a `activate` (el botón no lleva `disabled` nativo para seguir siendo enfocable).
|
|
140
|
+
const stop = inject(Renderer2).listen(
|
|
141
|
+
this.element,
|
|
142
|
+
'click',
|
|
143
|
+
(event: Event) => {
|
|
144
|
+
if (!this.disabledInput()) return;
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
event.stopImmediatePropagation();
|
|
147
|
+
},
|
|
148
|
+
{ capture: true },
|
|
149
|
+
);
|
|
150
|
+
inject(DestroyRef).onDestroy(stop);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
focus(): void {
|
|
154
|
+
this.element.focus();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Clic, y también Enter o Espacio (el navegador los convierte en clic). */
|
|
158
|
+
protected onClick(): void {
|
|
159
|
+
this.toolbar.activateItem(this);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Toolbar:
|
|
165
|
+
*
|
|
166
|
+
* ```html
|
|
167
|
+
* <mimi-toolbar aria-label="Formato" [(value)]="alineacion" [(pressed)]="estilos">
|
|
168
|
+
* <button mimiToolbarItem="negrita" kind="toggle" aria-label="Negrita"><svg lucideBold></svg></button>
|
|
169
|
+
* <mimi-toolbar-separator />
|
|
170
|
+
* <button mimiToolbarItem="izquierda" kind="tool" aria-label="Izquierda">…</button>
|
|
171
|
+
* </mimi-toolbar>
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
@Component({
|
|
175
|
+
selector: 'mimi-toolbar',
|
|
176
|
+
template: '<ng-content />',
|
|
177
|
+
host: {
|
|
178
|
+
role: 'toolbar',
|
|
179
|
+
'[attr.aria-orientation]': 'orientation()',
|
|
180
|
+
'[attr.data-orientation]': 'orientation()',
|
|
181
|
+
'[attr.aria-label]': 'ariaLabel() || null',
|
|
182
|
+
'[attr.aria-labelledby]': 'ariaLabelledby() || null',
|
|
183
|
+
'[class]': 'classes()',
|
|
184
|
+
'(keydown)': 'onKeydown($event)',
|
|
185
|
+
'(focusin)': 'onFocusIn($event)',
|
|
186
|
+
},
|
|
187
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
188
|
+
})
|
|
189
|
+
export class MimiToolbar {
|
|
190
|
+
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
191
|
+
|
|
192
|
+
/** Nombre accesible (obligatorio, o `aria-labelledby`). */
|
|
193
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
194
|
+
readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
|
|
195
|
+
readonly orientation = input<MimiToolbarOrientation>('horizontal');
|
|
196
|
+
/** Las flechas vuelven al principio desde el último botón (y al revés). */
|
|
197
|
+
readonly wrap = input(true, { transform: booleanAttribute });
|
|
198
|
+
/** La herramienta activa (`kind="tool"`). Admite `[(value)]`. */
|
|
199
|
+
readonly value = model<string | undefined>(undefined);
|
|
200
|
+
/** Los interruptores presionados (`kind="toggle"`). Admite `[(pressed)]`. */
|
|
201
|
+
readonly pressed = model<string[]>([]);
|
|
202
|
+
/** Cada activación (de cualquier tipo), después de actualizar `value` o `pressed`. */
|
|
203
|
+
readonly activate = output<string>();
|
|
204
|
+
/** Clases de la píldora, mezcladas con `cn()` (gana la del usuario). */
|
|
205
|
+
readonly userClass = input('', { alias: 'class' });
|
|
206
|
+
|
|
207
|
+
private readonly found = contentChildren(MimiToolbarItem, { descendants: true });
|
|
208
|
+
/**
|
|
209
|
+
* En el orden del documento (el de `contentChildren` es el de declaración). 4 es
|
|
210
|
+
* `Node.DOCUMENT_POSITION_FOLLOWING`, escrito así porque en el servidor no hay `Node`.
|
|
211
|
+
*/
|
|
212
|
+
private readonly items = computed(() =>
|
|
213
|
+
[...this.found()].sort((a, b) => (a.element.compareDocumentPosition(b.element) & 4 ? -1 : 1)),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
private readonly dir = inject(Directionality).valueSignal;
|
|
217
|
+
private readonly manager = new FocusKeyManager(this.items, inject(Injector)).withHomeAndEnd();
|
|
218
|
+
|
|
219
|
+
/** El último botón activo del gestor (por teclado, clic o foco). */
|
|
220
|
+
private readonly active = signal<MimiToolbarItem | null>(null);
|
|
221
|
+
/** El que tiene `tabindex="0"`: el último activo si sigue; si no, el primero. */
|
|
222
|
+
private readonly tabStop = computed(() => {
|
|
223
|
+
const items = this.items();
|
|
224
|
+
const active = this.active();
|
|
225
|
+
return active && items.includes(active) ? active : items[0];
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
protected readonly classes = computed(() =>
|
|
229
|
+
cn(toolbarVariants({ orientation: this.orientation() }), this.userClass()),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
constructor() {
|
|
233
|
+
effect(() => {
|
|
234
|
+
const vertical = this.orientation() === 'vertical';
|
|
235
|
+
this.manager
|
|
236
|
+
.withWrap(this.wrap())
|
|
237
|
+
.withVerticalOrientation(vertical)
|
|
238
|
+
.withHorizontalOrientation(vertical ? null : this.dir())
|
|
239
|
+
// Deshabilitados enfocables (patrón de WAI-ARIA para toolbars).
|
|
240
|
+
.skipPredicate(() => false);
|
|
241
|
+
});
|
|
242
|
+
const changes = this.manager.change.subscribe(() => this.active.set(this.manager.activeItem));
|
|
243
|
+
inject(DestroyRef).onDestroy(() => {
|
|
244
|
+
changes.unsubscribe();
|
|
245
|
+
this.manager.destroy();
|
|
246
|
+
});
|
|
247
|
+
if (isDevMode()) afterRenderEffect({ read: () => this.warn(this.items()) });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Uso interno: ¿lleva `tabindex="0"`? */
|
|
251
|
+
isTabStop(item: MimiToolbarItem): boolean {
|
|
252
|
+
return this.tabStop() === item;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Uso interno: el clic (y Enter o Espacio, que el navegador convierte en clic). */
|
|
256
|
+
activateItem(item: MimiToolbarItem): void {
|
|
257
|
+
this.setActive(item);
|
|
258
|
+
if (item.disabledInput()) return;
|
|
259
|
+
const value = item.value();
|
|
260
|
+
if (item.kind() === 'tool') this.value.set(value);
|
|
261
|
+
if (item.kind() === 'toggle') {
|
|
262
|
+
this.pressed.update((pressed) =>
|
|
263
|
+
pressed.includes(value) ? pressed.filter((v) => v !== value) : [...pressed, value],
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
this.activate.emit(value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Solo las teclas de los botones: un campo o un menú dentro conserva sus flechas. */
|
|
270
|
+
protected onKeydown(event: KeyboardEvent): void {
|
|
271
|
+
if (!this.items().some((item) => item.element === event.target)) return;
|
|
272
|
+
this.manager.onKeydown(event);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** El foco llegó a un botón (Tab, clic o código): el gestor sigue desde ahí. */
|
|
276
|
+
protected onFocusIn(event: FocusEvent): void {
|
|
277
|
+
const item = this.items().find((i) => i.element === event.target);
|
|
278
|
+
if (item) this.setActive(item);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private setActive(item: MimiToolbarItem): void {
|
|
282
|
+
// updateActiveItem no emite `change`: la señal se actualiza aquí.
|
|
283
|
+
this.manager.updateActiveItem(item);
|
|
284
|
+
this.active.set(item);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private warned = false;
|
|
288
|
+
private warn(items: readonly MimiToolbarItem[]): void {
|
|
289
|
+
if (this.warned) return;
|
|
290
|
+
const values = items.map((item) => untracked(item.value));
|
|
291
|
+
const problems = [
|
|
292
|
+
!this.host.getAttribute('aria-label')?.trim() &&
|
|
293
|
+
!this.host.getAttribute('aria-labelledby')?.trim() &&
|
|
294
|
+
'la toolbar no tiene aria-label (su nombre accesible)',
|
|
295
|
+
values.some((value) => !value) && 'un botón tiene el valor vacío',
|
|
296
|
+
values.some((value, i) => values.indexOf(value) !== i) && 'hay valores repetidos',
|
|
297
|
+
items.some((item) => !accessibleName(item.element)) &&
|
|
298
|
+
'un botón sin texto visible no tiene aria-label',
|
|
299
|
+
items.some((item) => countMissing(item.element)) &&
|
|
300
|
+
'el aria-label de un botón con contador no incluye el número',
|
|
301
|
+
].filter(Boolean);
|
|
302
|
+
if (!problems.length) return;
|
|
303
|
+
this.warned = true;
|
|
304
|
+
console.warn(`[Mimi] Toolbar: ${problems.join('; ')}.`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Separador entre grupos de botones, perpendicular a la toolbar. */
|
|
309
|
+
@Component({
|
|
310
|
+
selector: 'mimi-toolbar-separator',
|
|
311
|
+
template: '',
|
|
312
|
+
host: {
|
|
313
|
+
role: 'separator',
|
|
314
|
+
'[attr.aria-orientation]': 'orientation()',
|
|
315
|
+
'[attr.data-orientation]': 'orientation()',
|
|
316
|
+
'[class]': 'classes()',
|
|
317
|
+
},
|
|
318
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
319
|
+
})
|
|
320
|
+
export class MimiToolbarSeparator {
|
|
321
|
+
private readonly toolbar = inject(MimiToolbar);
|
|
322
|
+
readonly userClass = input('', { alias: 'class' });
|
|
323
|
+
|
|
324
|
+
protected readonly orientation = computed(() =>
|
|
325
|
+
this.toolbar.orientation() === 'vertical' ? 'horizontal' : 'vertical',
|
|
326
|
+
);
|
|
327
|
+
protected readonly classes = computed(() =>
|
|
328
|
+
cn(
|
|
329
|
+
'shrink-0 bg-border',
|
|
330
|
+
this.orientation() === 'vertical' ? 'mx-[2px] h-[22px] w-px' : 'my-[2px] h-px w-[22px]',
|
|
331
|
+
this.userClass(),
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Contador sobre un botón: `<span mimiToolbarCount>3</span>`. Es decorativo (`aria-hidden`): el
|
|
338
|
+
* número va también en el `aria-label` del botón («Comentarios, 3 sin leer»).
|
|
339
|
+
*/
|
|
340
|
+
@Directive({
|
|
341
|
+
selector: '[mimiToolbarCount]',
|
|
342
|
+
host: { 'aria-hidden': 'true', 'data-slot': 'count', '[class]': 'classes()' },
|
|
343
|
+
})
|
|
344
|
+
export class MimiToolbarCount {
|
|
345
|
+
readonly userClass = input('', { alias: 'class' });
|
|
346
|
+
protected readonly classes = computed(() => cn(COUNT_CLASSES, this.userClass()));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Todas las piezas de la toolbar, para importarlas juntas. */
|
|
350
|
+
export const MimiToolbarImports = [
|
|
351
|
+
MimiToolbar,
|
|
352
|
+
MimiToolbarItem,
|
|
353
|
+
MimiToolbarSeparator,
|
|
354
|
+
MimiToolbarCount,
|
|
355
|
+
] as const;
|
|
356
|
+
|
|
357
|
+
/** Nombre del botón: aria-labelledby, aria-label o el texto visible (sin el contador). */
|
|
358
|
+
function accessibleName(button: HTMLElement): string {
|
|
359
|
+
const label = button.getAttribute('aria-label') ?? button.getAttribute('aria-labelledby');
|
|
360
|
+
return label?.trim() || visibleText(button).trim();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function visibleText(node: Node): string {
|
|
364
|
+
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
|
|
365
|
+
if (!(node instanceof Element) || node.getAttribute('aria-hidden') === 'true') return '';
|
|
366
|
+
if (node.hasAttribute('mimiToolbarCount') || node.getAttribute('data-slot') === 'count') {
|
|
367
|
+
return '';
|
|
368
|
+
}
|
|
369
|
+
return Array.from(node.childNodes).map(visibleText).join('');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Hay contador y el aria-label no tiene su número. */
|
|
373
|
+
function countMissing(button: HTMLElement): boolean {
|
|
374
|
+
const count = button.querySelector('[data-slot="count"]')?.textContent?.trim();
|
|
375
|
+
const label = button.getAttribute('aria-label');
|
|
376
|
+
return !!count && !!label && !label.includes(count);
|
|
377
|
+
}
|