@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.
Files changed (64) hide show
  1. package/README.md +19 -1
  2. package/dist/collection.json +1 -1
  3. package/dist/init/css.js +17 -0
  4. package/dist/init/index.js +7 -5
  5. package/dist/init/items.js +9 -0
  6. package/dist/ng-add/index.js +17 -1
  7. package/dist/ng-add/schema.json +27 -0
  8. package/dist/registry.json +222 -1
  9. package/dist/templates/components/avatar/avatar.ts +1 -1
  10. package/dist/templates/components/avatar/avatar.variants.ts +4 -4
  11. package/dist/templates/components/badge/badge.ts +41 -6
  12. package/dist/templates/components/badge/badge.variants.ts +105 -11
  13. package/dist/templates/components/button/button.ts +74 -7
  14. package/dist/templates/components/button/button.variants.ts +145 -21
  15. package/dist/templates/components/card/card.ts +8 -5
  16. package/dist/templates/components/checkbox/checkbox.ts +3 -3
  17. package/dist/templates/components/confirm/confirm-texts.ts +46 -0
  18. package/dist/templates/components/confirm/confirm.ts +190 -0
  19. package/dist/templates/components/confirm/index.ts +2 -0
  20. package/dist/templates/components/context-menu/context-menu.ts +378 -0
  21. package/dist/templates/components/context-menu/index.ts +14 -0
  22. package/dist/templates/components/dialog/dialog-texts.ts +41 -0
  23. package/dist/templates/components/dialog/dialog.ts +701 -0
  24. package/dist/templates/components/dialog/index.ts +18 -0
  25. package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
  26. package/dist/templates/components/dropdown-menu/index.ts +14 -0
  27. package/dist/templates/components/form-field/form-error.ts +1 -1
  28. package/dist/templates/components/form-field/form-field.ts +3 -2
  29. package/dist/templates/components/input/input.variants.ts +6 -4
  30. package/dist/templates/components/menu/index.ts +15 -0
  31. package/dist/templates/components/menu/menu.ts +656 -0
  32. package/dist/templates/components/pagination/index.ts +2 -0
  33. package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
  34. package/dist/templates/components/pagination/pagination-texts.ts +69 -0
  35. package/dist/templates/components/pagination/pagination.ts +443 -0
  36. package/dist/templates/components/popover/index.ts +12 -0
  37. package/dist/templates/components/popover/popover.ts +399 -0
  38. package/dist/templates/components/select/index.ts +12 -0
  39. package/dist/templates/components/select/select-texts.ts +62 -0
  40. package/dist/templates/components/select/select.ts +1085 -0
  41. package/dist/templates/components/separator/separator.variants.ts +10 -7
  42. package/dist/templates/components/skeleton/skeleton.ts +4 -1
  43. package/dist/templates/components/switch/switch.ts +6 -6
  44. package/dist/templates/components/tabs/index.ts +2 -0
  45. package/dist/templates/components/tabs/tabs.ts +426 -0
  46. package/dist/templates/components/textarea/textarea.variants.ts +14 -11
  47. package/dist/templates/components/toast/index.ts +3 -0
  48. package/dist/templates/components/toast/toast-config.ts +45 -0
  49. package/dist/templates/components/toast/toast-texts.ts +46 -0
  50. package/dist/templates/components/toast/toast.ts +1000 -0
  51. package/dist/templates/components/toolbar/index.ts +1 -0
  52. package/dist/templates/components/toolbar/toolbar.ts +377 -0
  53. package/dist/templates/components/tooltip/index.ts +7 -0
  54. package/dist/templates/components/tooltip/tooltip.ts +407 -0
  55. package/dist/templates/theme/provider.ts +62 -6
  56. package/dist/templates/theme/theme-base.css +307 -9
  57. package/dist/templates/theme/types.ts +278 -4
  58. package/dist/templates/utils/cn.ts +15 -1
  59. package/dist/templates/utils/control-styles.ts +2 -2
  60. package/dist/templates/utils/overlay.ts +140 -0
  61. package/dist/ui/index.js +80 -8
  62. package/dist/ui/schema.json +44 -0
  63. package/dist/utils/write-files.js +156 -59
  64. package/package.json +6 -5
@@ -0,0 +1,74 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ inject,
6
+ input,
7
+ linkedSignal,
8
+ output,
9
+ } from '@angular/core';
10
+ import { type MimiOption, MimiSelect } from '@/components/ui/select';
11
+ import { MIMI_PAGINATION_TEXT } from './pagination-texts';
12
+
13
+ /**
14
+ * Uso interno de Pagination (se exporta porque el compilador lo exige): el selector de registros
15
+ * por página. Va en su propio archivo para que Pagination lo cargue con `@defer`: Select, aria y el
16
+ * overlay solo llegan si hay `pageSizeOptions`.
17
+ */
18
+ @Component({
19
+ selector: 'mimi-pagination-page-size',
20
+ imports: [MimiSelect],
21
+ template: `
22
+ <mimi-select
23
+ side="top"
24
+ align="end"
25
+ [matchWidth]="false"
26
+ [options]="selectOptions()"
27
+ [(value)]="selected"
28
+ [aria-label]="text.pageSize"
29
+ [class]="triggerClasses()"
30
+ panelClass="min-w-[120px] [--mimi-select-option-height:30px]"
31
+ (valueChange)="onChange($event)"
32
+ />
33
+ `,
34
+ host: { style: 'display: contents' },
35
+ changeDetection: ChangeDetectionStrategy.OnPush,
36
+ })
37
+ export class MimiPaginationPageSize {
38
+ protected readonly text = inject(MIMI_PAGINATION_TEXT);
39
+
40
+ readonly options = input.required<readonly number[]>();
41
+ readonly value = input.required<number>();
42
+ readonly size = input<'sm' | 'default'>('sm');
43
+ /** Mientras está deshabilitado, una elección se descarta (sin `disabled` nativo: no se pierde el foco). */
44
+ readonly blocked = input(false);
45
+ readonly changed = output<number>();
46
+
47
+ /** Lo que muestra Select; vuelve al valor de Pagination si se descarta una elección. */
48
+ protected readonly selected = linkedSignal<number | null>(() => this.value());
49
+
50
+ protected readonly selectOptions = computed<MimiOption<number>[]>(() =>
51
+ this.options().map((size) => ({ value: size, label: this.text.pageSizeOption(size) })),
52
+ );
53
+
54
+ protected readonly triggerClasses = computed(() =>
55
+ [
56
+ // El botón «10 / pág.» del diseño: sin borde, transparente, redondo y del alto de los demás.
57
+ 'w-auto gap-1 rounded-full border-0 bg-transparent pr-2 pl-2.5 font-semibold tabular-nums',
58
+ 'hover:bg-accent data-[state=open]:bg-accent has-[[role=combobox]:active]:scale-95',
59
+ // El chevron mira hacia arriba (el panel abre arriba) y mide 14px.
60
+ '[&>svg:last-child]:size-3.5 [&>svg:last-child]:-scale-y-100',
61
+ this.size() === 'default'
62
+ ? 'h-[var(--mimi-pagination-item-size,var(--mimi-control-height,2.5rem))] text-sm'
63
+ : 'h-[var(--mimi-pagination-item-size,var(--mimi-control-height-sm,2rem))] text-[13px]',
64
+ ].join(' '),
65
+ );
66
+
67
+ protected onChange(size: number | null): void {
68
+ if (size === null || this.blocked()) {
69
+ this.selected.set(this.value());
70
+ return;
71
+ }
72
+ this.changed.emit(size);
73
+ }
74
+ }
@@ -0,0 +1,69 @@
1
+ import { InjectionToken, type Provider } from '@angular/core';
2
+
3
+ /*
4
+ * Textos de Pagination, en archivo propio y sin @angular/cdk ni Select: así se pueden proveer en
5
+ * `app.config.ts` sin llevar la paginación al chunk inicial.
6
+ */
7
+
8
+ /** Textos que Pagination muestra o anuncia. Los que dependen de un dato son funciones. */
9
+ export interface MimiPaginationText {
10
+ /** Nombre de la región de navegación. */
11
+ label: string;
12
+ /** Nombre del botón ←. */
13
+ previous: string;
14
+ /** Nombre del botón →. */
15
+ next: string;
16
+ /** Nombre del campo de página, con el número de páginas: «Página, de 12». */
17
+ page: (pages: number) => string;
18
+ /** Nombre del selector de registros por página. */
19
+ pageSize: string;
20
+ /** Cada opción del selector: «10 / pág.». */
21
+ pageSizeOption: (size: number) => string;
22
+ /** Error del campo: «Entre 1 y 12». */
23
+ outOfRange: (pages: number) => string;
24
+ /** Anuncio al cambiar de página: «Página 3 de 12». */
25
+ announce: (page: number, pages: number) => string;
26
+ }
27
+
28
+ /** Textos predeterminados (inglés). */
29
+ export const MIMI_PAGINATION_TEXT_EN: MimiPaginationText = {
30
+ label: 'Pagination',
31
+ previous: 'Previous page',
32
+ next: 'Next page',
33
+ page: (pages) => `Page, of ${pages}`,
34
+ pageSize: 'Rows per page',
35
+ pageSizeOption: (size) => `${size} / page`,
36
+ outOfRange: (pages) => `Between 1 and ${pages}`,
37
+ announce: (page, pages) => `Page ${page} of ${pages}`,
38
+ };
39
+
40
+ /** Textos en español: `provideMimiPaginationText(MIMI_PAGINATION_TEXT_ES)`. */
41
+ export const MIMI_PAGINATION_TEXT_ES: MimiPaginationText = {
42
+ label: 'Paginación',
43
+ previous: 'Página anterior',
44
+ next: 'Página siguiente',
45
+ page: (pages) => `Página, de ${pages}`,
46
+ pageSize: 'Registros por página',
47
+ pageSizeOption: (size) => `${size} / pág.`,
48
+ outOfRange: (pages) => `Entre 1 y ${pages}`,
49
+ announce: (page, pages) => `Página ${page} de ${pages}`,
50
+ };
51
+
52
+ /** Textos activos. Sin `provideMimiPaginationText`, los predeterminados en inglés. */
53
+ export const MIMI_PAGINATION_TEXT = new InjectionToken<MimiPaginationText>('MIMI_PAGINATION_TEXT', {
54
+ providedIn: 'root',
55
+ factory: () => MIMI_PAGINATION_TEXT_EN,
56
+ });
57
+
58
+ /**
59
+ * Cambia los textos de Pagination. Lo que no definas sigue en inglés:
60
+ *
61
+ * ```ts
62
+ * provideMimiPaginationText(MIMI_PAGINATION_TEXT_ES)
63
+ * ```
64
+ *
65
+ * Va en `app.config.ts` o en los `providers` de un componente o ruta.
66
+ */
67
+ export function provideMimiPaginationText(text: Partial<MimiPaginationText>): Provider {
68
+ return { provide: MIMI_PAGINATION_TEXT, useValue: { ...MIMI_PAGINATION_TEXT_EN, ...text } };
69
+ }
@@ -0,0 +1,443 @@
1
+ import { LiveAnnouncer } from '@angular/cdk/a11y';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ type ElementRef,
6
+ booleanAttribute,
7
+ computed,
8
+ inject,
9
+ input,
10
+ isDevMode,
11
+ model,
12
+ output,
13
+ signal,
14
+ viewChild,
15
+ } from '@angular/core';
16
+ import { cva } from 'class-variance-authority';
17
+ import { MimiButton } from '@/components/ui/button';
18
+ import { MimiInput } from '@/components/ui/input';
19
+ import { cn } from '@/components/ui/utils/cn';
20
+ import { MimiPaginationPageSize } from './pagination-page-size';
21
+ import { MIMI_PAGINATION_TEXT } from './pagination-texts';
22
+
23
+ /*
24
+ * Pagination (docs/design/Mimi Pagination.dc.html y Mimi F7 Pagination.dc.html; prefijo de tokens
25
+ * `pagination`, spec 13): píldora compacta `[←] [página] / total [→] | registros por página`.
26
+ *
27
+ * Es una región de navegación (`role="navigation"`) con paradas de Tab normales: son tres o cuatro
28
+ * controles distintos, no una fila de ítems iguales, y el campo necesita sus flechas. ← y → usan
29
+ * `aria-disabled` en los extremos (no `disabled`), para que el foco no se pierda al llegar.
30
+ */
31
+
32
+ /** `sm` (por defecto, como el diseño) o `default`, con los nombres de tamaño de Button e Input. */
33
+ export type MimiPaginationSize = 'sm' | 'default';
34
+ export type MimiPaginationVariant = 'inline' | 'floating';
35
+
36
+ /** Lo que emite `paginate`: el estado final de cada acción del usuario. */
37
+ export interface MimiPaginationEvent {
38
+ page: number;
39
+ pageSize: number;
40
+ }
41
+
42
+ const paginationVariants = cva(
43
+ [
44
+ 'inline-flex max-w-full items-center rounded-full border font-sans text-foreground',
45
+ 'border-[color:var(--mimi-pagination-border,var(--mimi-glass-border))] bg-[color:var(--mimi-pagination-bg,var(--mimi-glass))]',
46
+ 'backdrop-blur-[var(--mimi-pagination-blur,var(--mimi-glass-blur))] backdrop-saturate-[1.4]',
47
+ ],
48
+ {
49
+ variants: {
50
+ size: { sm: 'gap-[4px] p-[3px] text-[13px]', default: 'gap-[6px] p-[4px] text-sm' },
51
+ // La flotante solo cambia la sombra; la posición se da con `class`.
52
+ variant: {
53
+ inline: 'shadow-neutral',
54
+ floating: 'shadow-[shadow:var(--mimi-pagination-shadow,var(--mimi-shadow-popover))]',
55
+ },
56
+ },
57
+ defaultVariants: { size: 'sm', variant: 'inline' },
58
+ },
59
+ );
60
+
61
+ const ARROW_CLASSES = {
62
+ sm: 'size-[var(--mimi-pagination-item-size,var(--mimi-control-height-sm,2rem))] rounded-full [&_svg]:size-4',
63
+ default:
64
+ 'size-[var(--mimi-pagination-item-size,var(--mimi-control-height,2.5rem))] rounded-full [&_svg]:size-[18px]',
65
+ } satisfies Record<MimiPaginationSize, string>;
66
+ // 0,9 en vez del 0,88 del diseño (plan D6); deshabilitado, sin escala (también con Espacio).
67
+ const ARROW_PRESS = 'active:scale-(--mimi-press-scale-sm) aria-disabled:active:scale-100';
68
+
69
+ const INPUT_CLASSES = cn(
70
+ 'rounded-full px-1 text-center font-semibold tabular-nums read-only:cursor-default',
71
+ 'hover:not-aria-invalid:border-input-hover',
72
+ 'aria-invalid:border-[color:var(--mimi-pagination-error,var(--mimi-destructive))] aria-invalid:focus-visible:border-[color:var(--mimi-pagination-error,var(--mimi-destructive))]',
73
+ 'aria-invalid:ring-3 aria-invalid:ring-destructive-ring',
74
+ );
75
+
76
+ const ERROR_CLASSES =
77
+ 'pointer-events-none absolute bottom-[calc(100%_+_12px)] left-1/2 z-10 -translate-x-1/2 rounded-[7px] bg-[color:var(--mimi-pagination-error,var(--mimi-destructive))] px-[9px] py-[5px] text-xs font-semibold whitespace-nowrap text-destructive-foreground shadow-destructive';
78
+
79
+ let nextId = 0;
80
+ let warnedSize = false;
81
+
82
+ /**
83
+ * Paginación:
84
+ *
85
+ * ```html
86
+ * <mimi-pagination [(page)]="page" [(pageSize)]="size" [total]="240"
87
+ * [pageSizeOptions]="[10, 25, 50]" (paginate)="load($event)" />
88
+ * ```
89
+ *
90
+ * `total` es el número de **registros**. `page` empieza en 1.
91
+ */
92
+ @Component({
93
+ selector: 'mimi-pagination',
94
+ imports: [MimiButton, MimiInput, MimiPaginationPageSize],
95
+ template: `
96
+ <button
97
+ type="button"
98
+ mimiBtn
99
+ variant="ghost"
100
+ size="icon"
101
+ [class]="arrowClasses()"
102
+ [attr.aria-label]="text.previous"
103
+ [attr.aria-disabled]="first() || disabled() ? 'true' : null"
104
+ (click)="previous()"
105
+ >
106
+ <svg
107
+ aria-hidden="true"
108
+ viewBox="0 0 24 24"
109
+ fill="none"
110
+ stroke="currentColor"
111
+ stroke-width="2"
112
+ stroke-linecap="round"
113
+ stroke-linejoin="round"
114
+ >
115
+ <!-- Lucide chevron-left (ISC) -->
116
+ <path d="m15 18-6-6 6-6" />
117
+ </svg>
118
+ </button>
119
+
120
+ <span class="relative flex items-center gap-[inherit]">
121
+ @for (attempt of [attempt()]; track attempt) {
122
+ @if (error()) {
123
+ <!-- Se recrea en cada intento: así role="alert" se vuelve a anunciar. -->
124
+ <span role="alert" data-slot="error" [id]="errorId" [class]="errorClasses">
125
+ {{ text.outOfRange(pageCount()) }}
126
+ <span
127
+ aria-hidden="true"
128
+ class="absolute -bottom-1 left-1/2 -ml-1 size-2 rotate-45 rounded-[1px] bg-[color:var(--mimi-pagination-error,var(--mimi-destructive))]"
129
+ ></span>
130
+ </span>
131
+ }
132
+ }
133
+ <input
134
+ #field
135
+ mimiInput
136
+ type="text"
137
+ inputmode="numeric"
138
+ autocomplete="off"
139
+ enterkeyhint="go"
140
+ [size]="size()"
141
+ [class]="inputClasses()"
142
+ [showError]="error()"
143
+ [value]="fieldValue()"
144
+ [readOnly]="disabled()"
145
+ [attr.maxlength]="maxLength()"
146
+ [attr.aria-label]="text.page(pageCount())"
147
+ [attr.aria-describedby]="error() ? errorId : null"
148
+ [attr.aria-disabled]="disabled() ? 'true' : null"
149
+ (input)="onInput($event)"
150
+ (keydown)="onKeydown($event)"
151
+ (focus)="onFocus($event)"
152
+ (mouseup)="onMouseup($event)"
153
+ (blur)="onBlur()"
154
+ />
155
+ <span
156
+ aria-hidden="true"
157
+ data-slot="total"
158
+ class="pe-[2px] whitespace-nowrap text-muted-foreground tabular-nums"
159
+ >
160
+ / {{ pageCount() }}
161
+ </span>
162
+ </span>
163
+
164
+ <button
165
+ type="button"
166
+ mimiBtn
167
+ variant="ghost"
168
+ size="icon"
169
+ [class]="arrowClasses()"
170
+ [attr.aria-label]="text.next"
171
+ [attr.aria-disabled]="last() || disabled() ? 'true' : null"
172
+ (click)="next()"
173
+ >
174
+ <svg
175
+ aria-hidden="true"
176
+ viewBox="0 0 24 24"
177
+ fill="none"
178
+ stroke="currentColor"
179
+ stroke-width="2"
180
+ stroke-linecap="round"
181
+ stroke-linejoin="round"
182
+ >
183
+ <!-- Lucide chevron-right (ISC) -->
184
+ <path d="m9 18 6-6-6-6" />
185
+ </svg>
186
+ </button>
187
+
188
+ @if (sizeOptions().length) {
189
+ <!-- En móvil (y con class, en una zona estrecha) se oculta: queda ← página / total →. -->
190
+ <span data-slot="page-size" class="flex items-center gap-[inherit] max-sm:hidden">
191
+ <span aria-hidden="true" class="mx-[2px] h-[18px] w-px shrink-0 bg-border"></span>
192
+ @defer (on immediate) {
193
+ <mimi-pagination-page-size
194
+ [options]="sizeOptions()"
195
+ [value]="safeSize()"
196
+ [size]="size()"
197
+ [blocked]="disabled()"
198
+ (changed)="changeSize($event)"
199
+ />
200
+ } @placeholder {
201
+ <!-- Mientras llega Select: el mismo botón, sin función, para que nada salte. -->
202
+ <span aria-hidden="true" [class]="placeholderClasses()">
203
+ {{ text.pageSizeOption(safeSize()) }}
204
+ <svg
205
+ class="size-3.5 shrink-0 text-muted-foreground"
206
+ viewBox="0 0 24 24"
207
+ fill="none"
208
+ stroke="currentColor"
209
+ stroke-width="2"
210
+ stroke-linecap="round"
211
+ stroke-linejoin="round"
212
+ >
213
+ <!-- Lucide chevron-up (ISC) -->
214
+ <path d="m18 15-6-6-6 6" />
215
+ </svg>
216
+ </span>
217
+ }
218
+ </span>
219
+ }
220
+ `,
221
+ host: {
222
+ role: 'navigation',
223
+ '[attr.aria-label]': 'ariaLabel() || text.label',
224
+ '[class]': 'classes()',
225
+ '[attr.data-size]': 'size()',
226
+ '[attr.data-variant]': 'variant()',
227
+ '[attr.data-disabled]': 'disabled() ? "" : null',
228
+ },
229
+ changeDetection: ChangeDetectionStrategy.OnPush,
230
+ })
231
+ export class MimiPagination {
232
+ protected readonly text = inject(MIMI_PAGINATION_TEXT);
233
+ // En una propiedad: la región del anunciador existe antes del primer anuncio.
234
+ private readonly announcer = inject(LiveAnnouncer);
235
+
236
+ /** La página, desde 1. Admite `[(page)]`. */
237
+ readonly page = model(1);
238
+ /** Registros por página. Admite `[(pageSize)]`. */
239
+ readonly pageSize = model(10);
240
+ /** Número total de **registros** (no de páginas). */
241
+ readonly total = input.required<number>();
242
+ /** Tamaños para elegir. Vacío (por defecto), sin selector. */
243
+ readonly pageSizeOptions = input<readonly number[]>([]);
244
+ /** `sm` (por defecto, controles de 32px) o `md` (40px). */
245
+ readonly size = input<MimiPaginationSize>('sm');
246
+ /** `floating` solo cambia la sombra; la posición va en `class`. */
247
+ readonly variant = input<MimiPaginationVariant>('inline');
248
+ /** Todo deshabilitado (por ejemplo, mientras carga), sin sacar el foco de donde esté. */
249
+ readonly disabled = input(false, { transform: booleanAttribute });
250
+ /** Nombre de la región. Por defecto, el de los textos («Pagination»). */
251
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
252
+ /** Clases de la píldora, mezcladas con `cn()` (gana la del usuario). */
253
+ readonly userClass = input('', { alias: 'class' });
254
+
255
+ /** Una vez por acción del usuario, con el estado final (la página y el tamaño). */
256
+ readonly paginate = output<MimiPaginationEvent>();
257
+
258
+ private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
259
+
260
+ /** Registros por página, siempre 1 o más. */
261
+ protected readonly safeSize = computed(() => {
262
+ const size = Math.floor(Number(this.pageSize()));
263
+ return size >= 1 ? size : 1;
264
+ });
265
+ /** Número de páginas (al menos 1). Útil para corregir una página que quedó fuera. */
266
+ readonly pageCount = computed(() => {
267
+ const total = Math.floor(Number(this.total()));
268
+ return Math.max(1, Math.ceil((total > 0 ? total : 0) / this.safeSize()));
269
+ });
270
+ /**
271
+ * La página que se pinta: la del modelo, acotada. Una página fuera de rango que llega de fuera
272
+ * (por ejemplo, `total` en 0 mientras cargan los datos) no se reescribe.
273
+ */
274
+ protected readonly current = computed(() => {
275
+ const page = Math.floor(Number(this.page()));
276
+ return Math.min(Math.max(Number.isFinite(page) ? page : 1, 1), this.pageCount());
277
+ });
278
+ protected readonly first = computed(() => this.current() <= 1);
279
+ protected readonly last = computed(() => this.current() >= this.pageCount());
280
+
281
+ /** Tamaños del selector; si `pageSize` no está, se agrega (y avisa en desarrollo). */
282
+ protected readonly sizeOptions = computed(() => {
283
+ const options = [...this.pageSizeOptions()];
284
+ const size = this.safeSize();
285
+ if (!options.length || options.includes(size)) return options;
286
+ if (isDevMode() && !warnedSize) {
287
+ warnedSize = true;
288
+ console.warn(`[Mimi] Pagination: pageSize (${size}) no está en pageSizeOptions; se agrega.`);
289
+ }
290
+ return [...options, size].sort((a, b) => a - b);
291
+ });
292
+
293
+ /** Lo que se está escribiendo en el campo (null: muestra la página actual). */
294
+ private readonly draft = signal<string | null>(null);
295
+ protected readonly error = signal(false);
296
+ /** Sube en cada intento inválido, para recrear el globo de error. */
297
+ protected readonly attempt = signal(0);
298
+ protected readonly fieldValue = computed(() => this.draft() ?? String(this.current()));
299
+ protected readonly maxLength = computed(() => String(this.pageCount()).length);
300
+ protected readonly errorId = `mimi-pagination-error-${nextId++}`;
301
+
302
+ protected readonly classes = computed(() =>
303
+ cn(paginationVariants({ size: this.size(), variant: this.variant() }), this.userClass()),
304
+ );
305
+ /**
306
+ * Clases propias de ← y → sobre las de `mimiBtn` ghost de ícono. No se le pasa `disabled` a
307
+ * Button (sería nativo y el foco se perdería en los extremos): se usa `aria-disabled`.
308
+ */
309
+ protected readonly arrowClasses = computed(() => cn(ARROW_CLASSES[this.size()], ARROW_PRESS));
310
+ protected readonly inputClasses = computed(() =>
311
+ cn(
312
+ INPUT_CLASSES,
313
+ this.size() === 'default'
314
+ ? 'w-[var(--mimi-pagination-input-width,52px)]'
315
+ : 'w-[var(--mimi-pagination-input-width,44px)]',
316
+ ),
317
+ );
318
+ protected readonly errorClasses = ERROR_CLASSES;
319
+ protected readonly placeholderClasses = computed(() =>
320
+ cn(
321
+ 'inline-flex items-center gap-1 rounded-full pr-2 pl-2.5 font-semibold whitespace-nowrap tabular-nums',
322
+ this.size() === 'default'
323
+ ? 'h-[var(--mimi-pagination-item-size,var(--mimi-control-height,2.5rem))] text-sm'
324
+ : 'h-[var(--mimi-pagination-item-size,var(--mimi-control-height-sm,2rem))] text-[13px]',
325
+ ),
326
+ );
327
+
328
+ private selectOnMouseup = false;
329
+
330
+ protected previous(): void {
331
+ if (!this.first()) this.go(this.current() - 1);
332
+ }
333
+
334
+ protected next(): void {
335
+ if (!this.last()) this.go(this.current() + 1);
336
+ }
337
+
338
+ /** Cambia el tamaño manteniendo visible el primer registro que se veía (como MatPaginator). */
339
+ protected changeSize(size: number): void {
340
+ if (this.disabled() || size === this.safeSize()) return;
341
+ const firstRecord = (this.current() - 1) * this.safeSize();
342
+ this.pageSize.set(size);
343
+ const page = Math.min(Math.floor(firstRecord / size) + 1, this.pageCount());
344
+ this.page.set(page);
345
+ this.paginate.emit({ page, pageSize: size });
346
+ this.announce(page);
347
+ }
348
+
349
+ protected onInput(event: Event): void {
350
+ const field = event.target as HTMLInputElement;
351
+ const digits = field.value.replace(/\D/g, '').slice(0, this.maxLength());
352
+ if (digits !== field.value) field.value = digits;
353
+ this.draft.set(digits);
354
+ this.error.set(false);
355
+ }
356
+
357
+ protected onKeydown(event: KeyboardEvent): void {
358
+ // Enfocado con Tab: ya no hay un mouseup que deshaga la selección.
359
+ this.selectOnMouseup = false;
360
+ switch (event.key) {
361
+ case 'Enter': {
362
+ // También evita enviar el formulario que la contenga.
363
+ event.preventDefault();
364
+ if (this.disabled() || this.draft() === null) return;
365
+ const page = this.parse(this.draft());
366
+ if (page === null) {
367
+ this.error.set(true);
368
+ this.attempt.update((n) => n + 1);
369
+ return;
370
+ }
371
+ this.go(page);
372
+ this.reset();
373
+ return;
374
+ }
375
+ case 'Escape':
376
+ if (this.draft() === null && !this.error()) return;
377
+ event.preventDefault();
378
+ this.reset();
379
+ return;
380
+ case 'ArrowUp':
381
+ case 'ArrowDown':
382
+ event.preventDefault();
383
+ if (this.disabled()) return;
384
+ this.reset();
385
+ if (event.key === 'ArrowUp') this.next();
386
+ else this.previous();
387
+ return;
388
+ }
389
+ }
390
+
391
+ /** Al salir: una página válida se aplica; si no, vuelve a la actual sin error. */
392
+ protected onBlur(): void {
393
+ this.selectOnMouseup = false;
394
+ if (this.draft() === null) return;
395
+ const page = this.disabled() ? null : this.parse(this.draft());
396
+ if (page !== null) this.go(page);
397
+ this.reset();
398
+ }
399
+
400
+ /** Al enfocar, selecciona el número (con un clic, después del mouseup, que lo deshace). */
401
+ protected onFocus(event: FocusEvent): void {
402
+ (event.target as HTMLInputElement).select();
403
+ this.selectOnMouseup = true;
404
+ }
405
+
406
+ protected onMouseup(event: MouseEvent): void {
407
+ if (!this.selectOnMouseup) return;
408
+ this.selectOnMouseup = false;
409
+ event.preventDefault();
410
+ }
411
+
412
+ /** Va a esa página: escribe el modelo, emite y anuncia, solo si cambia. */
413
+ private go(page: number): void {
414
+ if (this.disabled() || page === this.current()) return;
415
+ this.page.set(page);
416
+ this.paginate.emit({ page, pageSize: this.safeSize() });
417
+ this.announce(page);
418
+ }
419
+
420
+ private announce(page: number): void {
421
+ void this.announcer.announce(this.text.announce(page, this.pageCount()), 'polite');
422
+ }
423
+
424
+ /**
425
+ * Vuelve a mostrar la página actual (también en el DOM: el binding no se entera si el texto
426
+ * escrito coincide con la página nueva). Va después de `go()`.
427
+ */
428
+ private reset(): void {
429
+ this.draft.set(null);
430
+ this.error.set(false);
431
+ this.field().nativeElement.value = String(this.current());
432
+ }
433
+
434
+ /** Página válida (entera, de 1 al total) o null. */
435
+ private parse(text: string | null): number | null {
436
+ if (!text) return null;
437
+ const page = Number(text);
438
+ return Number.isInteger(page) && page >= 1 && page <= this.pageCount() ? page : null;
439
+ }
440
+ }
441
+
442
+ /** Para importarla con su selector (las piezas internas no se usan en plantillas). */
443
+ export const MimiPaginationImports = [MimiPagination] as const;
@@ -0,0 +1,12 @@
1
+ // Solo la API pública: MimiPopoverOutlet es interno (se queda en './popover').
2
+ export {
3
+ MimiPopover,
4
+ type MimiPopoverAlign,
5
+ MimiPopoverClose,
6
+ MimiPopoverContent,
7
+ MimiPopoverDescription,
8
+ MimiPopoverImports,
9
+ type MimiPopoverSide,
10
+ MimiPopoverTitle,
11
+ MimiPopoverTrigger,
12
+ } from './popover';