@mimi-ng/cli 0.4.0 → 0.5.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 (35) hide show
  1. package/README.md +29 -7
  2. package/dist/init/index.js +8 -0
  3. package/dist/registry.js +4 -1
  4. package/dist/registry.json +87 -1
  5. package/dist/templates/blocks/login-01/login-01.ts +12 -3
  6. package/dist/templates/blocks/login-02/login-02.ts +12 -3
  7. package/dist/templates/blocks/otp-01/otp-01.ts +12 -3
  8. package/dist/templates/blocks/pricing-01/pricing-01.ts +1 -1
  9. package/dist/templates/blocks/settings-01/settings-01.ts +13 -3
  10. package/dist/templates/blocks/signup-01/signup-01.ts +12 -2
  11. package/dist/templates/blocks/table-01/index.ts +1 -0
  12. package/dist/templates/blocks/table-01/table-01.html +214 -0
  13. package/dist/templates/blocks/table-01/table-01.ts +266 -0
  14. package/dist/templates/components/checkbox/checkbox.ts +14 -5
  15. package/dist/templates/components/command/command-texts.ts +65 -0
  16. package/dist/templates/components/command/command.ts +553 -0
  17. package/dist/templates/components/command/index.ts +13 -0
  18. package/dist/templates/components/data-table/data-table-texts.ts +110 -0
  19. package/dist/templates/components/data-table/data-table.ts +1153 -0
  20. package/dist/templates/components/data-table/index.ts +18 -0
  21. package/dist/templates/components/sortable-list/index.ts +9 -0
  22. package/dist/templates/components/sortable-list/sortable-list-texts.ts +71 -0
  23. package/dist/templates/components/sortable-list/sortable-list.ts +510 -0
  24. package/dist/templates/components/tree/index.ts +9 -0
  25. package/dist/templates/components/tree/tree-texts.ts +45 -0
  26. package/dist/templates/components/tree/tree.ts +730 -0
  27. package/dist/templates/theme/provider.ts +4 -0
  28. package/dist/templates/theme/types.ts +84 -0
  29. package/dist/ui/index.js +232 -40
  30. package/dist/ui/schema.json +30 -6
  31. package/dist/utils/pages.js +138 -0
  32. package/dist/utils/project.js +33 -9
  33. package/dist/utils/tailwind.js +15 -11
  34. package/dist/utils/write-files.js +32 -7
  35. package/package.json +1 -1
@@ -0,0 +1,1153 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ Directive,
5
+ type EmbeddedViewRef,
6
+ TemplateRef,
7
+ ViewContainerRef,
8
+ booleanAttribute,
9
+ computed,
10
+ contentChild,
11
+ contentChildren,
12
+ effect,
13
+ inject,
14
+ input,
15
+ isDevMode,
16
+ model,
17
+ signal,
18
+ untracked,
19
+ } from '@angular/core';
20
+ import { MimiCheckbox } from '@/components/ui/checkbox';
21
+ import { MimiChip } from '@/components/ui/chip';
22
+ import {
23
+ MimiDropdownMenu,
24
+ MimiDropdownMenuContent,
25
+ MimiDropdownMenuTrigger,
26
+ MimiMenuCheckboxItem,
27
+ } from '@/components/ui/dropdown-menu';
28
+ import { MimiInput } from '@/components/ui/input';
29
+ import { MimiPagination } from '@/components/ui/pagination';
30
+ import { MimiToggleGroup, MimiToggleGroupItem } from '@/components/ui/toggle-group';
31
+ import { cn } from '@/components/ui/utils/cn';
32
+ import { MIMI_DATA_TABLE_TEXT } from './data-table-texts';
33
+
34
+ /*
35
+ * Data Table (docs/design/Mimi Data Table.dc.html; prefijo de tokens `table`, spec 13). Una
36
+ * tabla de datos con búsqueda, filtros por columna, orden, paginación (Pagination), selección
37
+ * (Checkbox), detalle expandible, acciones por fila, columnas fijas y dos densidades.
38
+ *
39
+ * Por dentro: una `<table>` nativa (patrón «tabla» de la APG, no el `grid` de aria: las celdas
40
+ * tienen controles que se recorren con Tab; G3.4-a). Sin `total`, la tabla busca, filtra, ordena y
41
+ * pagina `data`; con `total`, `data` ya es la página y la tabla solo emite los cambios.
42
+ */
43
+
44
+ /** Una opción del filtro de una columna. */
45
+ export interface MimiTableFilterOption {
46
+ label: string;
47
+ value: unknown;
48
+ }
49
+
50
+ /** Una columna. */
51
+ export interface MimiTableColumn<T = any> {
52
+ /** Identificador; por defecto, también la propiedad de la fila (`row[id]`). */
53
+ id: string;
54
+ /** Texto del encabezado. */
55
+ header: string;
56
+ /** El valor de la celda (para mostrar, buscar, filtrar y ordenar). Por defecto, `row[id]`. */
57
+ value?: (row: T) => unknown;
58
+ /** Se puede ordenar por esta columna. */
59
+ sortable?: boolean;
60
+ /** Opciones del filtro de la columna (un menú con casillas). Sin opciones, sin filtro. */
61
+ filterOptions?: readonly MimiTableFilterOption[];
62
+ /** Entra en la búsqueda global. Por defecto, `true`. */
63
+ searchable?: boolean;
64
+ /** Alineación del contenido (`end` para cifras). */
65
+ align?: 'start' | 'end';
66
+ /** Ancho (un valor CSS). Hace falta en las columnas fijas. */
67
+ width?: string;
68
+ /** Fija a la izquierda al desplazar. Solo columnas iniciales con `width`. */
69
+ sticky?: boolean;
70
+ }
71
+
72
+ /** Orden de la tabla. */
73
+ export interface MimiTableSort {
74
+ column: string;
75
+ direction: 'asc' | 'desc';
76
+ }
77
+
78
+ /** Densidad: filas de 42px (`compact`) o de 60px (`comfortable`). */
79
+ export type MimiTableDensity = 'compact' | 'comfortable';
80
+
81
+ /** Filtros por columna: los valores elegidos de cada una. */
82
+ export type MimiTableFilters = Readonly<Record<string, readonly unknown[]>>;
83
+
84
+ /** Contexto de las plantillas de celda: `let-row let-value="value" let-density="density"`. */
85
+ export interface MimiTableCellContext<T = any> {
86
+ $implicit: T;
87
+ value: unknown;
88
+ density: MimiTableDensity;
89
+ }
90
+
91
+ /** Contexto de las plantillas de detalle y acciones (`let-row`) y de la selección (`let-ids`). */
92
+ export interface MimiTableRowContext<T = any> {
93
+ $implicit: T;
94
+ density: MimiTableDensity;
95
+ }
96
+
97
+ /** Celda a medida de una columna: `<ng-template mimiTableCell="estado" let-row let-value="value">`. */
98
+ @Directive({ selector: 'ng-template[mimiTableCell]' })
99
+ export class MimiTableCell<T = any> {
100
+ readonly column = input.required<string>({ alias: 'mimiTableCell' });
101
+ readonly templateRef = inject<TemplateRef<MimiTableCellContext<T>>>(TemplateRef);
102
+
103
+ static ngTemplateContextGuard<T>(
104
+ _dir: MimiTableCell<T>,
105
+ ctx: unknown,
106
+ ): ctx is MimiTableCellContext<T> {
107
+ return true;
108
+ }
109
+ }
110
+
111
+ /** Detalle de una fila (agrega la columna de expandir): `<ng-template mimiTableDetail let-row>`. */
112
+ @Directive({ selector: 'ng-template[mimiTableDetail]' })
113
+ export class MimiTableDetail<T = any> {
114
+ readonly templateRef = inject<TemplateRef<MimiTableRowContext<T>>>(TemplateRef);
115
+
116
+ static ngTemplateContextGuard<T>(
117
+ _dir: MimiTableDetail<T>,
118
+ ctx: unknown,
119
+ ): ctx is MimiTableRowContext<T> {
120
+ return true;
121
+ }
122
+ }
123
+
124
+ /** Acciones de una fila (agrega su columna): `<ng-template mimiTableActions let-row>`. */
125
+ @Directive({ selector: 'ng-template[mimiTableActions]' })
126
+ export class MimiTableActions<T = any> {
127
+ readonly templateRef = inject<TemplateRef<MimiTableRowContext<T>>>(TemplateRef);
128
+
129
+ static ngTemplateContextGuard<T>(
130
+ _dir: MimiTableActions<T>,
131
+ ctx: unknown,
132
+ ): ctx is MimiTableRowContext<T> {
133
+ return true;
134
+ }
135
+ }
136
+
137
+ /** Acciones de la barra con filas elegidas: `<ng-template mimiTableBulk let-ids>`. */
138
+ @Directive({ selector: 'ng-template[mimiTableBulk]' })
139
+ export class MimiTableBulk {
140
+ readonly templateRef = inject<TemplateRef<MimiTableRowContext<unknown[]>>>(TemplateRef);
141
+
142
+ static ngTemplateContextGuard(
143
+ _dir: MimiTableBulk,
144
+ ctx: unknown,
145
+ ): ctx is MimiTableRowContext<unknown[]> {
146
+ return true;
147
+ }
148
+ }
149
+
150
+ /** Lo que va debajo de «sin datos» o «sin resultados»: `<button mimiBtn mimiTableEmpty>`. */
151
+ @Directive({ selector: '[mimiTableEmpty]', host: { 'data-slot': 'empty-action' } })
152
+ export class MimiTableEmpty {}
153
+
154
+ /**
155
+ * Uso interno: pinta una plantilla con su contexto. Recibe cada dato por separado (la fila es el
156
+ * mismo objeto de `data`): un contexto nuevo en cada detección de cambios repetiría el efecto sin
157
+ * fin (NG0103, G3.2); sin `NgTemplateOutlet`, que lleva `@angular/common` al JS inicial (G3.1).
158
+ * @internal No forma parte de la API de Mimi.
159
+ */
160
+ @Directive({ selector: '[mimiTableOutlet]' })
161
+ export class MimiTableOutlet {
162
+ readonly template = input.required<TemplateRef<any>>({ alias: 'mimiTableOutlet' });
163
+ readonly item = input.required<unknown>({ alias: 'mimiTableOutletItem' });
164
+ readonly value = input<unknown>(undefined, { alias: 'mimiTableOutletValue' });
165
+ readonly density = input.required<MimiTableDensity>({ alias: 'mimiTableOutletDensity' });
166
+
167
+ private readonly viewContainer = inject(ViewContainerRef);
168
+ private view: EmbeddedViewRef<MimiTableCellContext> | null = null;
169
+ private rendered: TemplateRef<unknown> | null = null;
170
+
171
+ private readonly render = effect(() => {
172
+ const template = this.template();
173
+ const context: MimiTableCellContext = {
174
+ $implicit: this.item(),
175
+ value: this.value(),
176
+ density: this.density(),
177
+ };
178
+ untracked(() => {
179
+ if (this.view && this.rendered === template) {
180
+ Object.assign(this.view.context, context);
181
+ this.view.markForCheck();
182
+ return;
183
+ }
184
+ this.viewContainer.clear();
185
+ this.rendered = template;
186
+ this.view = this.viewContainer.createEmbeddedView(template, { ...context });
187
+ });
188
+ });
189
+ }
190
+
191
+ let nextId = 0;
192
+
193
+ /** Trazos de Lucide (ISC). */
194
+ const ICON = {
195
+ search: ['m21 21-4.34-4.34', 'M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16z'],
196
+ x: ['M18 6 6 18', 'm6 6 12 12'],
197
+ sortNone: ['m7 15 5 5 5-5', 'm7 9 5-5 5 5'],
198
+ sortAsc: ['m5 12 7-7 7 7', 'M12 19V5'],
199
+ sortDesc: ['M12 5v14', 'm19 12-7 7-7-7'],
200
+ filter: ['M3 6h18', 'M7 12h10', 'M10 18h4'],
201
+ chevron: ['m9 18 6-6-6-6'],
202
+ rows4: [
203
+ 'M21 7.5H3',
204
+ 'M21 12H3',
205
+ 'M21 16.5H3',
206
+ 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
207
+ ],
208
+ rows2: ['M3 12h18', 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z'],
209
+ searchX: [
210
+ 'm13.5 8.5-5 5',
211
+ 'm8.5 8.5 5 5',
212
+ 'M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16z',
213
+ 'm21 21-4.3-4.3',
214
+ ],
215
+ } as const;
216
+
217
+ const HOST_CLASSES = cn(
218
+ 'group/data-table relative flex min-w-0 flex-col font-sans text-card-foreground',
219
+ 'rounded-[var(--mimi-radius-card)] border border-border bg-[color:var(--mimi-table-bg,var(--mimi-card))]',
220
+ 'shadow-[shadow:var(--mimi-shadow-card)]',
221
+ );
222
+
223
+ const TOOLBAR_CLASSES =
224
+ 'flex min-h-[var(--mimi-table-toolbar-height,56px)] flex-wrap items-center gap-2.5 border-b border-border py-2.5 pr-3 pl-4';
225
+
226
+ /** Fondo de las celdas: fijo, en hover y elegida (las fijas necesitan fondo opaco). */
227
+ const CELL_BG = cn(
228
+ 'bg-[color:var(--mimi-table-bg,var(--mimi-card))] transition-[background-color] duration-120 ease-[ease]',
229
+ 'group-hover/row:bg-[color:var(--mimi-table-row-hover,color-mix(in_oklab,var(--mimi-foreground)_3.5%,var(--mimi-card)))]',
230
+ 'group-data-[selected]/row:bg-[color:var(--mimi-table-row-selected,color-mix(in_oklab,var(--mimi-info)_8%,var(--mimi-card)))]',
231
+ );
232
+
233
+ const CELL_CLASSES = cn(
234
+ 'h-[var(--mimi-table-row-height,42px)] border-b border-border px-[var(--mimi-table-cell-px,10px)]',
235
+ 'group-data-[density=comfortable]/data-table:h-[var(--mimi-table-row-height-comfortable,60px)]',
236
+ CELL_BG,
237
+ );
238
+
239
+ const HEAD_CLASSES = cn(
240
+ 'h-[var(--mimi-table-head-height,38px)] border-b border-border px-[var(--mimi-table-cell-px,10px)] text-start whitespace-nowrap',
241
+ 'group-data-[density=comfortable]/data-table:h-[var(--mimi-table-head-height-comfortable,44px)]',
242
+ 'bg-[color:var(--mimi-table-head-bg,var(--mimi-muted))] text-[12.5px] font-semibold text-muted-foreground',
243
+ );
244
+
245
+ /**
246
+ * Sombra de la última columna fija, solo con la tabla desplazada. El desplazamiento horizontal
247
+ * vale 1 y en RTL -1 (la sombra cae del lado del contenido que pasa por debajo); un
248
+ * `--mimi-table-sticky-shadow` propio se usa tal cual en las dos direcciones.
249
+ */
250
+ const EDGE_CLASSES = cn(
251
+ 'rtl:[--sticky-dir:-1]',
252
+ 'group-data-[scrolled]/data-table:shadow-[shadow:var(--mimi-table-sticky-shadow,calc(6px_*_var(--sticky-dir,1))_0_10px_-6px_oklch(0_0_0/.25))]',
253
+ );
254
+
255
+ const ICON_BUTTON_CLASSES = cn(
256
+ 'inline-flex shrink-0 cursor-pointer items-center justify-center rounded-[var(--mimi-radius-sm)] border-0 bg-transparent text-muted-foreground',
257
+ 'mimi-transition hover:bg-accent hover:text-foreground',
258
+ 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
259
+ );
260
+
261
+ const SKELETON_CLASSES =
262
+ 'h-2.5 rounded-[4px] bg-muted animate-mimi-pulse motion-reduce:animate-none';
263
+
264
+ /** Clases de cada pieza, ya mezcladas (sin clases estáticas que choquen con un binding). */
265
+ const UI = {
266
+ selectHead: cn(
267
+ HEAD_CLASSES,
268
+ 'sticky start-0 z-[2] box-border w-11 min-w-11 max-w-11 pe-0 ps-3.5',
269
+ ),
270
+ expandHead: cn(HEAD_CLASSES, 'sticky z-[2] box-border w-9 min-w-9 max-w-9 px-0'),
271
+ actionsHead: cn(HEAD_CLASSES, 'w-14'),
272
+ selectCell: cn(
273
+ CELL_CLASSES,
274
+ 'sticky start-0 z-[1] box-border w-11 min-w-11 max-w-11 pe-0 ps-3.5',
275
+ ),
276
+ expandCell: cn(CELL_CLASSES, 'sticky z-[1] box-border w-9 min-w-9 max-w-9 px-0'),
277
+ actionsCell: cn(CELL_CLASSES, 'px-2 text-center'),
278
+ clearSelection: cn(ICON_BUTTON_CLASSES, 'h-[30px] px-2.5 text-[13px] font-medium'),
279
+ clearSearch: cn(
280
+ ICON_BUTTON_CLASSES,
281
+ 'absolute top-1/2 end-2 size-[22px] -translate-y-1/2 rounded-full bg-muted [&_svg]:size-3',
282
+ ),
283
+ sort: cn(
284
+ ICON_BUTTON_CLASSES,
285
+ '-ml-1.5 h-7 gap-[5px] px-1.5 text-[12.5px] font-semibold in-data-[sorted]:text-foreground',
286
+ ),
287
+ filter: cn(
288
+ ICON_BUTTON_CLASSES,
289
+ 'relative size-6 data-[active]:bg-info-soft data-[active]:text-info-soft-foreground [&_svg]:size-[13px]',
290
+ ),
291
+ expand: cn(
292
+ ICON_BUTTON_CLASSES,
293
+ 'size-[26px] [&_svg]:size-[15px] [&_svg]:transition-transform [&_svg]:duration-250 aria-expanded:[&_svg]:rotate-90 rtl:[&_svg]:-scale-x-100',
294
+ ),
295
+ clearAll: cn(
296
+ 'h-9 cursor-pointer rounded-[var(--mimi-radius)] border border-border bg-card px-3.5 text-[13.5px] font-medium shadow-[shadow:var(--mimi-shadow-neutral)] mimi-transition hover:bg-accent',
297
+ 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
298
+ ),
299
+ skeletonWide: cn(SKELETON_CLASSES, 'w-1/4'),
300
+ skeletonShort: cn(SKELETON_CLASSES, 'w-1/6'),
301
+ skeletonMid: cn(SKELETON_CLASSES, 'w-1/5'),
302
+ } as const;
303
+
304
+ const fold = (text: string) => text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
305
+
306
+ /** Compara dos valores: números como números, texto sin tildes ni mayúsculas, vacíos al final. */
307
+ function compareValues(a: unknown, b: unknown): number {
308
+ if (a == null || a === '') return b == null || b === '' ? 0 : 1;
309
+ if (b == null || b === '') return -1;
310
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
311
+ if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
312
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
313
+ }
314
+
315
+ /** `row.id` o, sin él, la propia fila: nunca `undefined`, que repetiría la clave de todas. */
316
+ const DEFAULT_ROW_ID = (row: unknown): unknown => (row as { id?: unknown } | null)?.id ?? row;
317
+
318
+ /** Una columna con lo que la plantilla necesita ya calculado. */
319
+ interface ColumnView<T> {
320
+ column: MimiTableColumn<T>;
321
+ left: string | null;
322
+ edge: boolean;
323
+ sort: 'ascending' | 'descending' | 'none' | null;
324
+ filtered: boolean;
325
+ cell: TemplateRef<MimiTableCellContext<T>> | null;
326
+ headClass: string;
327
+ cellClass: string;
328
+ }
329
+
330
+ /**
331
+ * Tabla de datos: `<mimi-data-table aria-label="Clientes" [data]="clientes" [columns]="columnas"
332
+ * selectable [(selection)]="elegidos" />`.
333
+ *
334
+ * - Celdas a medida: `<ng-template mimiTableCell="estado" let-row let-value="value">`.
335
+ * - Detalle expandible: `<ng-template mimiTableDetail let-row>`.
336
+ * - Acciones por fila: `<ng-template mimiTableActions let-row>`; de la selección: `mimiTableBulk`.
337
+ */
338
+ @Component({
339
+ selector: 'mimi-data-table',
340
+ imports: [
341
+ MimiCheckbox,
342
+ MimiChip,
343
+ MimiDropdownMenu,
344
+ MimiDropdownMenuContent,
345
+ MimiDropdownMenuTrigger,
346
+ MimiInput,
347
+ MimiMenuCheckboxItem,
348
+ MimiPagination,
349
+ MimiTableOutlet,
350
+ MimiToggleGroup,
351
+ MimiToggleGroupItem,
352
+ ],
353
+ template: `
354
+ <span role="status" class="sr-only">{{ liveText() }}</span>
355
+
356
+ @if (toolbar()) {
357
+ <div data-slot="toolbar" [class]="toolbarClasses">
358
+ @if (selectable() && selection().length) {
359
+ <div data-slot="selection" class="flex min-w-0 flex-1 items-center gap-2.5">
360
+ <span
361
+ class="inline-flex h-[26px] items-center rounded-full bg-info-soft px-2.5 text-[13px] font-bold text-info-soft-foreground"
362
+ >{{ texts.selected(selection().length) }}</span
363
+ >
364
+ <button
365
+ type="button"
366
+ data-slot="clear-selection"
367
+ [class]="ui.clearSelection"
368
+ (click)="selection.set([])"
369
+ >
370
+ {{ texts.clearSelection }}
371
+ </button>
372
+ <span class="flex-1"></span>
373
+ @if (bulk(); as bulk) {
374
+ <span class="flex items-center gap-2">
375
+ <ng-container
376
+ [mimiTableOutlet]="bulk.templateRef"
377
+ [mimiTableOutletItem]="selection()"
378
+ [mimiTableOutletDensity]="density()"
379
+ />
380
+ </span>
381
+ }
382
+ </div>
383
+ } @else {
384
+ <div class="relative w-[280px] max-w-full">
385
+ <svg
386
+ aria-hidden="true"
387
+ class="pointer-events-none absolute top-1/2 start-2.5 size-[15px] -translate-y-1/2 text-muted-foreground"
388
+ viewBox="0 0 24 24"
389
+ fill="none"
390
+ stroke="currentColor"
391
+ stroke-width="2"
392
+ stroke-linecap="round"
393
+ stroke-linejoin="round"
394
+ >
395
+ @for (d of icon.search; track d) {
396
+ <path [attr.d]="d" />
397
+ }
398
+ </svg>
399
+ <input
400
+ mimiInput
401
+ type="search"
402
+ data-slot="search"
403
+ class="h-9 pr-8 pl-8 text-[13.5px] [&::-webkit-search-cancel-button]:hidden"
404
+ [value]="query()"
405
+ [attr.placeholder]="searchPlaceholder() || texts.search"
406
+ [attr.aria-label]="searchPlaceholder() || texts.search"
407
+ (input)="setQuery($any($event.target).value)"
408
+ />
409
+ @if (query()) {
410
+ <button
411
+ type="button"
412
+ data-slot="clear-search"
413
+ [class]="ui.clearSearch"
414
+ [attr.aria-label]="texts.clearSearch"
415
+ (click)="setQuery('')"
416
+ >
417
+ <svg
418
+ aria-hidden="true"
419
+ viewBox="0 0 24 24"
420
+ fill="none"
421
+ stroke="currentColor"
422
+ stroke-width="2"
423
+ stroke-linecap="round"
424
+ stroke-linejoin="round"
425
+ >
426
+ @for (d of icon.x; track d) {
427
+ <path [attr.d]="d" />
428
+ }
429
+ </svg>
430
+ </button>
431
+ }
432
+ </div>
433
+ <div data-slot="chips" class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
434
+ @for (chip of chips(); track chip.key) {
435
+ <mimi-chip
436
+ removable
437
+ [label]="chip.name"
438
+ (remove)="toggleFilter(chip.column, chip.value)"
439
+ >{{ chip.name }}</mimi-chip
440
+ >
441
+ }
442
+ @if (chips().length) {
443
+ <button
444
+ type="button"
445
+ data-slot="clear-filters"
446
+ class="h-[26px] cursor-pointer rounded-full px-2 text-[12.5px] font-medium text-muted-foreground underline underline-offset-[3px] hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
447
+ (click)="clearFilters()"
448
+ >
449
+ {{ texts.clearFilters }}
450
+ </button>
451
+ }
452
+ </div>
453
+ <mimi-toggle-group
454
+ data-slot="density"
455
+ [aria-label]="texts.density"
456
+ [value]="density()"
457
+ (valueChange)="setDensity($event)"
458
+ >
459
+ <button
460
+ mimiToggleGroupItem="compact"
461
+ [attr.aria-label]="texts.compact"
462
+ [attr.title]="texts.compact"
463
+ >
464
+ <svg
465
+ aria-hidden="true"
466
+ viewBox="0 0 24 24"
467
+ fill="none"
468
+ stroke="currentColor"
469
+ stroke-width="2"
470
+ stroke-linecap="round"
471
+ stroke-linejoin="round"
472
+ >
473
+ @for (d of icon.rows4; track d) {
474
+ <path [attr.d]="d" />
475
+ }
476
+ </svg>
477
+ </button>
478
+ <button
479
+ mimiToggleGroupItem="comfortable"
480
+ [attr.aria-label]="texts.comfortable"
481
+ [attr.title]="texts.comfortable"
482
+ >
483
+ <svg
484
+ aria-hidden="true"
485
+ viewBox="0 0 24 24"
486
+ fill="none"
487
+ stroke="currentColor"
488
+ stroke-width="2"
489
+ stroke-linecap="round"
490
+ stroke-linejoin="round"
491
+ >
492
+ @for (d of icon.rows2; track d) {
493
+ <path [attr.d]="d" />
494
+ }
495
+ </svg>
496
+ </button>
497
+ </mimi-toggle-group>
498
+ }
499
+ </div>
500
+ }
501
+
502
+ <div
503
+ data-slot="scroll"
504
+ class="overflow-x-auto overscroll-x-contain focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
505
+ role="region"
506
+ tabindex="0"
507
+ [attr.aria-label]="ariaLabel() || null"
508
+ (scroll)="onScroll($event)"
509
+ >
510
+ <table
511
+ class="w-full min-w-max border-separate border-spacing-0 text-[length:var(--mimi-table-font-size,13px)] tabular-nums group-data-[density=comfortable]/data-table:text-[length:var(--mimi-table-font-size-comfortable,14px)]"
512
+ [attr.aria-label]="ariaLabel() || null"
513
+ [attr.aria-busy]="loading() || null"
514
+ >
515
+ <thead>
516
+ <tr>
517
+ @if (selectable()) {
518
+ <th scope="col" data-slot="select" [class]="ui.selectHead">
519
+ <span class="sr-only">{{ texts.select }}</span>
520
+ <mimi-checkbox
521
+ [aria-label]="texts.selectAll"
522
+ [checked]="pageState() === 'all'"
523
+ [indeterminate]="pageState() === 'some'"
524
+ [disabled]="!pageIds().length"
525
+ (checkedChange)="togglePage($event)"
526
+ />
527
+ </th>
528
+ }
529
+ @if (detail()) {
530
+ <th scope="col" [class]="ui.expandHead" [style.inset-inline-start]="expandLeft()">
531
+ <span class="sr-only">{{ texts.details }}</span>
532
+ </th>
533
+ }
534
+ @for (col of columnViews(); track col.column.id) {
535
+ <th
536
+ scope="col"
537
+ [class]="col.headClass"
538
+ [style.inset-inline-start]="col.left"
539
+ [style.width]="col.column.width ?? null"
540
+ [style.min-width]="col.left !== null ? col.column.width : null"
541
+ [style.max-width]="col.left !== null ? col.column.width : null"
542
+ [attr.aria-sort]="col.sort"
543
+ [attr.data-sorted]="
544
+ col.sort === 'ascending' || col.sort === 'descending' ? '' : null
545
+ "
546
+ >
547
+ <span
548
+ [class]="
549
+ col.column.align === 'end'
550
+ ? 'flex items-center justify-end gap-0.5'
551
+ : 'flex items-center gap-0.5'
552
+ "
553
+ >
554
+ @if (col.column.sortable) {
555
+ <button
556
+ type="button"
557
+ data-slot="sort"
558
+ [class]="ui.sort"
559
+ (click)="toggleSort(col.column.id)"
560
+ >
561
+ {{ col.column.header }}
562
+ <svg
563
+ aria-hidden="true"
564
+ [class]="col.sort === 'none' ? 'size-[13px] opacity-45' : 'size-[13px]'"
565
+ viewBox="0 0 24 24"
566
+ fill="none"
567
+ stroke="currentColor"
568
+ stroke-width="2"
569
+ stroke-linecap="round"
570
+ stroke-linejoin="round"
571
+ >
572
+ @for (d of sortIcon(col.sort); track d) {
573
+ <path [attr.d]="d" />
574
+ }
575
+ </svg>
576
+ </button>
577
+ } @else {
578
+ {{ col.column.header }}
579
+ }
580
+ @if (col.column.filterOptions?.length) {
581
+ <mimi-dropdown-menu>
582
+ <button
583
+ type="button"
584
+ mimiDropdownMenuTrigger
585
+ data-slot="filter"
586
+ [class]="ui.filter"
587
+ [attr.aria-label]="texts.filter(col.column.header)"
588
+ [attr.data-active]="col.filtered ? '' : null"
589
+ >
590
+ <svg
591
+ aria-hidden="true"
592
+ viewBox="0 0 24 24"
593
+ fill="none"
594
+ stroke="currentColor"
595
+ stroke-width="2"
596
+ stroke-linecap="round"
597
+ stroke-linejoin="round"
598
+ >
599
+ @for (d of icon.filter; track d) {
600
+ <path [attr.d]="d" />
601
+ }
602
+ </svg>
603
+ @if (col.filtered) {
604
+ <span
605
+ aria-hidden="true"
606
+ class="absolute top-0.5 end-0.5 size-1.5 rounded-full bg-info"
607
+ ></span>
608
+ }
609
+ </button>
610
+ <ng-template mimiDropdownMenuContent>
611
+ @for (option of col.column.filterOptions; track $index) {
612
+ <mimi-menu-checkbox-item
613
+ [checked]="isFiltered(col.column.id, option.value)"
614
+ (checkedChange)="toggleFilter(col.column.id, option.value)"
615
+ >{{ option.label }}</mimi-menu-checkbox-item
616
+ >
617
+ }
618
+ </ng-template>
619
+ </mimi-dropdown-menu>
620
+ }
621
+ </span>
622
+ </th>
623
+ }
624
+ @if (actions()) {
625
+ <th scope="col" [class]="ui.actionsHead">
626
+ <span class="sr-only">{{ texts.actions }}</span>
627
+ </th>
628
+ }
629
+ </tr>
630
+ </thead>
631
+ <tbody>
632
+ @if (loading()) {
633
+ @for (n of skeletonRows(); track n) {
634
+ <tr aria-hidden="true" data-slot="skeleton">
635
+ <td [class]="cellClasses" [attr.colspan]="colCount()">
636
+ <span class="flex items-center gap-6">
637
+ <span [class]="ui.skeletonWide"></span>
638
+ <span [class]="ui.skeletonShort"></span>
639
+ <span [class]="ui.skeletonMid"></span>
640
+ </span>
641
+ </td>
642
+ </tr>
643
+ }
644
+ } @else {
645
+ @for (row of pageRows(); track rowId()(row)) {
646
+ <tr class="group/row" [attr.data-selected]="isSelected(row) ? '' : null">
647
+ @if (selectable()) {
648
+ <td [class]="ui.selectCell">
649
+ <mimi-checkbox
650
+ [aria-label]="texts.selectRow(rowLabel(row))"
651
+ [checked]="isSelected(row)"
652
+ (checkedChange)="toggleRow(row)"
653
+ />
654
+ </td>
655
+ }
656
+ @if (detail()) {
657
+ <td [class]="ui.expandCell" [style.inset-inline-start]="expandLeft()">
658
+ <button
659
+ type="button"
660
+ data-slot="expand"
661
+ [class]="ui.expand"
662
+ [attr.aria-label]="texts.expand(rowLabel(row))"
663
+ [attr.aria-expanded]="isExpanded(row)"
664
+ [attr.aria-controls]="isExpanded(row) ? detailId(row) : null"
665
+ (click)="toggleExpanded(row)"
666
+ >
667
+ <svg
668
+ aria-hidden="true"
669
+ viewBox="0 0 24 24"
670
+ fill="none"
671
+ stroke="currentColor"
672
+ stroke-width="2"
673
+ stroke-linecap="round"
674
+ stroke-linejoin="round"
675
+ >
676
+ <path [attr.d]="icon.chevron[0]" />
677
+ </svg>
678
+ </button>
679
+ </td>
680
+ }
681
+ @for (col of columnViews(); track col.column.id) {
682
+ <td
683
+ [class]="col.cellClass"
684
+ [style.inset-inline-start]="col.left"
685
+ [style.min-width]="col.left !== null ? col.column.width : null"
686
+ [style.max-width]="col.left !== null ? col.column.width : null"
687
+ >
688
+ @if (col.cell; as cell) {
689
+ <ng-container
690
+ [mimiTableOutlet]="cell"
691
+ [mimiTableOutletItem]="row"
692
+ [mimiTableOutletValue]="valueOf(col.column, row)"
693
+ [mimiTableOutletDensity]="density()"
694
+ />
695
+ } @else {
696
+ {{ valueOf(col.column, row) }}
697
+ }
698
+ </td>
699
+ }
700
+ @if (actions(); as actions) {
701
+ <td [class]="ui.actionsCell">
702
+ <ng-container
703
+ [mimiTableOutlet]="actions.templateRef"
704
+ [mimiTableOutletItem]="row"
705
+ [mimiTableOutletDensity]="density()"
706
+ />
707
+ </td>
708
+ }
709
+ </tr>
710
+ @if (detail(); as detail) {
711
+ @if (isExpanded(row)) {
712
+ <tr data-slot="detail">
713
+ <td
714
+ class="border-b border-border bg-[color:var(--mimi-table-expanded-bg,var(--mimi-muted))] p-0"
715
+ [attr.colspan]="colCount()"
716
+ >
717
+ <div class="sticky start-0 w-fit max-w-full px-5 py-4" [id]="detailId(row)">
718
+ <ng-container
719
+ [mimiTableOutlet]="detail.templateRef"
720
+ [mimiTableOutletItem]="row"
721
+ [mimiTableOutletDensity]="density()"
722
+ />
723
+ </div>
724
+ </td>
725
+ </tr>
726
+ }
727
+ }
728
+ } @empty {
729
+ <tr>
730
+ <td class="border-b border-border p-0" [attr.colspan]="colCount()">
731
+ <div
732
+ data-slot="empty"
733
+ class="sticky start-0 flex w-full max-w-[760px] flex-col items-center gap-2 px-6 py-14 text-center"
734
+ >
735
+ <span
736
+ class="inline-flex size-[52px] items-center justify-center rounded-full bg-muted text-muted-foreground"
737
+ >
738
+ <svg
739
+ aria-hidden="true"
740
+ class="size-6"
741
+ viewBox="0 0 24 24"
742
+ fill="none"
743
+ stroke="currentColor"
744
+ stroke-width="2"
745
+ stroke-linecap="round"
746
+ stroke-linejoin="round"
747
+ >
748
+ @for (d of icon.searchX; track d) {
749
+ <path [attr.d]="d" />
750
+ }
751
+ </svg>
752
+ </span>
753
+ <span class="text-[15px] font-bold">{{
754
+ narrowed() ? texts.noResults : texts.empty
755
+ }}</span>
756
+ <span class="max-w-[360px] text-[13.5px] text-pretty text-muted-foreground">{{
757
+ narrowed() ? texts.noResultsHint : texts.emptyHint
758
+ }}</span>
759
+ <span class="mt-2 flex gap-2 empty:hidden">
760
+ @if (narrowed()) {
761
+ <button
762
+ type="button"
763
+ data-slot="clear-all"
764
+ [class]="ui.clearAll"
765
+ (click)="clearAll()"
766
+ >
767
+ {{ texts.clearFilters }}
768
+ </button>
769
+ }
770
+ <ng-content select="[mimiTableEmpty]"
771
+ /></span>
772
+ </div>
773
+ </td>
774
+ </tr>
775
+ }
776
+ }
777
+ </tbody>
778
+ </table>
779
+ </div>
780
+
781
+ @if (!loading() && count() > 0) {
782
+ <div
783
+ data-slot="footer"
784
+ class="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3 pr-3 pl-4 text-[13px] text-muted-foreground tabular-nums"
785
+ >
786
+ <span data-slot="range">{{ rangeText() }}</span>
787
+ <mimi-pagination
788
+ [total]="count()"
789
+ [pageSizeOptions]="pageSizeOptions()"
790
+ [(page)]="page"
791
+ [(pageSize)]="pageSize"
792
+ />
793
+ </div>
794
+ }
795
+ `,
796
+ host: {
797
+ '[class]': 'classes()',
798
+ '[attr.aria-label]': 'null',
799
+ '[attr.data-density]': 'density()',
800
+ '[attr.data-scrolled]': 'scrolled() ? "" : null',
801
+ '[attr.data-loading]': 'loading() ? "" : null',
802
+ },
803
+ changeDetection: ChangeDetectionStrategy.OnPush,
804
+ })
805
+ export class MimiDataTable<T = any> {
806
+ protected readonly texts = inject(MIMI_DATA_TABLE_TEXT);
807
+
808
+ /** Las filas (o, con `total`, las de la página actual). */
809
+ readonly data = input<readonly T[]>([]);
810
+ readonly columns = input<readonly MimiTableColumn<T>[]>([]);
811
+ /**
812
+ * Identificador estable de cada fila. Por defecto, `row.id`; si la fila no tiene `id`, la propia
813
+ * fila (el objeto), y en modo desarrollo avisa de que falta `rowId` (G3.P).
814
+ */
815
+ readonly rowId = input<(row: T) => unknown>(DEFAULT_ROW_ID);
816
+ /** Agrega la columna de casillas y la barra de selección. */
817
+ readonly selectable = input(false, { transform: booleanAttribute });
818
+ /** Muestra la barra (búsqueda, chips, densidad). Por defecto, `true`. */
819
+ readonly toolbar = input(true, { transform: booleanAttribute });
820
+ /** Filas de esqueleto, con `aria-busy`. */
821
+ readonly loading = input(false, { transform: booleanAttribute });
822
+ /** Modo servidor: el total de filas. Con él, `data` ya es la página y la tabla no la procesa. */
823
+ readonly total = input<number | null>(null);
824
+ readonly pageSizeOptions = input<readonly number[]>([10, 25, 50]);
825
+ readonly searchPlaceholder = input('');
826
+ /** Nombre de la tabla. */
827
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
828
+ readonly userClass = input('', { alias: 'class' });
829
+
830
+ /** Ids elegidos. Admite `[(selection)]`. */
831
+ readonly selection = model<unknown[]>([]);
832
+ /** Ids con el detalle abierto. */
833
+ readonly expanded = model<unknown[]>([]);
834
+ readonly sort = model<MimiTableSort | null>(null);
835
+ /** La búsqueda global. */
836
+ readonly query = model('');
837
+ readonly filters = model<MimiTableFilters>({});
838
+ /** La página, desde 1. */
839
+ readonly page = model(1);
840
+ readonly pageSize = model(10);
841
+ readonly density = model<MimiTableDensity>('compact');
842
+
843
+ private readonly cells = contentChildren(MimiTableCell);
844
+ protected readonly detail = contentChild(MimiTableDetail);
845
+ protected readonly actions = contentChild(MimiTableActions);
846
+ protected readonly bulk = contentChild(MimiTableBulk);
847
+
848
+ protected readonly icon = ICON;
849
+ protected readonly ui = UI;
850
+ protected readonly toolbarClasses = TOOLBAR_CLASSES;
851
+ protected readonly cellClasses = CELL_CLASSES;
852
+ protected readonly classes = computed(() => cn(HOST_CLASSES, this.userClass()));
853
+
854
+ private readonly baseId = `mimi-data-table-${nextId++}`;
855
+ protected readonly scrolled = signal(false);
856
+
857
+ private readonly server = computed(() => this.total() !== null);
858
+
859
+ /** Desde dónde empiezan las columnas de datos fijas: la casilla (44px) y el expandir (36px). */
860
+ protected readonly expandLeft = computed(() => (this.selectable() ? '44px' : '0px'));
861
+
862
+ protected readonly columnViews = computed<ColumnView<T>[]>(() => {
863
+ const cells = this.cells();
864
+ const sort = this.sort();
865
+ const filters = this.filters();
866
+ const offsets: string[] = [];
867
+ if (this.selectable()) offsets.push('44px');
868
+ if (this.detail()) offsets.push('36px');
869
+ const columns = this.columns();
870
+ const lastSticky = columns.reduce((last, col, i) => (col.sticky && col.width ? i : last), -1);
871
+ return columns.map((column, i) => {
872
+ const sticky = !!column.sticky && !!column.width && i <= lastSticky;
873
+ const left = sticky ? (offsets.length ? `calc(${offsets.join(' + ')})` : '0px') : null;
874
+ if (sticky) offsets.push(column.width!);
875
+ const edge = sticky && i === lastSticky;
876
+ const extra = cn(edge && EDGE_CLASSES, column.align === 'end' && 'text-end');
877
+ return {
878
+ column,
879
+ left,
880
+ edge,
881
+ headClass: cn(HEAD_CLASSES, sticky && 'sticky z-[2]', extra),
882
+ cellClass: cn(CELL_CLASSES, sticky && 'sticky z-[1]', extra),
883
+ sort: !column.sortable
884
+ ? null
885
+ : sort?.column === column.id
886
+ ? sort.direction === 'asc'
887
+ ? 'ascending'
888
+ : 'descending'
889
+ : 'none',
890
+ filtered: (filters[column.id]?.length ?? 0) > 0,
891
+ cell:
892
+ (cells.find((c) => c.column() === column.id)?.templateRef as
893
+ TemplateRef<MimiTableCellContext<T>> | undefined) ?? null,
894
+ };
895
+ });
896
+ });
897
+
898
+ protected readonly colCount = computed(
899
+ () =>
900
+ this.columns().length +
901
+ (this.selectable() ? 1 : 0) +
902
+ (this.detail() ? 1 : 0) +
903
+ (this.actions() ? 1 : 0),
904
+ );
905
+
906
+ /** Hay búsqueda o filtros: el vacío es «sin resultados». */
907
+ protected readonly narrowed = computed(
908
+ () => !!this.query().trim() || Object.values(this.filters()).some((v) => v.length > 0),
909
+ );
910
+
911
+ private readonly filteredRows = computed(() => {
912
+ const data = this.data();
913
+ if (this.server()) return data;
914
+ const columns = this.columns();
915
+ const query = fold(this.query().trim());
916
+ const filters = this.filters();
917
+ const searchable = columns.filter((c) => c.searchable !== false);
918
+ const active = columns.filter((c) => (filters[c.id]?.length ?? 0) > 0);
919
+ let rows = data.filter(
920
+ (row) =>
921
+ active.every((c) => filters[c.id].includes(this.valueOf(c, row))) &&
922
+ (!query ||
923
+ searchable.some((c) => fold(String(this.valueOf(c, row) ?? '')).includes(query))),
924
+ );
925
+ const sort = this.sort();
926
+ const column = sort && columns.find((c) => c.id === sort.column);
927
+ if (sort && column) {
928
+ const factor = sort.direction === 'asc' ? 1 : -1;
929
+ rows = [...rows].sort(
930
+ (a, b) => factor * compareValues(this.valueOf(column, a), this.valueOf(column, b)),
931
+ );
932
+ }
933
+ return rows;
934
+ });
935
+
936
+ /** Filas en total (con la búsqueda y los filtros), o `total` en modo servidor. */
937
+ protected readonly count = computed(() => this.total() ?? this.filteredRows().length);
938
+
939
+ protected readonly pageRows = computed(() => {
940
+ const rows = this.filteredRows();
941
+ if (this.server()) return rows;
942
+ const size = this.pageSize();
943
+ const start = (this.page() - 1) * size;
944
+ return rows.slice(start, start + size);
945
+ });
946
+
947
+ protected readonly pageIds = computed(() => this.pageRows().map((row) => this.rowId()(row)));
948
+ protected readonly pageState = computed<'all' | 'some' | 'none'>(() => {
949
+ const ids = this.pageIds();
950
+ const selected = this.selection();
951
+ const n = ids.filter((id) => selected.includes(id)).length;
952
+ return n === 0 ? 'none' : n === ids.length ? 'all' : 'some';
953
+ });
954
+
955
+ protected readonly skeletonRows = computed(() =>
956
+ Array.from({ length: Math.min(this.pageSize(), 5) }, (_, i) => i),
957
+ );
958
+
959
+ protected readonly chips = computed(() => {
960
+ const filters = this.filters();
961
+ return this.columns().flatMap((column) =>
962
+ (filters[column.id] ?? []).map((value, i) => {
963
+ const label =
964
+ column.filterOptions?.find((o) => Object.is(o.value, value))?.label ?? String(value);
965
+ return {
966
+ key: `${column.id}-${i}`,
967
+ column: column.id,
968
+ value,
969
+ name: `${column.header}: ${label}`,
970
+ };
971
+ }),
972
+ );
973
+ });
974
+
975
+ protected readonly rangeText = computed(() => {
976
+ const count = this.count();
977
+ const from = Math.min((this.page() - 1) * this.pageSize() + 1, count);
978
+ // Nunca «desde > hasta»: una página sin filas todavía (modo servidor) cuenta como la fila `from`.
979
+ const to = Math.max(from, Math.min(from + this.pageRows().length - 1, count));
980
+ return this.texts.range(from, to, count);
981
+ });
982
+
983
+ /** Lo que oyen los lectores: las filas elegidas o, al buscar o filtrar, el rango. */
984
+ protected readonly liveText = computed(() => {
985
+ if (this.loading()) return this.texts.loading;
986
+ const selected = this.selection().length;
987
+ if (this.selectable() && selected) return this.texts.selected(selected);
988
+ if (!this.narrowed()) return '';
989
+ return this.count() ? this.rangeText() : this.texts.noResults;
990
+ });
991
+
992
+ /**
993
+ * Si la búsqueda, los filtros o (con `total`) un total menor dejan la página fuera de rango,
994
+ * vuelve a la última (G3.P).
995
+ */
996
+ private readonly clampPage = effect(() => {
997
+ if (this.loading()) return;
998
+ const pages = Math.max(1, Math.ceil(this.count() / this.pageSize()));
999
+ if (untracked(this.page) > pages) untracked(() => this.page.set(pages));
1000
+ });
1001
+
1002
+ /** En desarrollo, avisa una vez si las filas no traen `id` y no hay `rowId` (G3.P). */
1003
+ private warnedRowId = false;
1004
+ private readonly warnRowId = isDevMode()
1005
+ ? effect(() => {
1006
+ const data = this.data();
1007
+ if (this.warnedRowId || this.rowId() !== DEFAULT_ROW_ID) return;
1008
+ if (!data.some((row) => (row as { id?: unknown } | null)?.id == null)) return;
1009
+ this.warnedRowId = true;
1010
+ console.warn(
1011
+ '[Mimi] Data Table: las filas no tienen `id` y falta `rowId`. Se identifican por el ' +
1012
+ 'objeto, así que la selección y el detalle abierto se pierden si `data` trae objetos ' +
1013
+ 'nuevos. Usa `[rowId]="fila => fila.codigo"`.',
1014
+ );
1015
+ })
1016
+ : null;
1017
+
1018
+ /**
1019
+ * Al buscar o filtrar (sin `total`), la selección pierde las filas que ya no se ven: la barra
1020
+ * nunca cuenta ni entrega filas ocultas. Quitar la búsqueda no devuelve lo podado (G3.P).
1021
+ */
1022
+ private readonly pruneSelection = effect(() => {
1023
+ this.query();
1024
+ this.filters();
1025
+ if (this.server() || !this.narrowed()) return;
1026
+ untracked(() => {
1027
+ const selected = this.selection();
1028
+ if (!selected.length) return;
1029
+ const visible = new Set(this.filteredRows().map((row) => this.rowId()(row)));
1030
+ const kept = selected.filter((id) => visible.has(id));
1031
+ if (kept.length !== selected.length) this.selection.set(kept);
1032
+ });
1033
+ });
1034
+
1035
+ protected valueOf(column: MimiTableColumn<T>, row: T): unknown {
1036
+ return column.value ? column.value(row) : (row as Record<string, unknown>)[column.id];
1037
+ }
1038
+
1039
+ protected rowLabel(row: T): string {
1040
+ const first = this.columns()[0];
1041
+ return first ? String(this.valueOf(first, row) ?? '') : '';
1042
+ }
1043
+
1044
+ /** Un número por id de fila: el id del detalle sigue a la fila, no a su lugar en la página. */
1045
+ private readonly detailKeys = new Map<unknown, number>();
1046
+
1047
+ protected detailId(row: T): string {
1048
+ const id = this.rowId()(row);
1049
+ let key = this.detailKeys.get(id);
1050
+ if (key === undefined) {
1051
+ key = this.detailKeys.size;
1052
+ this.detailKeys.set(id, key);
1053
+ }
1054
+ return `${this.baseId}-detail-${key}`;
1055
+ }
1056
+
1057
+ protected sortIcon(sort: ColumnView<T>['sort']): readonly string[] {
1058
+ return sort === 'ascending'
1059
+ ? ICON.sortAsc
1060
+ : sort === 'descending'
1061
+ ? ICON.sortDesc
1062
+ : ICON.sortNone;
1063
+ }
1064
+
1065
+ protected isSelected(row: T): boolean {
1066
+ return this.selection().includes(this.rowId()(row));
1067
+ }
1068
+
1069
+ protected isExpanded(row: T): boolean {
1070
+ return this.expanded().includes(this.rowId()(row));
1071
+ }
1072
+
1073
+ protected isFiltered(column: string, value: unknown): boolean {
1074
+ return (this.filters()[column] ?? []).some((v) => Object.is(v, value));
1075
+ }
1076
+
1077
+ protected toggleRow(row: T): void {
1078
+ const id = this.rowId()(row);
1079
+ this.selection.update((ids) => (ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]));
1080
+ }
1081
+
1082
+ protected togglePage(checked: boolean): void {
1083
+ const page = this.pageIds();
1084
+ this.selection.update((ids) =>
1085
+ checked
1086
+ ? [...ids, ...page.filter((id) => !ids.includes(id))]
1087
+ : ids.filter((id) => !page.includes(id)),
1088
+ );
1089
+ }
1090
+
1091
+ protected toggleExpanded(row: T): void {
1092
+ const id = this.rowId()(row);
1093
+ this.expanded.update((ids) => (ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]));
1094
+ }
1095
+
1096
+ /** Sin orden → ascendente → descendente → sin orden. */
1097
+ protected toggleSort(column: string): void {
1098
+ const sort = this.sort();
1099
+ this.sort.set(
1100
+ sort?.column !== column
1101
+ ? { column, direction: 'asc' }
1102
+ : sort.direction === 'asc'
1103
+ ? { column, direction: 'desc' }
1104
+ : null,
1105
+ );
1106
+ this.page.set(1);
1107
+ }
1108
+
1109
+ protected toggleFilter(column: string, value: unknown): void {
1110
+ const filters = this.filters();
1111
+ const values = filters[column] ?? [];
1112
+ const next = values.some((v) => Object.is(v, value))
1113
+ ? values.filter((v) => !Object.is(v, value))
1114
+ : [...values, value];
1115
+ this.filters.set({ ...filters, [column]: next });
1116
+ this.page.set(1);
1117
+ }
1118
+
1119
+ protected clearFilters(): void {
1120
+ this.filters.set({});
1121
+ this.page.set(1);
1122
+ }
1123
+
1124
+ /** «Limpiar filtros» del vacío: también la búsqueda. */
1125
+ protected clearAll(): void {
1126
+ this.query.set('');
1127
+ this.clearFilters();
1128
+ }
1129
+
1130
+ protected setQuery(query: string): void {
1131
+ this.query.set(query);
1132
+ this.page.set(1);
1133
+ }
1134
+
1135
+ protected setDensity(value: unknown): void {
1136
+ if (value === 'compact' || value === 'comfortable') this.density.set(value);
1137
+ }
1138
+
1139
+ protected onScroll(event: Event): void {
1140
+ // En RTL el desplazamiento horizontal es negativo.
1141
+ this.scrolled.set(Math.abs((event.target as HTMLElement).scrollLeft) > 0);
1142
+ }
1143
+ }
1144
+
1145
+ /** Todas las piezas de Data Table, para importarlas juntas. */
1146
+ export const MimiDataTableImports = [
1147
+ MimiDataTable,
1148
+ MimiTableCell,
1149
+ MimiTableDetail,
1150
+ MimiTableActions,
1151
+ MimiTableBulk,
1152
+ MimiTableEmpty,
1153
+ ] as const;