@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,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
DestroyRef,
|
|
5
|
+
Directive,
|
|
6
|
+
Injector,
|
|
7
|
+
TemplateRef,
|
|
8
|
+
ViewContainerRef,
|
|
9
|
+
assertInInjectionContext,
|
|
10
|
+
effect,
|
|
11
|
+
inject,
|
|
12
|
+
input,
|
|
13
|
+
untracked,
|
|
14
|
+
} from '@angular/core';
|
|
15
|
+
import { MimiButton } from '@/components/ui/button';
|
|
16
|
+
import {
|
|
17
|
+
MIMI_DIALOG_DATA,
|
|
18
|
+
MimiDialogDescription,
|
|
19
|
+
MimiDialogRef,
|
|
20
|
+
MimiDialogService,
|
|
21
|
+
MimiDialogTitle,
|
|
22
|
+
} from '@/components/ui/dialog';
|
|
23
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
24
|
+
import { MIMI_CONFIRM_TEXT } from './confirm-texts';
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* Confirm (docs/design/Mimi F2 Overlays.dc.html, «2 · Confirm»; prefijo de tokens `confirm`,
|
|
28
|
+
* spec 13): un alertdialog de 380px, sin X, con un círculo de 40px para el ícono, el título, la
|
|
29
|
+
* descripción y dos botones de 36px a la derecha.
|
|
30
|
+
*
|
|
31
|
+
* Por dentro abre un Dialog (MimiDialogService): el foco, el fondo, Escape, el bloqueo del scroll
|
|
32
|
+
* y la animación son suyos. Confirm solo pone el contenido y traduce el cierre a true o false.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Opciones de `confirm()`. */
|
|
36
|
+
export interface MimiConfirmOptions {
|
|
37
|
+
/** La pregunta. Es el nombre accesible del diálogo. */
|
|
38
|
+
title: string;
|
|
39
|
+
/** Qué pasa si se confirma. Recomendada: un alertdialog la anuncia junto al título. */
|
|
40
|
+
description?: string;
|
|
41
|
+
/** Texto del botón que confirma. Por defecto, el de `MIMI_CONFIRM_TEXT`. */
|
|
42
|
+
confirmText?: string;
|
|
43
|
+
/** Texto del botón que cancela. Por defecto, el de `MIMI_CONFIRM_TEXT`. */
|
|
44
|
+
cancelText?: string;
|
|
45
|
+
/**
|
|
46
|
+
* `danger` para una acción destructiva: el botón de confirmar va en rojo y el foco inicial, en
|
|
47
|
+
* Cancelar. Por defecto, `primary` (el foco inicial va en Confirmar).
|
|
48
|
+
*/
|
|
49
|
+
tone?: 'primary' | 'danger';
|
|
50
|
+
/** Ícono dentro del círculo de color: una plantilla con un `<svg>`. Sin él, no hay círculo. */
|
|
51
|
+
icon?: TemplateRef<unknown>;
|
|
52
|
+
/** Clases del panel, mezcladas con `cn()` (gana la del usuario). */
|
|
53
|
+
class?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** La función que devuelve `injectMimiConfirm()`. */
|
|
57
|
+
export type MimiConfirmFn = (options: MimiConfirmOptions) => Promise<boolean>;
|
|
58
|
+
|
|
59
|
+
const ICON_CLASSES = {
|
|
60
|
+
primary: 'bg-info-soft text-info-soft-foreground',
|
|
61
|
+
danger:
|
|
62
|
+
'bg-[color:var(--mimi-confirm-icon-bg,var(--mimi-destructive-soft-bg))] text-[color:var(--mimi-confirm-icon-fg,var(--mimi-destructive-soft-foreground))]',
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
/** Crea la plantilla del ícono. Hace lo mismo que NgTemplateOutlet, sin @angular/common. */
|
|
66
|
+
@Directive({ selector: '[mimiConfirmIcon]' })
|
|
67
|
+
class MimiConfirmIcon {
|
|
68
|
+
readonly template = input.required<TemplateRef<unknown>>({ alias: 'mimiConfirmIcon' });
|
|
69
|
+
private readonly viewContainer = inject(ViewContainerRef);
|
|
70
|
+
|
|
71
|
+
private readonly render = effect(() => {
|
|
72
|
+
const template = this.template();
|
|
73
|
+
untracked(() => {
|
|
74
|
+
this.viewContainer.clear();
|
|
75
|
+
this.viewContainer.createEmbeddedView(template);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** El contenido del diálogo: ícono, título, descripción y los dos botones. */
|
|
81
|
+
@Component({
|
|
82
|
+
selector: 'mimi-confirm',
|
|
83
|
+
imports: [MimiButton, MimiDialogTitle, MimiDialogDescription, MimiConfirmIcon],
|
|
84
|
+
template: `
|
|
85
|
+
<div class="flex items-start gap-3.5">
|
|
86
|
+
@if (options.icon) {
|
|
87
|
+
<span aria-hidden="true" data-slot="icon" [class]="iconClasses">
|
|
88
|
+
<ng-container [mimiConfirmIcon]="options.icon" />
|
|
89
|
+
</span>
|
|
90
|
+
}
|
|
91
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
92
|
+
<h2 mimiDialogTitle class="text-base leading-[1.3] tracking-normal">
|
|
93
|
+
{{ options.title }}
|
|
94
|
+
</h2>
|
|
95
|
+
@if (options.description) {
|
|
96
|
+
<p mimiDialogDescription class="text-[13px] leading-normal text-pretty">
|
|
97
|
+
{{ options.description }}
|
|
98
|
+
</p>
|
|
99
|
+
}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
<!-- Los botones miden 36px de alto con 14px a los lados, como en el diseño. -->
|
|
103
|
+
<div class="flex justify-end gap-2">
|
|
104
|
+
<button
|
|
105
|
+
mimiBtn
|
|
106
|
+
type="button"
|
|
107
|
+
variant="outline"
|
|
108
|
+
data-slot="cancel"
|
|
109
|
+
class="h-9 px-3.5"
|
|
110
|
+
(click)="ref.close(false)"
|
|
111
|
+
>
|
|
112
|
+
{{ options.cancelText ?? text.cancel }}
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
mimiBtn
|
|
116
|
+
type="button"
|
|
117
|
+
[tone]="tone"
|
|
118
|
+
data-slot="confirm"
|
|
119
|
+
class="h-9 px-3.5"
|
|
120
|
+
(click)="ref.close(true)"
|
|
121
|
+
>
|
|
122
|
+
{{ options.confirmText ?? text.confirm }}
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
`,
|
|
126
|
+
host: { '[attr.data-tone]': 'tone' },
|
|
127
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
128
|
+
})
|
|
129
|
+
class MimiConfirmContent {
|
|
130
|
+
protected readonly options = inject<MimiConfirmOptions>(MIMI_DIALOG_DATA);
|
|
131
|
+
protected readonly ref = inject<MimiDialogRef<boolean>>(MimiDialogRef);
|
|
132
|
+
protected readonly text = inject(MIMI_CONFIRM_TEXT);
|
|
133
|
+
|
|
134
|
+
protected readonly tone = this.options.tone ?? 'primary';
|
|
135
|
+
protected readonly iconClasses = cn(
|
|
136
|
+
'inline-flex size-10 shrink-0 items-center justify-center rounded-full [&_svg]:size-5 [&_svg]:shrink-0',
|
|
137
|
+
ICON_CLASSES[this.tone],
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Prepara `confirm()`, que pregunta y espera la respuesta:
|
|
143
|
+
*
|
|
144
|
+
* ```ts
|
|
145
|
+
* private readonly confirm = injectMimiConfirm();
|
|
146
|
+
*
|
|
147
|
+
* async remove() {
|
|
148
|
+
* if (await this.confirm({ title: '¿Eliminar este proyecto?', tone: 'danger' })) { … }
|
|
149
|
+
* }
|
|
150
|
+
* ```
|
|
151
|
+
*
|
|
152
|
+
* Se llama al declarar la propiedad (o en el constructor): necesita el contexto de inyección.
|
|
153
|
+
* Así toma el injector del componente y los textos de `provideMimiConfirmText` de su ruta o del
|
|
154
|
+
* propio componente. Dentro de un método falla con NG0203.
|
|
155
|
+
*
|
|
156
|
+
* `confirm()` resuelve `true` si se confirmó y `false` en cualquier otro caso (Cancelar, Escape,
|
|
157
|
+
* una navegación, `closeAll()` o el componente que se destruye), después de la animación de
|
|
158
|
+
* salida y con el foco ya devuelto. Nunca rechaza: si el diálogo no se pudo abrir, el error va al
|
|
159
|
+
* `ErrorHandler` y la respuesta es `false`. El clic en el fondo no cierra.
|
|
160
|
+
*
|
|
161
|
+
* Como todo Dialog, se abre después del render en curso: desde un ítem de menú, el foco vuelve
|
|
162
|
+
* al botón del menú al cerrar.
|
|
163
|
+
*/
|
|
164
|
+
export function injectMimiConfirm(): MimiConfirmFn {
|
|
165
|
+
assertInInjectionContext(injectMimiConfirm);
|
|
166
|
+
const dialog = inject(MimiDialogService);
|
|
167
|
+
const injector = inject(Injector);
|
|
168
|
+
const destroyRef = inject(DestroyRef);
|
|
169
|
+
|
|
170
|
+
return (options) =>
|
|
171
|
+
new Promise<boolean>((resolve) => {
|
|
172
|
+
const ref = dialog.open<boolean, MimiConfirmOptions>(MimiConfirmContent, {
|
|
173
|
+
data: options,
|
|
174
|
+
role: 'alertdialog',
|
|
175
|
+
closeButton: false,
|
|
176
|
+
closeOnBackdropClick: false,
|
|
177
|
+
// Lo más seguro primero: en una acción destructiva, Enter no confirma por accidente.
|
|
178
|
+
autoFocus: options.tone === 'danger' ? '[data-slot="cancel"]' : '[data-slot="confirm"]',
|
|
179
|
+
// Por clase y no con `width`: así la clase del usuario puede cambiar el ancho.
|
|
180
|
+
class: cn('w-[var(--mimi-confirm-width,380px)] gap-3.5 p-5', options.class),
|
|
181
|
+
injector,
|
|
182
|
+
});
|
|
183
|
+
// Si el componente que pregunta se destruye antes de la respuesta, se cierra con false.
|
|
184
|
+
const stop = destroyRef.onDestroy(() => ref.close(false));
|
|
185
|
+
ref.afterClosed().subscribe((result) => {
|
|
186
|
+
stop();
|
|
187
|
+
resolve(result === true);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CdkConnectedOverlay,
|
|
3
|
+
type FlexibleConnectedPositionStrategyOrigin,
|
|
4
|
+
type FlexibleOverlayPopoverLocation,
|
|
5
|
+
type ScrollStrategy,
|
|
6
|
+
createCloseScrollStrategy,
|
|
7
|
+
createRepositionScrollStrategy,
|
|
8
|
+
} from '@angular/cdk/overlay';
|
|
9
|
+
import {
|
|
10
|
+
ChangeDetectionStrategy,
|
|
11
|
+
Component,
|
|
12
|
+
DOCUMENT,
|
|
13
|
+
DestroyRef,
|
|
14
|
+
Directive,
|
|
15
|
+
ElementRef,
|
|
16
|
+
Injector,
|
|
17
|
+
TemplateRef,
|
|
18
|
+
afterNextRender,
|
|
19
|
+
booleanAttribute,
|
|
20
|
+
computed,
|
|
21
|
+
contentChild,
|
|
22
|
+
effect,
|
|
23
|
+
inject,
|
|
24
|
+
input,
|
|
25
|
+
isDevMode,
|
|
26
|
+
model,
|
|
27
|
+
signal,
|
|
28
|
+
untracked,
|
|
29
|
+
viewChild,
|
|
30
|
+
} from '@angular/core';
|
|
31
|
+
import { MimiMenuItemsImports } from '@/components/ui/menu';
|
|
32
|
+
import { MimiMenuPanel } from '@/components/ui/menu/menu';
|
|
33
|
+
import {
|
|
34
|
+
MIMI_VIEWPORT_MARGIN,
|
|
35
|
+
mimiConnectedPositions,
|
|
36
|
+
mimiPointPositions,
|
|
37
|
+
mimiWaitForExit,
|
|
38
|
+
} from '@/components/ui/utils/overlay';
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* Context Menu (docs/design/Mimi F2 Overlays.dc.html, «6 · Context Menu»): el menú aparece en el
|
|
42
|
+
* punto del clic derecho, a 8px como mínimo de los bordes. Mismos ítems, panel y tokens que el
|
|
43
|
+
* Dropdown Menu (`@/components/ui/menu`, prefijo `menu`, spec 13).
|
|
44
|
+
*
|
|
45
|
+
* Se abre con el evento `contextmenu` (clic derecho, Shift+F10, la tecla de menú, la pulsación
|
|
46
|
+
* larga de Android). Con el puntero, el origen del CDK es el punto; con el teclado, el elemento
|
|
47
|
+
* enfocado. aria no tiene un patrón de menú contextual: la apertura y la posición son de Mimi y
|
|
48
|
+
* del CDK; el teclado dentro del menú, de aria (MimiMenuPanel).
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** Distancia al elemento enfocado cuando se abre con el teclado (la del Dropdown Menu). */
|
|
52
|
+
const OFFSET = 8;
|
|
53
|
+
/** Margen para atribuir un `contextmenu` a Shift+F10 o a la tecla de menú. */
|
|
54
|
+
const KEYBOARD_WINDOW = 1000;
|
|
55
|
+
|
|
56
|
+
let nextId = 0;
|
|
57
|
+
/** Eventos ya atendidos: con triggers anidados, abre el más interno. */
|
|
58
|
+
const handled = new WeakSet<Event>();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Menú contextual: uno o varios triggers (`[mimiContextMenuTrigger]`) y el contenido en un
|
|
62
|
+
* `<ng-template mimiContextMenuContent let-dato>`. No dibuja nada propio (`display: contents`): su
|
|
63
|
+
* `class` y su `aria-label` van al panel.
|
|
64
|
+
*/
|
|
65
|
+
@Component({
|
|
66
|
+
selector: 'mimi-context-menu',
|
|
67
|
+
imports: [CdkConnectedOverlay, MimiMenuPanel],
|
|
68
|
+
template: `
|
|
69
|
+
<ng-content />
|
|
70
|
+
<ng-template
|
|
71
|
+
cdkConnectedOverlay
|
|
72
|
+
[cdkConnectedOverlayOrigin]="origin()!"
|
|
73
|
+
[cdkConnectedOverlayOpen]="rendered()"
|
|
74
|
+
[cdkConnectedOverlayPositions]="positions()"
|
|
75
|
+
[cdkConnectedOverlayViewportMargin]="viewportMargin"
|
|
76
|
+
[cdkConnectedOverlayDisableClose]="true"
|
|
77
|
+
[cdkConnectedOverlayUsePopover]="popoverLocation"
|
|
78
|
+
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
|
|
79
|
+
cdkConnectedOverlayTransformOriginOn="[data-mimi-overlay-panel]"
|
|
80
|
+
(detach)="onDetach()"
|
|
81
|
+
(overlayOutsideClick)="onOutsideClick($event)"
|
|
82
|
+
>
|
|
83
|
+
<!-- Se recrea en cada apertura: aria no tiene una API pública para reiniciar el menú. -->
|
|
84
|
+
@for (key of [openKey()]; track key) {
|
|
85
|
+
@if (content(); as content) {
|
|
86
|
+
<mimi-menu-panel
|
|
87
|
+
[template]="content.templateRef"
|
|
88
|
+
[context]="context()"
|
|
89
|
+
[menuId]="menuId"
|
|
90
|
+
[label]="ariaLabel() ?? ''"
|
|
91
|
+
[state]="state()"
|
|
92
|
+
[class]="userClass()"
|
|
93
|
+
(closeRequest)="close($event)"
|
|
94
|
+
/>
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
</ng-template>
|
|
98
|
+
`,
|
|
99
|
+
host: {
|
|
100
|
+
style: 'display: contents',
|
|
101
|
+
// El class y el aria-label son del panel: en el host no tendrían efecto o se heredarían.
|
|
102
|
+
'[attr.class]': 'null',
|
|
103
|
+
'[attr.aria-label]': 'null',
|
|
104
|
+
'[attr.data-state]': 'open() ? "open" : "closed"',
|
|
105
|
+
'[attr.data-disabled]': 'disabled() ? "" : null',
|
|
106
|
+
},
|
|
107
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
108
|
+
})
|
|
109
|
+
export class MimiContextMenu {
|
|
110
|
+
private readonly document = inject(DOCUMENT);
|
|
111
|
+
private readonly injector = inject(Injector);
|
|
112
|
+
|
|
113
|
+
/** Abierto o cerrado. Admite `[(open)]`; abierto desde el código, va junto al primer trigger. */
|
|
114
|
+
readonly open = model(false);
|
|
115
|
+
/** Nombre accesible del menú (`aria-label`). Hace falta: no hay un botón con texto que lo nombre. */
|
|
116
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
117
|
+
/** Sin menú propio: el clic derecho muestra el del navegador. */
|
|
118
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
119
|
+
/** Clases del panel, mezcladas con `cn()` (gana la del usuario). */
|
|
120
|
+
readonly userClass = input('', { alias: 'class' });
|
|
121
|
+
|
|
122
|
+
protected readonly content = contentChild(MimiContextMenuContent);
|
|
123
|
+
private readonly panel = viewChild(MimiMenuPanel);
|
|
124
|
+
|
|
125
|
+
readonly menuId = `mimi-context-menu-${nextId++}`;
|
|
126
|
+
|
|
127
|
+
/** El panel está en el DOM (también mientras dura la animación de salida). */
|
|
128
|
+
protected readonly rendered = signal(false);
|
|
129
|
+
protected readonly state = signal<'open' | 'closed'>('closed');
|
|
130
|
+
/** Cambia en cada apertura para recrear el menú (ítem activo y submenús desde cero). */
|
|
131
|
+
protected readonly openKey = signal(0);
|
|
132
|
+
protected readonly origin = signal<FlexibleConnectedPositionStrategyOrigin | null>(null);
|
|
133
|
+
/** Abierto con el puntero (en un punto) o con el teclado (junto al elemento). */
|
|
134
|
+
private readonly byPointer = signal(false);
|
|
135
|
+
/** Dato del trigger que lo abrió (`let-x` en el contenido). */
|
|
136
|
+
protected readonly context = signal<unknown>(undefined);
|
|
137
|
+
/** El trigger que lo abrió (lleva `data-state="open"`). */
|
|
138
|
+
readonly activeTrigger = signal<MimiContextMenuTrigger | null>(null);
|
|
139
|
+
|
|
140
|
+
protected readonly viewportMargin = MIMI_VIEWPORT_MARGIN;
|
|
141
|
+
/** El panel va dentro de este componente, después de los triggers (orden de Tab). */
|
|
142
|
+
protected readonly popoverLocation: FlexibleOverlayPopoverLocation = {
|
|
143
|
+
type: 'parent',
|
|
144
|
+
element: inject<ElementRef<HTMLElement>>(ElementRef).nativeElement,
|
|
145
|
+
};
|
|
146
|
+
protected readonly positions = computed(() =>
|
|
147
|
+
this.byPointer() ? mimiPointPositions() : mimiConnectedPositions('bottom', 'start', OFFSET),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Al hacer scroll: abierto con el puntero se cierra (como el menú del sistema: el punto no
|
|
152
|
+
* sigue al contenido); abierto con el teclado, sigue al elemento.
|
|
153
|
+
*/
|
|
154
|
+
protected readonly scrollStrategy: ScrollStrategy = (() => {
|
|
155
|
+
const close = createCloseScrollStrategy(this.injector);
|
|
156
|
+
const reposition = createRepositionScrollStrategy(this.injector);
|
|
157
|
+
return {
|
|
158
|
+
attach: (overlayRef) => {
|
|
159
|
+
close.attach(overlayRef);
|
|
160
|
+
reposition.attach(overlayRef);
|
|
161
|
+
},
|
|
162
|
+
enable: () => (this.byPointer() ? close : reposition).enable(),
|
|
163
|
+
disable: () => {
|
|
164
|
+
close.disable();
|
|
165
|
+
reposition.disable();
|
|
166
|
+
},
|
|
167
|
+
detach: () => {
|
|
168
|
+
close.detach?.();
|
|
169
|
+
reposition.detach?.();
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
})();
|
|
173
|
+
|
|
174
|
+
private readonly triggers = new Set<MimiContextMenuTrigger>();
|
|
175
|
+
/** Elemento con el foco antes de abrir: lo recupera al cerrar. */
|
|
176
|
+
private previousFocus: HTMLElement | null = null;
|
|
177
|
+
/** timeStamp del último Shift+F10 o tecla de menú. */
|
|
178
|
+
private keyAt: number | null = null;
|
|
179
|
+
private closing = 0;
|
|
180
|
+
private refocus = true;
|
|
181
|
+
private warnedLabel = false;
|
|
182
|
+
|
|
183
|
+
private readonly syncOpen = effect(() => {
|
|
184
|
+
const open = this.open();
|
|
185
|
+
untracked(() => (open ? this.show() : this.hide()));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
private readonly syncDisabled = effect(() => {
|
|
189
|
+
if (this.disabled() && untracked(this.open)) this.close();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** Cierra. Con `refocus` (ítem elegido, Escape) el foco vuelve a donde estaba antes de abrir. */
|
|
193
|
+
close(refocus = true): void {
|
|
194
|
+
this.refocus = refocus;
|
|
195
|
+
this.open.set(false);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Lo usan los triggers. */
|
|
199
|
+
register(trigger: MimiContextMenuTrigger): void {
|
|
200
|
+
this.triggers.add(trigger);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
unregister(trigger: MimiContextMenuTrigger): void {
|
|
204
|
+
this.triggers.delete(trigger);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Shift+F10 o la tecla de menú: el `contextmenu` que sigue viene del teclado. */
|
|
208
|
+
markKeyboard(event: KeyboardEvent): void {
|
|
209
|
+
this.keyAt = event.timeStamp;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Abre desde un `contextmenu` en un trigger. */
|
|
213
|
+
openFrom(trigger: MimiContextMenuTrigger, event: MouseEvent): void {
|
|
214
|
+
if (this.disabled() || handled.has(event)) return;
|
|
215
|
+
handled.add(event);
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
// Chrome marca con button -1 el contextmenu del teclado; los demás, por la tecla de antes.
|
|
218
|
+
const keyboard =
|
|
219
|
+
event.button === -1 ||
|
|
220
|
+
(this.keyAt !== null && event.timeStamp - this.keyAt < KEYBOARD_WINDOW);
|
|
221
|
+
this.keyAt = null;
|
|
222
|
+
if (!this.rendered()) {
|
|
223
|
+
const active = this.document.activeElement;
|
|
224
|
+
this.previousFocus = active instanceof HTMLElement ? active : null;
|
|
225
|
+
}
|
|
226
|
+
this.activeTrigger.set(trigger);
|
|
227
|
+
this.context.set(trigger.data());
|
|
228
|
+
this.byPointer.set(!keyboard);
|
|
229
|
+
const target = event.target instanceof Element ? event.target : trigger.element;
|
|
230
|
+
this.origin.set(keyboard ? target : { x: event.clientX, y: event.clientY });
|
|
231
|
+
if (this.rendered()) this.reopen();
|
|
232
|
+
this.open.set(true);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private reopen(): void {
|
|
236
|
+
this.openKey.update((key) => key + 1);
|
|
237
|
+
// El modo de scroll puede haber cambiado (puntero o teclado).
|
|
238
|
+
this.scrollStrategy.disable();
|
|
239
|
+
this.scrollStrategy.enable();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private show(): void {
|
|
243
|
+
this.closing++;
|
|
244
|
+
if (!this.origin()) {
|
|
245
|
+
// Abierto desde el código: junto al primer trigger.
|
|
246
|
+
const first = this.triggers.values().next().value;
|
|
247
|
+
if (!first) {
|
|
248
|
+
this.open.set(false);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
this.activeTrigger.set(first);
|
|
252
|
+
this.context.set(first.data());
|
|
253
|
+
this.byPointer.set(false);
|
|
254
|
+
this.origin.set(first.element);
|
|
255
|
+
}
|
|
256
|
+
this.warnMissingLabel();
|
|
257
|
+
this.state.set('open');
|
|
258
|
+
if (this.rendered()) this.openKey.update((key) => key + 1);
|
|
259
|
+
else this.rendered.set(true);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private hide(): void {
|
|
263
|
+
if (!this.rendered()) return;
|
|
264
|
+
this.state.set('closed');
|
|
265
|
+
const panel = this.panel()?.element;
|
|
266
|
+
const active = this.document.activeElement;
|
|
267
|
+
const focusInside = !active || active === this.document.body || panel?.contains(active);
|
|
268
|
+
if (this.refocus && focusInside) this.restoreFocus();
|
|
269
|
+
this.refocus = true;
|
|
270
|
+
const closing = ++this.closing;
|
|
271
|
+
afterNextRender(
|
|
272
|
+
{
|
|
273
|
+
read: () => {
|
|
274
|
+
void mimiWaitForExit(panel).then(() => {
|
|
275
|
+
if (closing !== this.closing) return;
|
|
276
|
+
this.rendered.set(false);
|
|
277
|
+
this.origin.set(null);
|
|
278
|
+
this.activeTrigger.set(null);
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{ injector: this.injector },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** El elemento que tenía el foco antes de abrir o, si ya no está, el trigger (si es enfocable). */
|
|
287
|
+
private restoreFocus(): void {
|
|
288
|
+
const previous = this.previousFocus;
|
|
289
|
+
const target =
|
|
290
|
+
previous?.isConnected && previous !== this.document.body
|
|
291
|
+
? previous
|
|
292
|
+
: this.activeTrigger()?.element;
|
|
293
|
+
target?.focus();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
protected onDetach(): void {
|
|
297
|
+
// El CDK lo cerró solo (scroll): el foco estaba en el menú, que ya no existe.
|
|
298
|
+
const wasOpen = this.open();
|
|
299
|
+
this.rendered.set(false);
|
|
300
|
+
this.origin.set(null);
|
|
301
|
+
this.activeTrigger.set(null);
|
|
302
|
+
if (!wasOpen) return;
|
|
303
|
+
this.open.set(false);
|
|
304
|
+
const active = this.document.activeElement;
|
|
305
|
+
if (!active || active === this.document.body) this.restoreFocus();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Clic fuera cierra; un clic derecho sobre otro trigger lo reabre ahí (lo hace el trigger). */
|
|
309
|
+
protected onOutsideClick(event: MouseEvent): void {
|
|
310
|
+
const target = event.target as Node | null;
|
|
311
|
+
if (
|
|
312
|
+
event.type === 'contextmenu' &&
|
|
313
|
+
[...this.triggers].some((t) => t.element.contains(target))
|
|
314
|
+
) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.close(false);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private warnMissingLabel(): void {
|
|
321
|
+
if (!isDevMode() || this.warnedLabel || this.ariaLabel()) return;
|
|
322
|
+
this.warnedLabel = true;
|
|
323
|
+
console.warn(
|
|
324
|
+
'[Mimi] Context Menu: el menú no tiene nombre accesible. Agrega aria-label="…" al <mimi-context-menu>.',
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Zona que abre el menú con clic derecho, Shift+F10 o la tecla de menú: cualquier elemento, uno o
|
|
331
|
+
* varios (por ejemplo, cada fila de una tabla). `mimiContextMenuTriggerData` llega al contenido.
|
|
332
|
+
*/
|
|
333
|
+
@Directive({
|
|
334
|
+
selector: '[mimiContextMenuTrigger]',
|
|
335
|
+
host: {
|
|
336
|
+
'[attr.data-state]': 'active() ? "open" : "closed"',
|
|
337
|
+
'(contextmenu)': 'menu.openFrom(this, $event)',
|
|
338
|
+
'(keydown)': 'onKeydown($event)',
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
export class MimiContextMenuTrigger {
|
|
342
|
+
protected readonly menu = inject(MimiContextMenu);
|
|
343
|
+
readonly element = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
344
|
+
/** Dato para el contenido (`<ng-template mimiContextMenuContent let-fila>`). */
|
|
345
|
+
readonly data = input<unknown>(undefined, { alias: 'mimiContextMenuTriggerData' });
|
|
346
|
+
|
|
347
|
+
protected readonly active = computed(
|
|
348
|
+
() => this.menu.open() && this.menu.activeTrigger() === this,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
constructor() {
|
|
352
|
+
this.menu.register(this);
|
|
353
|
+
inject(DestroyRef).onDestroy(() => this.menu.unregister(this));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
protected onKeydown(event: KeyboardEvent): void {
|
|
357
|
+
if ((event.key === 'F10' && event.shiftKey) || event.key === 'ContextMenu') {
|
|
358
|
+
this.menu.markKeyboard(event);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Contenido del menú: `<ng-template mimiContextMenuContent let-dato>`. Se crea en cada apertura y
|
|
365
|
+
* se destruye al cerrar. `dato` es el `mimiContextMenuTriggerData` del trigger (sin tipo: anótalo
|
|
366
|
+
* en tu componente si lo necesitas).
|
|
367
|
+
*/
|
|
368
|
+
@Directive({ selector: 'ng-template[mimiContextMenuContent]' })
|
|
369
|
+
export class MimiContextMenuContent {
|
|
370
|
+
readonly templateRef = inject(TemplateRef);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export const MimiContextMenuImports = [
|
|
374
|
+
MimiContextMenu,
|
|
375
|
+
MimiContextMenuTrigger,
|
|
376
|
+
MimiContextMenuContent,
|
|
377
|
+
...MimiMenuItemsImports,
|
|
378
|
+
] as const;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './context-menu';
|
|
2
|
+
// Los ítems y sus tipos, para importar todo desde @/components/ui/context-menu.
|
|
3
|
+
export {
|
|
4
|
+
MimiMenuCheckboxItem,
|
|
5
|
+
MimiMenuItem,
|
|
6
|
+
type MimiMenuItemTone,
|
|
7
|
+
MimiMenuLabel,
|
|
8
|
+
MimiMenuRadioGroup,
|
|
9
|
+
MimiMenuRadioItem,
|
|
10
|
+
MimiMenuSeparator,
|
|
11
|
+
MimiMenuSub,
|
|
12
|
+
MimiMenuSubContent,
|
|
13
|
+
MimiMenuSubTrigger,
|
|
14
|
+
} from '@/components/ui/menu';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { InjectionToken, type Provider } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Textos de Dialog, en archivo propio y sin @angular/cdk: así se pueden proveer en
|
|
5
|
+
* `app.config.ts` sin llevar el Dialog al chunk inicial.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Textos que Dialog anuncia. */
|
|
9
|
+
export interface MimiDialogText {
|
|
10
|
+
/** Nombre accesible del botón de cerrar (la X). */
|
|
11
|
+
close: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Textos predeterminados (inglés). */
|
|
15
|
+
export const MIMI_DIALOG_TEXT_EN: MimiDialogText = {
|
|
16
|
+
close: 'Close',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Textos en español: `provideMimiDialogText(MIMI_DIALOG_TEXT_ES)`. */
|
|
20
|
+
export const MIMI_DIALOG_TEXT_ES: MimiDialogText = {
|
|
21
|
+
close: 'Cerrar',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Textos activos. Sin `provideMimiDialogText`, los predeterminados en inglés. */
|
|
25
|
+
export const MIMI_DIALOG_TEXT = new InjectionToken<MimiDialogText>('MIMI_DIALOG_TEXT', {
|
|
26
|
+
providedIn: 'root',
|
|
27
|
+
factory: () => MIMI_DIALOG_TEXT_EN,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cambia los textos de Dialog. Lo que no definas sigue en inglés:
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* provideMimiDialogText(MIMI_DIALOG_TEXT_ES)
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* Va en `app.config.ts` o en los `providers` de un componente o ruta.
|
|
38
|
+
*/
|
|
39
|
+
export function provideMimiDialogText(text: Partial<MimiDialogText>): Provider {
|
|
40
|
+
return { provide: MIMI_DIALOG_TEXT, useValue: { ...MIMI_DIALOG_TEXT_EN, ...text } };
|
|
41
|
+
}
|