@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.
Files changed (104) hide show
  1. package/README.md +28 -1
  2. package/dist/init/css.js +17 -0
  3. package/dist/init/index.js +7 -5
  4. package/dist/init/items.js +9 -0
  5. package/dist/registry.json +482 -1
  6. package/dist/templates/components/avatar/avatar.ts +1 -1
  7. package/dist/templates/components/avatar/avatar.variants.ts +4 -4
  8. package/dist/templates/components/badge/badge.ts +41 -6
  9. package/dist/templates/components/badge/badge.variants.ts +105 -11
  10. package/dist/templates/components/button/button.ts +74 -7
  11. package/dist/templates/components/button/button.variants.ts +145 -21
  12. package/dist/templates/components/calendar/calendar-texts.ts +72 -0
  13. package/dist/templates/components/calendar/calendar.ts +588 -0
  14. package/dist/templates/components/calendar/dates.ts +181 -0
  15. package/dist/templates/components/calendar/index.ts +3 -0
  16. package/dist/templates/components/card/card.ts +8 -5
  17. package/dist/templates/components/checkbox/checkbox.ts +3 -3
  18. package/dist/templates/components/chip/chip-texts.ts +40 -0
  19. package/dist/templates/components/chip/chip.ts +116 -0
  20. package/dist/templates/components/chip/index.ts +2 -0
  21. package/dist/templates/components/color-picker/color-picker-texts.ts +57 -0
  22. package/dist/templates/components/color-picker/color-picker.ts +718 -0
  23. package/dist/templates/components/color-picker/color.ts +157 -0
  24. package/dist/templates/components/color-picker/index.ts +3 -0
  25. package/dist/templates/components/combobox/combobox-texts.ts +70 -0
  26. package/dist/templates/components/combobox/combobox.ts +798 -0
  27. package/dist/templates/components/combobox/index.ts +2 -0
  28. package/dist/templates/components/confirm/confirm-texts.ts +46 -0
  29. package/dist/templates/components/confirm/confirm.ts +190 -0
  30. package/dist/templates/components/confirm/index.ts +2 -0
  31. package/dist/templates/components/context-menu/context-menu.ts +378 -0
  32. package/dist/templates/components/context-menu/index.ts +14 -0
  33. package/dist/templates/components/date-picker/date-picker.ts +240 -0
  34. package/dist/templates/components/date-picker/index.ts +3 -0
  35. package/dist/templates/components/date-range/date-range.ts +308 -0
  36. package/dist/templates/components/date-range/index.ts +1 -0
  37. package/dist/templates/components/dialog/dialog-texts.ts +41 -0
  38. package/dist/templates/components/dialog/dialog.ts +701 -0
  39. package/dist/templates/components/dialog/index.ts +18 -0
  40. package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
  41. package/dist/templates/components/dropdown-menu/index.ts +14 -0
  42. package/dist/templates/components/file-upload/file-upload-texts.ts +77 -0
  43. package/dist/templates/components/file-upload/file-upload.ts +698 -0
  44. package/dist/templates/components/file-upload/index.ts +2 -0
  45. package/dist/templates/components/form-field/form-error.ts +1 -1
  46. package/dist/templates/components/form-field/form-field.ts +3 -2
  47. package/dist/templates/components/input/input.variants.ts +6 -4
  48. package/dist/templates/components/input-mask/index.ts +1 -0
  49. package/dist/templates/components/input-mask/input-mask.ts +319 -0
  50. package/dist/templates/components/input-otp/index.ts +1 -0
  51. package/dist/templates/components/input-otp/input-otp.ts +212 -0
  52. package/dist/templates/components/menu/index.ts +15 -0
  53. package/dist/templates/components/menu/menu.ts +656 -0
  54. package/dist/templates/components/number-input/index.ts +2 -0
  55. package/dist/templates/components/number-input/number-input-texts.ts +46 -0
  56. package/dist/templates/components/number-input/number-input.ts +450 -0
  57. package/dist/templates/components/option-list/index.ts +11 -0
  58. package/dist/templates/components/option-list/option-list.ts +518 -0
  59. package/dist/templates/components/pagination/index.ts +2 -0
  60. package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
  61. package/dist/templates/components/pagination/pagination-texts.ts +69 -0
  62. package/dist/templates/components/pagination/pagination.ts +443 -0
  63. package/dist/templates/components/password/index.ts +2 -0
  64. package/dist/templates/components/password/password-texts.ts +68 -0
  65. package/dist/templates/components/password/password.ts +363 -0
  66. package/dist/templates/components/popover/index.ts +12 -0
  67. package/dist/templates/components/popover/popover.ts +407 -0
  68. package/dist/templates/components/progress/index.ts +1 -0
  69. package/dist/templates/components/progress/progress.ts +217 -0
  70. package/dist/templates/components/select/index.ts +12 -0
  71. package/dist/templates/components/select/select-texts.ts +62 -0
  72. package/dist/templates/components/select/select.ts +1086 -0
  73. package/dist/templates/components/separator/separator.variants.ts +10 -7
  74. package/dist/templates/components/skeleton/skeleton.ts +4 -1
  75. package/dist/templates/components/slider/index.ts +2 -0
  76. package/dist/templates/components/slider/slider-texts.ts +26 -0
  77. package/dist/templates/components/slider/slider.ts +425 -0
  78. package/dist/templates/components/switch/switch.ts +6 -6
  79. package/dist/templates/components/tabs/index.ts +2 -0
  80. package/dist/templates/components/tabs/tabs.ts +426 -0
  81. package/dist/templates/components/tags-input/index.ts +2 -0
  82. package/dist/templates/components/tags-input/tags-input-texts.ts +44 -0
  83. package/dist/templates/components/tags-input/tags-input.ts +303 -0
  84. package/dist/templates/components/textarea/textarea.variants.ts +14 -11
  85. package/dist/templates/components/toast/index.ts +3 -0
  86. package/dist/templates/components/toast/toast-config.ts +45 -0
  87. package/dist/templates/components/toast/toast-texts.ts +46 -0
  88. package/dist/templates/components/toast/toast.ts +1000 -0
  89. package/dist/templates/components/toggle-group/index.ts +1 -0
  90. package/dist/templates/components/toggle-group/toggle-group.ts +406 -0
  91. package/dist/templates/components/toolbar/index.ts +1 -0
  92. package/dist/templates/components/toolbar/toolbar.ts +377 -0
  93. package/dist/templates/components/tooltip/index.ts +7 -0
  94. package/dist/templates/components/tooltip/tooltip.ts +407 -0
  95. package/dist/templates/theme/provider.ts +75 -6
  96. package/dist/templates/theme/theme-base.css +410 -9
  97. package/dist/templates/theme/types.ts +494 -4
  98. package/dist/templates/utils/cn.ts +15 -1
  99. package/dist/templates/utils/control-styles.ts +2 -2
  100. package/dist/templates/utils/overlay.ts +153 -0
  101. package/dist/ui/index.js +184 -8
  102. package/dist/ui/schema.json +100 -0
  103. package/dist/utils/write-files.js +166 -59
  104. package/package.json +1 -1
@@ -0,0 +1,2 @@
1
+ export * from './combobox';
2
+ export * from './combobox-texts';
@@ -0,0 +1,46 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /*
4
+ * Textos de Confirm, 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 de los botones de Confirm. */
9
+ export interface MimiConfirmText {
10
+ /** Botón que confirma. */
11
+ confirm: string;
12
+ /** Botón que cancela. */
13
+ cancel: string;
14
+ }
15
+
16
+ /** Textos predeterminados (inglés). */
17
+ export const MIMI_CONFIRM_TEXT_EN: MimiConfirmText = {
18
+ confirm: 'Confirm',
19
+ cancel: 'Cancel',
20
+ };
21
+
22
+ /** Textos en español: `provideMimiConfirmText(MIMI_CONFIRM_TEXT_ES)`. */
23
+ export const MIMI_CONFIRM_TEXT_ES: MimiConfirmText = {
24
+ confirm: 'Confirmar',
25
+ cancel: 'Cancelar',
26
+ };
27
+
28
+ /** Textos activos. Sin `provideMimiConfirmText`, los predeterminados en inglés. */
29
+ export const MIMI_CONFIRM_TEXT = new InjectionToken<MimiConfirmText>('MIMI_CONFIRM_TEXT', {
30
+ providedIn: 'root',
31
+ factory: () => MIMI_CONFIRM_TEXT_EN,
32
+ });
33
+
34
+ /**
35
+ * Cambia los textos de Confirm. Lo que no definas sigue en inglés:
36
+ *
37
+ * ```ts
38
+ * provideMimiConfirmText(MIMI_CONFIRM_TEXT_ES)
39
+ * ```
40
+ *
41
+ * Va en `app.config.ts` o en los `providers` de un componente o ruta. `confirmText` y
42
+ * `cancelText` de cada llamada ganan sobre estos.
43
+ */
44
+ export function provideMimiConfirmText(text: Partial<MimiConfirmText>): Provider {
45
+ return { provide: MIMI_CONFIRM_TEXT, useValue: { ...MIMI_CONFIRM_TEXT_EN, ...text } };
46
+ }
@@ -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,2 @@
1
+ export * from './confirm';
2
+ export * from './confirm-texts';
@@ -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';