@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,656 @@
|
|
|
1
|
+
import { Menu, MenuItem } from '@angular/aria/menu';
|
|
2
|
+
import {
|
|
3
|
+
ChangeDetectionStrategy,
|
|
4
|
+
Component,
|
|
5
|
+
DestroyRef,
|
|
6
|
+
Directive,
|
|
7
|
+
ElementRef,
|
|
8
|
+
InjectionToken,
|
|
9
|
+
Injector,
|
|
10
|
+
TemplateRef,
|
|
11
|
+
ViewContainerRef,
|
|
12
|
+
afterNextRender,
|
|
13
|
+
booleanAttribute,
|
|
14
|
+
computed,
|
|
15
|
+
contentChild,
|
|
16
|
+
effect,
|
|
17
|
+
inject,
|
|
18
|
+
input,
|
|
19
|
+
isDevMode,
|
|
20
|
+
model,
|
|
21
|
+
output,
|
|
22
|
+
signal,
|
|
23
|
+
untracked,
|
|
24
|
+
viewChild,
|
|
25
|
+
} from '@angular/core';
|
|
26
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
27
|
+
import { MIMI_OVERLAY_ANIMATION, MIMI_VIEWPORT_MARGIN } from '@/components/ui/utils/overlay';
|
|
28
|
+
|
|
29
|
+
/*
|
|
30
|
+
* Piezas compartidas de Dropdown Menu y Context Menu (docs/design/Mimi F2 Overlays.dc.html, «5 ·
|
|
31
|
+
* Dropdown Menu» y «6 · Context Menu»; prefijo de tokens `menu`, spec 13). Ítem de 32px, padding
|
|
32
|
+
* 0 8px, gap 8px, columna de ícono de 16px, atajo en monoespaciada; etiqueta de grupo, separador,
|
|
33
|
+
* checkbox, radio y submenú. Panel de 240px con padding de 4px.
|
|
34
|
+
*
|
|
35
|
+
* Teclado, foco, typeahead y submenús: @angular/aria/menu (decisión 4.3), solo por dentro. Cada
|
|
36
|
+
* ítem pinta su `div ngMenuItem` con un `value` interno único; MimiMenuPanel traduce el
|
|
37
|
+
* `itemSelected` de aria al ítem (MIMI_MENU_HOST). Nada de aria sale en la API pública.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
let nextId = 0;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Uso interno: el panel avisa al ítem elegido.
|
|
44
|
+
* @internal No está en el índice; no forma parte de la API de Mimi.
|
|
45
|
+
*/
|
|
46
|
+
export interface MimiMenuHost {
|
|
47
|
+
register(id: string, select: () => void): void;
|
|
48
|
+
unregister(id: string): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @internal No está en el índice; no forma parte de la API de Mimi. */
|
|
52
|
+
export const MIMI_MENU_HOST = new InjectionToken<MimiMenuHost>('MimiMenuHost');
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Uso interno (no está en los Imports; se exporta porque el compilador lo exige): pinta el
|
|
56
|
+
* contenido del usuario con el injector de este punto, dentro del `ngMenu`, para que los ítems
|
|
57
|
+
* encuentren su menú. Hace lo mismo que NgTemplateOutlet con `injector`, sin @angular/common.
|
|
58
|
+
* `mimiMenuOutletContext` llega al contenido como `let-x` (el dato de la fila en Context Menu).
|
|
59
|
+
* @internal No está en el índice; no forma parte de la API de Mimi.
|
|
60
|
+
*/
|
|
61
|
+
@Directive({ selector: '[mimiMenuOutlet]' })
|
|
62
|
+
export class MimiMenuOutlet {
|
|
63
|
+
readonly template = input.required<TemplateRef<unknown>>({ alias: 'mimiMenuOutlet' });
|
|
64
|
+
readonly context = input<unknown>(undefined, { alias: 'mimiMenuOutletContext' });
|
|
65
|
+
private readonly viewContainer = inject(ViewContainerRef);
|
|
66
|
+
private readonly injector = inject(Injector);
|
|
67
|
+
private readonly render = effect(() => {
|
|
68
|
+
const template = this.template();
|
|
69
|
+
const context = { $implicit: this.context() };
|
|
70
|
+
untracked(() => {
|
|
71
|
+
this.viewContainer.clear();
|
|
72
|
+
this.viewContainer.createEmbeddedView(template, context, { injector: this.injector });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Panel del menú y del submenú: fondo, borde, radio y sombra de la spec 13.
|
|
79
|
+
* @internal No está en el índice; no forma parte de la API de Mimi.
|
|
80
|
+
*/
|
|
81
|
+
export const MIMI_MENU_PANEL_CLASSES =
|
|
82
|
+
'flex flex-col p-1 outline-none rounded-[var(--mimi-menu-radius,var(--mimi-radius))] border border-[color:var(--mimi-menu-border,var(--mimi-border))] bg-[color:var(--mimi-menu-bg,var(--mimi-popover))] text-popover-foreground shadow-[shadow:var(--mimi-menu-shadow,var(--mimi-shadow-popover))]';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Uso interno de Dropdown Menu y Context Menu (no está en los Imports): el `ngMenu` raíz con el
|
|
86
|
+
* contenido del usuario. Reúne lo que aria no hace en un menú sin `ngMenuTrigger` (plan, G1.5b):
|
|
87
|
+
* avisa al ítem elegido y pide cerrar; Escape (aria detiene su propagación, así que se escucha en
|
|
88
|
+
* el mismo elemento); Tab fuera del menú; y el foco inicial con Home/End, porque aria no tiene un
|
|
89
|
+
* método público para enfocar el primer o el último ítem. Lleva la animación de entrada y salida.
|
|
90
|
+
* @internal No está en el índice: Dropdown Menu y Context Menu lo importan desde `menu/menu`.
|
|
91
|
+
*/
|
|
92
|
+
@Component({
|
|
93
|
+
selector: 'mimi-menu-panel',
|
|
94
|
+
imports: [Menu, MimiMenuOutlet],
|
|
95
|
+
template: `
|
|
96
|
+
<div
|
|
97
|
+
ngMenu
|
|
98
|
+
#panel
|
|
99
|
+
data-mimi-overlay-panel
|
|
100
|
+
[id]="menuId()"
|
|
101
|
+
[class]="classes()"
|
|
102
|
+
[attr.aria-label]="label() || null"
|
|
103
|
+
[attr.aria-labelledby]="label() ? null : labelledBy()"
|
|
104
|
+
[attr.data-state]="state()"
|
|
105
|
+
[attr.data-side]="side()"
|
|
106
|
+
[attr.data-align]="align()"
|
|
107
|
+
[attr.inert]="state() === 'closed' ? '' : null"
|
|
108
|
+
(itemSelected)="onItemSelected($event)"
|
|
109
|
+
(keydown)="onKeydown($event)"
|
|
110
|
+
(focusout)="onFocusOut($event)"
|
|
111
|
+
(contextmenu)="onContextMenu($event)"
|
|
112
|
+
>
|
|
113
|
+
<ng-container [mimiMenuOutlet]="template()" [mimiMenuOutletContext]="context()" />
|
|
114
|
+
</div>
|
|
115
|
+
`,
|
|
116
|
+
styles: MIMI_OVERLAY_ANIMATION,
|
|
117
|
+
providers: [{ provide: MIMI_MENU_HOST, useExisting: MimiMenuPanel }],
|
|
118
|
+
host: { style: 'display: contents', '[attr.class]': 'null' },
|
|
119
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
120
|
+
})
|
|
121
|
+
export class MimiMenuPanel implements MimiMenuHost {
|
|
122
|
+
private readonly injector = inject(Injector);
|
|
123
|
+
|
|
124
|
+
readonly template = input.required<TemplateRef<unknown>>();
|
|
125
|
+
/** Dato para el contenido (`let-x`). */
|
|
126
|
+
readonly context = input<unknown>(undefined);
|
|
127
|
+
readonly menuId = input.required<string>();
|
|
128
|
+
readonly label = input('');
|
|
129
|
+
/** id del elemento que nombra el menú, si no hay `label`. */
|
|
130
|
+
readonly labelledBy = input<string | null | undefined>(null);
|
|
131
|
+
readonly state = input<'open' | 'closed'>('open');
|
|
132
|
+
readonly side = input<string | null>(null);
|
|
133
|
+
readonly align = input<string | null>(null);
|
|
134
|
+
/** Ítem enfocado al crearse. */
|
|
135
|
+
readonly initialFocus = input<'first' | 'last'>('first');
|
|
136
|
+
/** Un elemento al que el foco puede ir sin cerrar el menú (el trigger del Dropdown). */
|
|
137
|
+
readonly focusExempt = input<Element | null | undefined>(null);
|
|
138
|
+
readonly userClass = input('', { alias: 'class' });
|
|
139
|
+
/** Pide cerrar: `true` devuelve el foco (ítem elegido, Escape), `false` no (Tab). */
|
|
140
|
+
readonly closeRequest = output<boolean>();
|
|
141
|
+
|
|
142
|
+
private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
|
|
143
|
+
private readonly items = new Map<string, () => void>();
|
|
144
|
+
|
|
145
|
+
protected readonly classes = computed(() =>
|
|
146
|
+
cn(
|
|
147
|
+
MIMI_MENU_PANEL_CLASSES,
|
|
148
|
+
'w-[var(--mimi-menu-width,240px)] max-w-[calc(100vw_-_16px)]',
|
|
149
|
+
this.userClass(),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
/** El `div` del menú (para la animación de salida y el foco). */
|
|
154
|
+
get element(): HTMLElement {
|
|
155
|
+
return this.panel().nativeElement;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
constructor() {
|
|
159
|
+
this.focusItem();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
register(id: string, select: () => void): void {
|
|
163
|
+
this.items.set(id, select);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
unregister(id: string): void {
|
|
167
|
+
this.items.delete(id);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Enfoca el primer o el último ítem después del render: se le envía Home o End al `ngMenu`,
|
|
172
|
+
* las teclas del patrón (la prueba «foco al abrir» del Dropdown avisa si aria cambia).
|
|
173
|
+
*/
|
|
174
|
+
focusItem(target?: 'first' | 'last'): void {
|
|
175
|
+
afterNextRender(
|
|
176
|
+
{
|
|
177
|
+
write: () => {
|
|
178
|
+
if (this.state() !== 'open') return;
|
|
179
|
+
const key = (target ?? this.initialFocus()) === 'last' ? 'End' : 'Home';
|
|
180
|
+
this.element.dispatchEvent(
|
|
181
|
+
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{ injector: this.injector },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
protected onItemSelected(value: unknown): void {
|
|
190
|
+
this.items.get(value as string)?.();
|
|
191
|
+
this.closeRequest.emit(true);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Escape en el menú raíz. En un submenú lo maneja aria (cierra solo el submenú). */
|
|
195
|
+
protected onKeydown(event: KeyboardEvent): void {
|
|
196
|
+
if (event.key === 'Escape' && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
this.closeRequest.emit(true);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Tab (o el foco que sale del menú por otro camino) cierra sin mover el foco. */
|
|
203
|
+
protected onFocusOut(event: FocusEvent): void {
|
|
204
|
+
const next = event.relatedTarget as Node | null;
|
|
205
|
+
if (!next || this.element.contains(next) || next === this.focusExempt()) return;
|
|
206
|
+
this.closeRequest.emit(false);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Clic derecho dentro del menú: ni el menú del navegador ni otro menú contextual de fuera. */
|
|
210
|
+
protected onContextMenu(event: MouseEvent): void {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
event.stopPropagation();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Tono de un ítem: un subconjunto de la lista común (spec 4, «Variante y tono»). Sin `tone`, el
|
|
218
|
+
* color normal del menú; no hay un valor «por defecto» con nombre.
|
|
219
|
+
*/
|
|
220
|
+
export type MimiMenuItemTone = 'danger';
|
|
221
|
+
|
|
222
|
+
/** Sin tono: activo (puntero o teclado) con fondo; deshabilitado, solo con el foco de teclado. */
|
|
223
|
+
const NO_TONE = [
|
|
224
|
+
'text-popover-foreground',
|
|
225
|
+
'not-aria-disabled:data-[active=true]:bg-[color:var(--mimi-menu-item-hover,var(--mimi-accent))]',
|
|
226
|
+
'aria-disabled:focus-visible:bg-[color:var(--mimi-menu-item-hover,var(--mimi-accent))]',
|
|
227
|
+
'aria-expanded:bg-[color:var(--mimi-menu-item-hover,var(--mimi-accent))]',
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
const DANGER = [
|
|
231
|
+
'text-[color:var(--mimi-menu-danger,var(--mimi-destructive-soft-foreground))]',
|
|
232
|
+
'not-aria-disabled:data-[active=true]:bg-[color:var(--mimi-menu-danger-hover,var(--mimi-destructive-soft-bg))]',
|
|
233
|
+
'aria-disabled:focus-visible:bg-[color:var(--mimi-menu-danger-hover,var(--mimi-destructive-soft-bg))]',
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
const MENU_ITEM_BASE = [
|
|
237
|
+
'relative flex h-[var(--mimi-menu-item-height,32px)] cursor-pointer items-center gap-2 px-2 text-sm outline-none select-none',
|
|
238
|
+
'rounded-[var(--mimi-menu-item-radius,var(--mimi-radius-sm))]',
|
|
239
|
+
'transition-[background-color] duration-120 ease-[ease] active:scale-[0.985]',
|
|
240
|
+
'aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:active:scale-100',
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Clases de un ítem de menú (también checkbox, radio y el disparador de un submenú).
|
|
245
|
+
* @internal No está en el índice; no forma parte de la API de Mimi.
|
|
246
|
+
*/
|
|
247
|
+
export function menuItemVariants({ tone }: { tone?: MimiMenuItemTone } = {}): string {
|
|
248
|
+
return cn(MENU_ITEM_BASE, tone === 'danger' ? DANGER : NO_TONE);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Columna de ícono de 16px. El check del checkbox va en `foreground`; los íconos, en muted. */
|
|
252
|
+
const ICON_COLUMN = 'inline-flex w-4 shrink-0 justify-center [&_svg]:size-4 [&_svg]:shrink-0';
|
|
253
|
+
const ICON_TONE = {
|
|
254
|
+
muted: 'text-muted-foreground',
|
|
255
|
+
checked: 'text-foreground',
|
|
256
|
+
danger: 'text-[color:var(--mimi-menu-danger,var(--mimi-destructive-soft-foreground))]',
|
|
257
|
+
};
|
|
258
|
+
const LABEL = 'min-w-0 flex-1 truncate';
|
|
259
|
+
const SHORTCUT =
|
|
260
|
+
'font-mono text-[11.5px] tracking-[.06em] text-[color:var(--mimi-menu-shortcut,var(--mimi-muted-foreground))]';
|
|
261
|
+
|
|
262
|
+
/** Registra el ítem en el menú raíz y devuelve el `value` interno de su `ngMenuItem` (único). */
|
|
263
|
+
function registerMenuItem(select: () => void): string {
|
|
264
|
+
const host = inject(MIMI_MENU_HOST);
|
|
265
|
+
const value = `mimi-menu-item-${nextId++}`;
|
|
266
|
+
host.register(value, select);
|
|
267
|
+
inject(DestroyRef).onDestroy(() => host.unregister(value));
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Texto del typeahead: `textValue` o, si no hay, el texto del ítem sin el atajo. */
|
|
272
|
+
function menuSearchTerm(
|
|
273
|
+
textValue: () => string,
|
|
274
|
+
label: () => ElementRef<HTMLElement> | undefined,
|
|
275
|
+
): () => string {
|
|
276
|
+
const text = signal('');
|
|
277
|
+
afterNextRender({ read: () => text.set(label()?.nativeElement.textContent?.trim() ?? '') });
|
|
278
|
+
return computed(() => textValue() || text());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Ítem de menú: `<mimi-menu-item (selected)="…">Ajustes</mimi-menu-item>`. Un `svg` (o un
|
|
283
|
+
* elemento con `mimiMenuIcon`) va a la columna de ícono; `shortcut` muestra el atajo a la derecha.
|
|
284
|
+
*/
|
|
285
|
+
@Component({
|
|
286
|
+
selector: 'mimi-menu-item',
|
|
287
|
+
imports: [MenuItem],
|
|
288
|
+
template: `
|
|
289
|
+
<div
|
|
290
|
+
ngMenuItem
|
|
291
|
+
[value]="value"
|
|
292
|
+
[disabled]="disabled()"
|
|
293
|
+
[searchTerm]="searchTerm()"
|
|
294
|
+
[class]="classes()"
|
|
295
|
+
[attr.data-tone]="tone()"
|
|
296
|
+
[attr.data-disabled]="disabled() ? '' : null"
|
|
297
|
+
>
|
|
298
|
+
<span aria-hidden="true" [class]="iconClasses()"
|
|
299
|
+
><ng-content select="svg,[mimiMenuIcon]"
|
|
300
|
+
/></span>
|
|
301
|
+
<span #label [class]="labelClasses"><ng-content /></span>
|
|
302
|
+
@if (shortcut()) {
|
|
303
|
+
<span [class]="shortcutClasses">{{ shortcut() }}</span>
|
|
304
|
+
}
|
|
305
|
+
</div>
|
|
306
|
+
`,
|
|
307
|
+
host: { style: 'display: contents', '[attr.class]': 'null' },
|
|
308
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
309
|
+
})
|
|
310
|
+
export class MimiMenuItem {
|
|
311
|
+
/** `danger` para acciones destructivas (texto y hover en rojo suave). Sin él, el color normal. */
|
|
312
|
+
readonly tone = input<MimiMenuItemTone>();
|
|
313
|
+
/** Atajo de teclado que se muestra (solo texto: no registra el atajo). */
|
|
314
|
+
readonly shortcut = input('');
|
|
315
|
+
/** No se puede elegir (recibe el foco, como pide el patrón de menú). */
|
|
316
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
317
|
+
/** Texto para la búsqueda por teclado, si el contenido no es solo texto. */
|
|
318
|
+
readonly textValue = input('');
|
|
319
|
+
/** Clases del ítem, mezcladas con `cn()` (gana la del usuario). */
|
|
320
|
+
readonly userClass = input('', { alias: 'class' });
|
|
321
|
+
/** Se eligió el ítem (clic, Enter o Espacio). El menú se cierra. */
|
|
322
|
+
readonly selected = output<void>();
|
|
323
|
+
|
|
324
|
+
private readonly label = viewChild<ElementRef<HTMLElement>>('label');
|
|
325
|
+
protected readonly value = registerMenuItem(() => this.selected.emit());
|
|
326
|
+
protected readonly searchTerm = menuSearchTerm(this.textValue, this.label);
|
|
327
|
+
|
|
328
|
+
protected readonly classes = computed(() =>
|
|
329
|
+
cn(menuItemVariants({ tone: this.tone() }), this.userClass()),
|
|
330
|
+
);
|
|
331
|
+
protected readonly iconClasses = computed(() =>
|
|
332
|
+
cn(ICON_COLUMN, this.tone() === 'danger' ? ICON_TONE.danger : ICON_TONE.muted),
|
|
333
|
+
);
|
|
334
|
+
protected readonly labelClasses = LABEL;
|
|
335
|
+
protected readonly shortcutClasses = SHORTCUT;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Ítem con casilla: `<mimi-menu-checkbox-item [(checked)]="…">`. Al marcarlo, el menú se cierra. */
|
|
339
|
+
@Component({
|
|
340
|
+
selector: 'mimi-menu-checkbox-item',
|
|
341
|
+
imports: [MenuItem],
|
|
342
|
+
template: `
|
|
343
|
+
<div
|
|
344
|
+
ngMenuItem
|
|
345
|
+
role="menuitemcheckbox"
|
|
346
|
+
[value]="value"
|
|
347
|
+
[disabled]="disabled()"
|
|
348
|
+
[searchTerm]="searchTerm()"
|
|
349
|
+
[class]="classes()"
|
|
350
|
+
[attr.aria-checked]="checked()"
|
|
351
|
+
[attr.data-state]="checked() ? 'checked' : 'unchecked'"
|
|
352
|
+
[attr.data-disabled]="disabled() ? '' : null"
|
|
353
|
+
>
|
|
354
|
+
<span aria-hidden="true" [class]="iconClasses">
|
|
355
|
+
@if (checked()) {
|
|
356
|
+
<svg
|
|
357
|
+
viewBox="0 0 24 24"
|
|
358
|
+
fill="none"
|
|
359
|
+
stroke="currentColor"
|
|
360
|
+
stroke-width="2"
|
|
361
|
+
stroke-linecap="round"
|
|
362
|
+
stroke-linejoin="round"
|
|
363
|
+
>
|
|
364
|
+
<path d="M20 6 9 17l-5-5" />
|
|
365
|
+
</svg>
|
|
366
|
+
}
|
|
367
|
+
</span>
|
|
368
|
+
<span #label [class]="labelClasses"><ng-content /></span>
|
|
369
|
+
@if (shortcut()) {
|
|
370
|
+
<span [class]="shortcutClasses">{{ shortcut() }}</span>
|
|
371
|
+
}
|
|
372
|
+
</div>
|
|
373
|
+
`,
|
|
374
|
+
host: { style: 'display: contents', '[attr.class]': 'null' },
|
|
375
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
376
|
+
})
|
|
377
|
+
export class MimiMenuCheckboxItem {
|
|
378
|
+
/** Marcado o no. Admite `[(checked)]`. */
|
|
379
|
+
readonly checked = model(false);
|
|
380
|
+
readonly shortcut = input('');
|
|
381
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
382
|
+
readonly textValue = input('');
|
|
383
|
+
readonly userClass = input('', { alias: 'class' });
|
|
384
|
+
|
|
385
|
+
private readonly label = viewChild<ElementRef<HTMLElement>>('label');
|
|
386
|
+
protected readonly value = registerMenuItem(() => this.checked.update((checked) => !checked));
|
|
387
|
+
protected readonly searchTerm = menuSearchTerm(this.textValue, this.label);
|
|
388
|
+
|
|
389
|
+
protected readonly classes = computed(() => cn(menuItemVariants(), this.userClass()));
|
|
390
|
+
protected readonly iconClasses = cn(ICON_COLUMN, ICON_TONE.checked);
|
|
391
|
+
protected readonly labelClasses = LABEL;
|
|
392
|
+
protected readonly shortcutClasses = SHORTCUT;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Grupo de opciones excluyentes: `<mimi-menu-radio-group [(value)]="orden">` con
|
|
397
|
+
* `<mimi-menu-radio-item value="…">`. Provisional: no está en el diseño (plan, D6).
|
|
398
|
+
*/
|
|
399
|
+
@Component({
|
|
400
|
+
selector: 'mimi-menu-radio-group',
|
|
401
|
+
template: '<ng-content />',
|
|
402
|
+
host: { role: 'group', '[attr.aria-label]': 'ariaLabel() || null', '[class]': 'classes()' },
|
|
403
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
404
|
+
})
|
|
405
|
+
export class MimiMenuRadioGroup {
|
|
406
|
+
/** Valor de la opción marcada. Admite `[(value)]`. */
|
|
407
|
+
readonly value = model<unknown>(undefined);
|
|
408
|
+
/** Nombre accesible del grupo (`aria-label`). */
|
|
409
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
410
|
+
readonly userClass = input('', { alias: 'class' });
|
|
411
|
+
protected readonly classes = computed(() => cn('flex flex-col', this.userClass()));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Opción de un `mimi-menu-radio-group`: punto en la columna del ícono si está marcada. */
|
|
415
|
+
@Component({
|
|
416
|
+
selector: 'mimi-menu-radio-item',
|
|
417
|
+
imports: [MenuItem],
|
|
418
|
+
template: `
|
|
419
|
+
<div
|
|
420
|
+
ngMenuItem
|
|
421
|
+
role="menuitemradio"
|
|
422
|
+
[value]="value"
|
|
423
|
+
[disabled]="disabled()"
|
|
424
|
+
[searchTerm]="searchTerm()"
|
|
425
|
+
[class]="classes()"
|
|
426
|
+
[attr.aria-checked]="checked()"
|
|
427
|
+
[attr.data-state]="checked() ? 'checked' : 'unchecked'"
|
|
428
|
+
[attr.data-disabled]="disabled() ? '' : null"
|
|
429
|
+
>
|
|
430
|
+
<span aria-hidden="true" [class]="iconClasses">
|
|
431
|
+
@if (checked()) {
|
|
432
|
+
<svg viewBox="0 0 24 24" class="size-2! fill-current">
|
|
433
|
+
<circle cx="12" cy="12" r="10" />
|
|
434
|
+
</svg>
|
|
435
|
+
}
|
|
436
|
+
</span>
|
|
437
|
+
<span #label [class]="labelClasses"><ng-content /></span>
|
|
438
|
+
</div>
|
|
439
|
+
`,
|
|
440
|
+
host: { style: 'display: contents', '[attr.class]': 'null' },
|
|
441
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
442
|
+
})
|
|
443
|
+
export class MimiMenuRadioItem {
|
|
444
|
+
private readonly group = inject(MimiMenuRadioGroup, { optional: true });
|
|
445
|
+
/** Valor de esta opción. */
|
|
446
|
+
readonly option = input.required<unknown>({ alias: 'value' });
|
|
447
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
448
|
+
readonly textValue = input('');
|
|
449
|
+
readonly userClass = input('', { alias: 'class' });
|
|
450
|
+
|
|
451
|
+
private readonly label = viewChild<ElementRef<HTMLElement>>('label');
|
|
452
|
+
protected readonly value = registerMenuItem(() => this.group?.value.set(this.option()));
|
|
453
|
+
protected readonly searchTerm = menuSearchTerm(this.textValue, this.label);
|
|
454
|
+
|
|
455
|
+
protected readonly checked = computed(() => this.group?.value() === this.option());
|
|
456
|
+
protected readonly classes = computed(() => cn(menuItemVariants(), this.userClass()));
|
|
457
|
+
protected readonly iconClasses = cn(ICON_COLUMN, 'items-center', ICON_TONE.checked);
|
|
458
|
+
protected readonly labelClasses = LABEL;
|
|
459
|
+
|
|
460
|
+
constructor() {
|
|
461
|
+
if (isDevMode() && !this.group) {
|
|
462
|
+
console.warn('[Mimi] Menu: pon <mimi-menu-radio-item> dentro de un <mimi-menu-radio-group>.');
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Etiqueta de un grupo de ítems (como «Cuenta» o «Vista» en el diseño). */
|
|
468
|
+
@Component({
|
|
469
|
+
selector: 'mimi-menu-label',
|
|
470
|
+
template: '<ng-content />',
|
|
471
|
+
host: { role: 'presentation', '[class]': 'classes()' },
|
|
472
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
473
|
+
})
|
|
474
|
+
export class MimiMenuLabel {
|
|
475
|
+
readonly userClass = input('', { alias: 'class' });
|
|
476
|
+
protected readonly classes = computed(() =>
|
|
477
|
+
cn(
|
|
478
|
+
'block px-2 pt-1.5 pb-1 text-[11px] font-semibold tracking-[.05em] text-muted-foreground uppercase',
|
|
479
|
+
this.userClass(),
|
|
480
|
+
),
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Línea entre grupos de ítems. */
|
|
485
|
+
@Component({
|
|
486
|
+
selector: 'mimi-menu-separator',
|
|
487
|
+
template: '',
|
|
488
|
+
host: { role: 'separator', '[class]': 'classes()' },
|
|
489
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
490
|
+
})
|
|
491
|
+
export class MimiMenuSeparator {
|
|
492
|
+
readonly userClass = input('', { alias: 'class' });
|
|
493
|
+
protected readonly classes = computed(() =>
|
|
494
|
+
cn('-mx-1 my-1 block h-px bg-border', this.userClass()),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Texto e ícono del ítem que abre un submenú (dentro de `mimi-menu-sub`). */
|
|
499
|
+
@Component({
|
|
500
|
+
selector: 'mimi-menu-sub-trigger',
|
|
501
|
+
template: `
|
|
502
|
+
<span aria-hidden="true" [class]="iconClasses"><ng-content select="svg,[mimiMenuIcon]" /></span>
|
|
503
|
+
<span #label [class]="labelClasses"><ng-content /></span>
|
|
504
|
+
`,
|
|
505
|
+
host: { style: 'display: contents', '[attr.class]': 'null' },
|
|
506
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
507
|
+
})
|
|
508
|
+
export class MimiMenuSubTrigger {
|
|
509
|
+
/** No abre el submenú. */
|
|
510
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
511
|
+
/** Texto para la búsqueda por teclado, si el contenido no es solo texto. */
|
|
512
|
+
readonly textValue = input('');
|
|
513
|
+
/** Clases del ítem, mezcladas con `cn()`. */
|
|
514
|
+
readonly userClass = input('', { alias: 'class' });
|
|
515
|
+
|
|
516
|
+
readonly label = viewChild<ElementRef<HTMLElement>>('label');
|
|
517
|
+
protected readonly iconClasses = cn(ICON_COLUMN, ICON_TONE.muted);
|
|
518
|
+
protected readonly labelClasses = LABEL;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Contenido del submenú: `<ng-template mimiMenuSubContent>` dentro de `mimi-menu-sub`. */
|
|
522
|
+
@Directive({ selector: 'ng-template[mimiMenuSubContent]' })
|
|
523
|
+
export class MimiMenuSubContent {
|
|
524
|
+
readonly templateRef = inject(TemplateRef);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Submenú: un `mimi-menu-sub-trigger` y su `<ng-template mimiMenuSubContent>`. Se abre con el
|
|
529
|
+
* puntero, con Enter, Espacio o →, y se cierra con ← o Escape (el foco vuelve a su ítem). Va a
|
|
530
|
+
* 10px del ítem; si no cabe a la derecha, pasa a la izquierda.
|
|
531
|
+
*/
|
|
532
|
+
@Component({
|
|
533
|
+
selector: 'mimi-menu-sub',
|
|
534
|
+
imports: [Menu, MenuItem, MimiMenuOutlet],
|
|
535
|
+
template: `
|
|
536
|
+
<div
|
|
537
|
+
ngMenuItem
|
|
538
|
+
[value]="value"
|
|
539
|
+
[submenu]="menu"
|
|
540
|
+
[attr.id]="triggerId"
|
|
541
|
+
[disabled]="trigger()?.disabled() ?? false"
|
|
542
|
+
[searchTerm]="searchTerm()"
|
|
543
|
+
[class]="itemClasses()"
|
|
544
|
+
[attr.data-disabled]="trigger()?.disabled() ? '' : null"
|
|
545
|
+
>
|
|
546
|
+
<ng-content select="mimi-menu-sub-trigger" />
|
|
547
|
+
<span aria-hidden="true" class="inline-flex text-muted-foreground">
|
|
548
|
+
<svg
|
|
549
|
+
class="size-3.5"
|
|
550
|
+
viewBox="0 0 24 24"
|
|
551
|
+
fill="none"
|
|
552
|
+
stroke="currentColor"
|
|
553
|
+
stroke-width="2"
|
|
554
|
+
stroke-linecap="round"
|
|
555
|
+
stroke-linejoin="round"
|
|
556
|
+
>
|
|
557
|
+
<path d="m9 18 6-6-6-6" />
|
|
558
|
+
</svg>
|
|
559
|
+
</span>
|
|
560
|
+
</div>
|
|
561
|
+
<div
|
|
562
|
+
ngMenu
|
|
563
|
+
#menu="ngMenu"
|
|
564
|
+
#panel
|
|
565
|
+
data-mimi-overlay-panel
|
|
566
|
+
[id]="menuId"
|
|
567
|
+
[hidden]="!menu.visible()"
|
|
568
|
+
[attr.aria-labelledby]="triggerId"
|
|
569
|
+
[attr.data-state]="menu.visible() ? 'open' : 'closed'"
|
|
570
|
+
[attr.data-side]="side()"
|
|
571
|
+
[style.translate]="shiftY() ? '0 ' + -shiftY() + 'px' : null"
|
|
572
|
+
[class]="panelClasses()"
|
|
573
|
+
>
|
|
574
|
+
@if (content(); as content) {
|
|
575
|
+
<ng-container [mimiMenuOutlet]="content.templateRef" />
|
|
576
|
+
}
|
|
577
|
+
</div>
|
|
578
|
+
`,
|
|
579
|
+
styles: MIMI_OVERLAY_ANIMATION,
|
|
580
|
+
host: { class: 'relative block' },
|
|
581
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
582
|
+
})
|
|
583
|
+
export class MimiMenuSub {
|
|
584
|
+
/** Clases del panel del submenú, mezcladas con `cn()`. */
|
|
585
|
+
readonly userClass = input('', { alias: 'class' });
|
|
586
|
+
|
|
587
|
+
protected readonly trigger = contentChild(MimiMenuSubTrigger, { descendants: false });
|
|
588
|
+
protected readonly content = contentChild(MimiMenuSubContent, { descendants: false });
|
|
589
|
+
private readonly menu = viewChild(Menu);
|
|
590
|
+
private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
|
|
591
|
+
|
|
592
|
+
protected readonly value = `mimi-menu-sub-${nextId++}`;
|
|
593
|
+
protected readonly triggerId = `${this.value}-trigger`;
|
|
594
|
+
protected readonly menuId = `${this.value}-menu`;
|
|
595
|
+
|
|
596
|
+
/** Lado en el que se abre: derecha, o izquierda si no cabe. */
|
|
597
|
+
protected readonly side = signal<'right' | 'left'>('right');
|
|
598
|
+
/** Cuánto sube el panel para no salir por abajo de la pantalla. */
|
|
599
|
+
protected readonly shiftY = signal(0);
|
|
600
|
+
|
|
601
|
+
private readonly labelText = signal('');
|
|
602
|
+
protected readonly searchTerm = computed(() => this.trigger()?.textValue() || this.labelText());
|
|
603
|
+
|
|
604
|
+
protected readonly itemClasses = computed(() =>
|
|
605
|
+
cn(menuItemVariants(), this.trigger()?.userClass()),
|
|
606
|
+
);
|
|
607
|
+
protected readonly panelClasses = computed(() =>
|
|
608
|
+
cn(
|
|
609
|
+
MIMI_MENU_PANEL_CLASSES,
|
|
610
|
+
'absolute top-[-5px] z-10 w-[200px] cursor-default',
|
|
611
|
+
'data-[side=right]:left-[calc(100%_+_10px)] data-[side=left]:right-[calc(100%_+_10px)]',
|
|
612
|
+
this.userClass(),
|
|
613
|
+
),
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
constructor() {
|
|
617
|
+
afterNextRender({
|
|
618
|
+
read: () =>
|
|
619
|
+
this.labelText.set(this.trigger()?.label()?.nativeElement.textContent?.trim() ?? ''),
|
|
620
|
+
});
|
|
621
|
+
// Al abrir: a la derecha; si no cabe, a la izquierda. Sube si sale por abajo.
|
|
622
|
+
const injector = inject(Injector);
|
|
623
|
+
effect(() => {
|
|
624
|
+
if (!this.menu()?.visible()) return;
|
|
625
|
+
untracked(() => {
|
|
626
|
+
this.side.set('right');
|
|
627
|
+
this.shiftY.set(0);
|
|
628
|
+
});
|
|
629
|
+
afterNextRender({ read: () => this.place() }, { injector });
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private place(): void {
|
|
634
|
+
const panel = this.panel().nativeElement;
|
|
635
|
+
const view = panel.ownerDocument.defaultView;
|
|
636
|
+
if (!view || panel.hidden) return;
|
|
637
|
+
const rect = panel.getBoundingClientRect();
|
|
638
|
+
if (rect.width === 0) return;
|
|
639
|
+
if (rect.right > view.innerWidth - MIMI_VIEWPORT_MARGIN) this.side.set('left');
|
|
640
|
+
const below = rect.bottom - (view.innerHeight - MIMI_VIEWPORT_MARGIN);
|
|
641
|
+
if (below > 0) this.shiftY.set(Math.min(below, Math.max(rect.top - MIMI_VIEWPORT_MARGIN, 0)));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Los ítems que traen `MimiDropdownMenuImports` y `MimiContextMenuImports`. */
|
|
646
|
+
export const MimiMenuItemsImports = [
|
|
647
|
+
MimiMenuItem,
|
|
648
|
+
MimiMenuCheckboxItem,
|
|
649
|
+
MimiMenuRadioGroup,
|
|
650
|
+
MimiMenuRadioItem,
|
|
651
|
+
MimiMenuLabel,
|
|
652
|
+
MimiMenuSeparator,
|
|
653
|
+
MimiMenuSub,
|
|
654
|
+
MimiMenuSubTrigger,
|
|
655
|
+
MimiMenuSubContent,
|
|
656
|
+
] as const;
|