@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.
- package/README.md +29 -7
- package/dist/init/index.js +8 -0
- package/dist/registry.js +4 -1
- package/dist/registry.json +87 -1
- package/dist/templates/blocks/login-01/login-01.ts +12 -3
- package/dist/templates/blocks/login-02/login-02.ts +12 -3
- package/dist/templates/blocks/otp-01/otp-01.ts +12 -3
- package/dist/templates/blocks/pricing-01/pricing-01.ts +1 -1
- package/dist/templates/blocks/settings-01/settings-01.ts +13 -3
- package/dist/templates/blocks/signup-01/signup-01.ts +12 -2
- package/dist/templates/blocks/table-01/index.ts +1 -0
- package/dist/templates/blocks/table-01/table-01.html +214 -0
- package/dist/templates/blocks/table-01/table-01.ts +266 -0
- package/dist/templates/components/checkbox/checkbox.ts +14 -5
- package/dist/templates/components/command/command-texts.ts +65 -0
- package/dist/templates/components/command/command.ts +553 -0
- package/dist/templates/components/command/index.ts +13 -0
- package/dist/templates/components/data-table/data-table-texts.ts +110 -0
- package/dist/templates/components/data-table/data-table.ts +1153 -0
- package/dist/templates/components/data-table/index.ts +18 -0
- package/dist/templates/components/sortable-list/index.ts +9 -0
- package/dist/templates/components/sortable-list/sortable-list-texts.ts +71 -0
- package/dist/templates/components/sortable-list/sortable-list.ts +510 -0
- package/dist/templates/components/tree/index.ts +9 -0
- package/dist/templates/components/tree/tree-texts.ts +45 -0
- package/dist/templates/components/tree/tree.ts +730 -0
- package/dist/templates/theme/provider.ts +4 -0
- package/dist/templates/theme/types.ts +84 -0
- package/dist/ui/index.js +232 -40
- package/dist/ui/schema.json +30 -6
- package/dist/utils/pages.js +138 -0
- package/dist/utils/project.js +33 -9
- package/dist/utils/tailwind.js +15 -11
- package/dist/utils/write-files.js +32 -7
- 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;
|