beez-ui 0.4.2 → 0.5.1

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 (53) hide show
  1. package/CHANGELOG.md +35 -27
  2. package/README.md +28 -5
  3. package/dist/adapters/unpic-image.js +92 -14
  4. package/dist/components/alert-dialog.js +444 -26
  5. package/dist/components/alert.js +153 -18
  6. package/dist/components/animated-theme-toggler.js +215 -76
  7. package/dist/components/avatar.js +247 -15
  8. package/dist/components/badge.js +18 -16
  9. package/dist/components/button.js +85 -28
  10. package/dist/components/calendar.js +327 -68
  11. package/dist/components/card.js +39 -9
  12. package/dist/components/carousel.js +454 -84
  13. package/dist/components/checkbox.js +50 -3
  14. package/dist/components/data-table.js +827 -583
  15. package/dist/components/dialog.js +363 -22
  16. package/dist/components/dropdown-menu.d.ts +1 -0
  17. package/dist/components/dropdown-menu.js +544 -32
  18. package/dist/components/filter-query-bar.js +809 -468
  19. package/dist/components/filter-query-grammar.js +781 -736
  20. package/dist/components/form.js +305 -43
  21. package/dist/components/highlighter.js +142 -87
  22. package/dist/components/hover-card.js +97 -7
  23. package/dist/components/input-group.js +261 -41
  24. package/dist/components/input.js +8 -2
  25. package/dist/components/label.js +38 -3
  26. package/dist/components/link.js +45 -7
  27. package/dist/components/pagination.js +332 -16
  28. package/dist/components/popover.js +222 -16
  29. package/dist/components/radio-group.js +85 -5
  30. package/dist/components/select.js +451 -22
  31. package/dist/components/separator.js +43 -3
  32. package/dist/components/sheet.js +345 -22
  33. package/dist/components/sidebar.js +1385 -193
  34. package/dist/components/skeleton.js +6 -2
  35. package/dist/components/svgs/whatsappIcon.js +11 -1
  36. package/dist/components/switch.js +55 -3
  37. package/dist/components/table.js +288 -18
  38. package/dist/components/tabs.js +161 -18
  39. package/dist/components/textarea.js +7 -2
  40. package/dist/components/themed-toaster.js +63 -20
  41. package/dist/components/tooltip.js +128 -9
  42. package/dist/components/typing-animation.js +335 -150
  43. package/dist/constants/sidebar.js +1 -4
  44. package/dist/hooks/use-mobile.js +8 -8
  45. package/dist/hooks/use-sidebar-persistence.js +27 -27
  46. package/dist/lib/image-dimensions.js +2 -2
  47. package/dist/lib/year-month-filter.js +38 -40
  48. package/dist/next.js +69 -17
  49. package/dist/providers/beez-ui-provider.js +48 -7
  50. package/dist/providers/native-provider.js +40 -6
  51. package/dist/styles.css +1 -1
  52. package/dist/tanstack.js +35 -23
  53. package/package.json +4 -1
@@ -1,15 +1,15 @@
1
1
  "use client";
2
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
2
  import * as React from "react";
4
- import { flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table";
5
- import { CircleAlert, ChevronDown, ChevronRight, Ellipsis, Eraser, X, } from "lucide-react";
3
+ import { flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
4
+ import { CircleAlert, ChevronDown, ChevronRight, Ellipsis, Eraser, X } from "lucide-react";
6
5
  import { Button } from "./button.js";
7
6
  import { Badge } from "./badge.js";
8
7
  import { FilterQueryBar } from "./filter-query-bar.js";
9
- import { parseFilterQuery, serializeFilterQuery, } from "./filter-query-grammar.js";
10
- import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
8
+ import { parseFilterQuery, serializeFilterQuery } from "./filter-query-grammar.js";
9
+ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./dropdown-menu.js";
11
10
  import { Input } from "./input.js";
12
- import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "./table.js";
11
+ import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "./table.js";
12
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
13
13
  const DIACRITICS_PATTERN = /[\u0300-\u036f]/g;
14
14
  const CLEAR_FILTER_ARIA_LABEL = "Limpiar filtro";
15
15
  const ACTIVE_EXCLUSIONS_SR_LABEL = "Filtros de exclusión activos";
@@ -21,592 +21,836 @@ const CLEAR_ALL_EXCLUSIONS_ARIA_LABEL = "Quitar todas las exclusiones";
21
21
  const CLEAR_ALL_EXCLUSIONS_FROM_INPUT_ARIA_LABEL = "Limpiar filtros excluidos";
22
22
  const REVERSE_FILTER_PENDING_MESSAGE = "Estás escribiendo una exclusión. Presioná Enter para aplicarla.";
23
23
  function normalizeFilterToken(value) {
24
- return value
25
- .normalize("NFD")
26
- .replace(DIACRITICS_PATTERN, "")
27
- .toLocaleLowerCase()
28
- .trim();
24
+ return value.normalize("NFD").replace(DIACRITICS_PATTERN, "").toLocaleLowerCase().trim();
29
25
  }
30
26
  function getTableFilterValue(filterValue, showExcludeFilterToggle) {
31
- const normalizedFilterValue = filterValue.trimStart();
32
- if (showExcludeFilterToggle && normalizedFilterValue.startsWith("-")) {
33
- return "";
34
- }
35
- return filterValue;
27
+ const normalizedFilterValue = filterValue.trimStart();
28
+ if (showExcludeFilterToggle && normalizedFilterValue.startsWith("-")) {
29
+ return "";
30
+ }
31
+ return filterValue;
36
32
  }
37
33
  function areColumnFiltersEqual(leftFilters, rightFilters) {
38
- if (leftFilters.length !== rightFilters.length) {
39
- return false;
40
- }
41
- return leftFilters.every((leftFilter, index) => leftFilter.id === rightFilters[index]?.id &&
42
- leftFilter.value === rightFilters[index]?.value);
34
+ if (leftFilters.length !== rightFilters.length) {
35
+ return false;
36
+ }
37
+ return leftFilters.every((leftFilter, index) => leftFilter.id === rightFilters[index]?.id && leftFilter.value === rightFilters[index]?.value);
43
38
  }
44
39
  function areStringArraysEqual(leftValues, rightValues) {
45
- if (leftValues.length !== rightValues.length) {
46
- return false;
47
- }
48
- return leftValues.every((value, index) => value === rightValues[index]);
40
+ if (leftValues.length !== rightValues.length) {
41
+ return false;
42
+ }
43
+ return leftValues.every((value, index) => value === rightValues[index]);
49
44
  }
50
45
  function areColumnFilterValuesEqual(leftValue, rightValue) {
51
- if (leftValue.kind !== rightValue.kind) {
52
- return false;
53
- }
54
- if (leftValue.kind === "numberRange" && rightValue.kind === "numberRange") {
55
- return leftValue.min === rightValue.min && leftValue.max === rightValue.max;
56
- }
57
- if (leftValue.kind === "yearMonthRange" && rightValue.kind === "yearMonthRange") {
58
- return (leftValue.mode === rightValue.mode &&
59
- leftValue.min === rightValue.min &&
60
- leftValue.max === rightValue.max);
61
- }
62
- if (leftValue.kind === "enum" && rightValue.kind === "enum") {
63
- return leftValue.value === rightValue.value;
64
- }
65
- if (leftValue.kind === "presence" && rightValue.kind === "presence") {
66
- return leftValue.value === rightValue.value;
67
- }
68
- return false;
46
+ if (leftValue.kind !== rightValue.kind) {
47
+ return false;
48
+ }
49
+ if (leftValue.kind === "numberRange" && rightValue.kind === "numberRange") {
50
+ return leftValue.min === rightValue.min && leftValue.max === rightValue.max;
51
+ }
52
+ if (leftValue.kind === "yearMonthRange" && rightValue.kind === "yearMonthRange") {
53
+ return leftValue.mode === rightValue.mode && leftValue.min === rightValue.min && leftValue.max === rightValue.max;
54
+ }
55
+ if (leftValue.kind === "enum" && rightValue.kind === "enum") {
56
+ return leftValue.value === rightValue.value;
57
+ }
58
+ if (leftValue.kind === "presence" && rightValue.kind === "presence") {
59
+ return leftValue.value === rightValue.value;
60
+ }
61
+ return false;
69
62
  }
70
63
  function areAdvancedFilterMapsEqual(leftMap, rightMap) {
71
- const leftKeys = Object.keys(leftMap);
72
- const rightKeys = Object.keys(rightMap);
73
- if (leftKeys.length !== rightKeys.length) {
74
- return false;
75
- }
76
- return leftKeys.every((key) => {
77
- const rightValue = rightMap[key];
78
- return (rightValue != null && areColumnFilterValuesEqual(leftMap[key], rightValue));
79
- });
64
+ const leftKeys = Object.keys(leftMap);
65
+ const rightKeys = Object.keys(rightMap);
66
+ if (leftKeys.length !== rightKeys.length) {
67
+ return false;
68
+ }
69
+ return leftKeys.every((key) => {
70
+ const rightValue = rightMap[key];
71
+ return rightValue != null && areColumnFilterValuesEqual(leftMap[key], rightValue);
72
+ });
80
73
  }
81
- export function DataTable({ columns, data, emptyMessage, toolbarActions, onVisibleRowsChange, onCellClick, getRowClassName, sorting: controlledSorting, onSortingChange, columnVisibility: controlledColumnVisibility, onColumnVisibilityChange, filterColumnId, filterLabel = "Filtrar", filterPlaceholder = "Filtrar...", filterExtraContent, filterValue: controlledFilterValue, onFilterValueChange, queryFilterConfig, queryFilterPlaceholder, queryFilterLabel, onAppliedFiltersChange, queryFilterControlsRef, queryFilterTrailingAction, rowGroups, showExcludeFilterToggle = false, excludeFilterValues: controlledExcludeFilterValues, onExcludeFilterValuesChange, excludeFilterPlaceholder = "Excluir por descripción", excludeFilterLabel = "Filtro de exclusión", excludeFilterToggleLabel = "Mostrar filtros de exclusión", excludeFilterRowsCountByValue, excludeFilterUniqueRowsCount, showColumnVisibilityToggle = false, columnVisibilityButtonLabel = "Columnas", columnVisibilityMenuLabel = "Mostrar columnas", columnVisibilityMenuExtraContent, sortingBadgeLabelOverrides, sortingBadgeIgnoredColumnIds, selectAllColumnsLabel = "Mostrar todas", deselectAllColumnsLabel = "Ocultar todas", hideableColumnsDefaultVisibility, }) {
82
- const [uncontrolledSorting, setUncontrolledSorting] = React.useState([]);
83
- const [columnFilters, setColumnFilters] = React.useState([]);
84
- const [uncontrolledFilterValue, setUncontrolledFilterValue] = React.useState("");
85
- const [filterQueryDraft, setFilterQueryDraft] = React.useState("");
86
- // Filtros estructurados de la última query parseada. Se preservan para que la
87
- // re-sincronización canónica (al perder foco) NO descarte los qualifiers sin
88
- // columna (textMatch/folder) ni los negados, que no viven en
89
- // `advancedFiltersAppliedByColumn` y quedarían como filtros activos invisibles.
90
- const [appliedFiltersDraft, setAppliedFiltersDraft] = React.useState([]);
91
- const [isQueryFilterFocused, setIsQueryFilterFocused] = React.useState(false);
92
- const [uncontrolledExcludeFilterValues, setUncontrolledExcludeFilterValues] = React.useState([]);
93
- const [excludeFilterInputValue, setExcludeFilterInputValue] = React.useState("");
94
- const [excludeFilterErrorMessage, setExcludeFilterErrorMessage] = React.useState(null);
95
- const [isExcludeFilterInputVisible, setIsExcludeFilterInputVisible] = React.useState(false);
96
- const [uncontrolledColumnVisibility, setUncontrolledColumnVisibility] = React.useState({});
97
- const [advancedFiltersAppliedByColumn, setAdvancedFiltersAppliedByColumn] = React.useState({});
98
- const sorting = controlledSorting ?? uncontrolledSorting;
99
- const columnVisibility = controlledColumnVisibility ?? uncontrolledColumnVisibility;
100
- const shouldShowQueryFilter = queryFilterConfig != null;
101
- const isFilterValueControlled = controlledFilterValue != null;
102
- const isExcludeFilterValuesControlled = controlledExcludeFilterValues != null;
103
- const resolvedFilterValue = isFilterValueControlled
104
- ? controlledFilterValue
105
- : uncontrolledFilterValue;
106
- const resolvedExcludeFilterValues = isExcludeFilterValuesControlled
107
- ? controlledExcludeFilterValues
108
- : uncontrolledExcludeFilterValues;
109
- const hasActiveExcludeFilterValues = resolvedExcludeFilterValues.length > 0;
110
- const tableFilterValue = getTableFilterValue(resolvedFilterValue, showExcludeFilterToggle);
111
- const hasPendingReverseFilter = showExcludeFilterToggle && resolvedFilterValue.trimStart().startsWith("-");
112
- const advancedFilterColumnIds = React.useMemo(() => {
113
- const columnIds = new Set();
114
- for (const queryQualifier of queryFilterConfig ?? []) {
115
- if (queryQualifier.columnId) {
116
- columnIds.add(queryQualifier.columnId);
117
- }
118
- }
119
- return columnIds;
120
- }, [queryFilterConfig]);
121
- const handleExcludeFilterValuesChange = React.useCallback((nextExcludeFilterValues) => {
122
- if (!isExcludeFilterValuesControlled) {
123
- setUncontrolledExcludeFilterValues(nextExcludeFilterValues);
124
- }
125
- onExcludeFilterValuesChange?.(nextExcludeFilterValues);
126
- }, [isExcludeFilterValuesControlled, onExcludeFilterValuesChange]);
127
- // La barra de query es la fuente de verdad mientras se tipea: parsea el texto
128
- // y escribe en los mismos canales que usan los controles clásicos (descripción,
129
- // exclusiones y avanzados), de modo que ambas vistas queden sincronizadas.
130
- const handleQueryFilterChange = React.useCallback((nextQuery) => {
131
- // Update urgente: mantiene el input y el dropdown de sugerencias
132
- // respondiendo de inmediato a cada tecla.
133
- setFilterQueryDraft(nextQuery);
134
- if (!queryFilterConfig) {
135
- return;
136
- }
137
- const parsedQuery = parseFilterQuery(nextQuery, queryFilterConfig);
138
- // El re-filtrado vuelve a renderizar toda la tabla (celdas con popovers,
139
- // tooltips y menús por fila), lo que bloquearía el commit del dropdown si
140
- // corriera urgente. Se difiere como transición de baja prioridad para que
141
- // el tipeo y las sugerencias no se cuelguen.
142
- React.startTransition(() => {
143
- // Cada canal se actualiza solo si cambió. Propagar valores iguales
144
- // (p. ej. una lista de exclusiones vacía nueva en cada tecla) dispararía
145
- // re-cálculos de memos y un re-render extra de todas las filas sin
146
- // ningún cambio observable.
147
- if (parsedQuery.descriptionFilter !== resolvedFilterValue) {
148
- if (!isFilterValueControlled) {
149
- setUncontrolledFilterValue(parsedQuery.descriptionFilter);
150
- }
151
- onFilterValueChange?.(parsedQuery.descriptionFilter);
152
- }
153
- if (!areStringArraysEqual(parsedQuery.excludedDescriptionFilters, resolvedExcludeFilterValues)) {
154
- handleExcludeFilterValuesChange(parsedQuery.excludedDescriptionFilters);
155
- }
156
- if (!areAdvancedFilterMapsEqual(parsedQuery.advancedFiltersByColumn, advancedFiltersAppliedByColumn)) {
157
- setAdvancedFiltersAppliedByColumn(parsedQuery.advancedFiltersByColumn);
158
- }
159
- setAppliedFiltersDraft(parsedQuery.appliedFilters);
160
- onAppliedFiltersChange?.(parsedQuery.appliedFilters);
161
- });
162
- }, [
163
- advancedFiltersAppliedByColumn,
164
- handleExcludeFilterValuesChange,
165
- isFilterValueControlled,
166
- onAppliedFiltersChange,
167
- onFilterValueChange,
168
- queryFilterConfig,
169
- resolvedExcludeFilterValues,
170
- resolvedFilterValue,
171
- ]);
172
- // Forma canónica de los filtros activos, para reflejarlos en la barra de query.
173
- // Se serializa desde los filtros aplicados id-backed (no desde el texto de la
174
- // barra), de modo que un filtro de carpeta apunte siempre por `folderId`: si la
175
- // carpeta se renombra, su slug se re-deriva del nombre nuevo; si desaparece, el
176
- // filtro se descarta. Definida antes del efecto de reparse para que este pueda
177
- // re-propagar la forma id-backed cuando cambia la config.
178
- const canonicalQueryFromFilters = React.useMemo(() => {
179
- if (!queryFilterConfig) {
180
- return "";
181
- }
182
- return serializeFilterQuery({
183
- advancedFiltersByColumn: advancedFiltersAppliedByColumn,
184
- appliedFilters: appliedFiltersDraft,
185
- descriptionFilter: tableFilterValue,
186
- excludedDescriptionFilters: resolvedExcludeFilterValues,
187
- invalidTokens: [],
188
- }, queryFilterConfig);
189
- }, [
190
- advancedFiltersAppliedByColumn,
191
- appliedFiltersDraft,
192
- queryFilterConfig,
193
- resolvedExcludeFilterValues,
194
- tableFilterValue,
195
- ]);
196
- // Firma estructural de la config de qualifiers: dispara la reconciliación solo
197
- // cuando su CONTENIDO cambia (no en cada render con una referencia de array
198
- // nueva), evitando re-propagar la forma canónica mientras el usuario tipea.
199
- const queryFilterConfigSignature = React.useMemo(() => (queryFilterConfig ? JSON.stringify(queryFilterConfig) : ""), [queryFilterConfig]);
200
- const previousQueryFilterConfigSignatureRef = React.useRef(null);
201
- // Si la config de qualifiers cambia (p. ej. se renombra una carpeta y su opción
202
- // pasa de `carpeta:hogar` a `carpeta:casa`, o se borra y desaparece), se
203
- // re-propaga la forma canónica id-backed contra la NUEVA config en vez de
204
- // re-parsear el texto con el slug viejo. Así un filtro de carpeta seleccionado
205
- // sobrevive a renames y colisiones de slug (se vuelve a serializar por
206
- // `folderId`), mientras que un filtro realmente huérfano (cuyo `folderId` ya no
207
- // existe) se descarta del predicado en vez de seguir filtrando de forma
208
- // invisible.
209
- React.useEffect(() => {
210
- const previousSignature = previousQueryFilterConfigSignatureRef.current;
211
- previousQueryFilterConfigSignatureRef.current = queryFilterConfigSignature;
212
- // Primer render o config sin contenido: nada que reconciliar todavía.
213
- if (previousSignature === null) {
214
- return;
215
- }
216
- if (!queryFilterConfig ||
217
- !filterQueryDraft ||
218
- previousSignature === queryFilterConfigSignature) {
219
- return;
220
- }
221
- handleQueryFilterChange(canonicalQueryFromFilters);
222
- // Solo debe re-correr cuando cambia el contenido de la config; el resto de los
223
- // valores se leen como estado actual y no como disparadores para no re-parsear
224
- // en cada tecla.
225
- // eslint-disable-next-line react-hooks/exhaustive-deps
226
- }, [queryFilterConfigSignature]);
227
- // Reemplaza los filtros de carpeta de la barra por un único `carpeta:<id>` (o
228
- // ninguno si `folderId` es `null`), preservando el resto. Lo usa el consumidor
229
- // al clickear un chip de carpeta, para que la barra sea la única fuente de
230
- // verdad y no queden filtros de carpeta inconsistentes.
231
- const setSingleFolderFilter = React.useCallback((folderId) => {
232
- if (!queryFilterConfig) {
233
- return;
234
- }
235
- const folderQualifierKey = queryFilterConfig.find((config) => config.kind === "folder")?.key;
236
- if (!folderQualifierKey) {
237
- return;
238
- }
239
- const parsedQuery = parseFilterQuery(filterQueryDraft, queryFilterConfig);
240
- // Limpia TODO filtro que apunte a la key de carpeta —tanto `carpeta:<id>`
241
- // (kind "folder") como la presencia `tiene:carpeta`/`no:carpeta`—, para que
242
- // el chip sea la única fuente de verdad y no queden filtros contradictorios
243
- // (p. ej. `no:carpeta` + `carpeta:hogar`, que oculta todas las filas).
244
- const nonFolderFilters = parsedQuery.appliedFilters.filter((appliedFilter) => appliedFilter.value.kind !== "folder" &&
245
- appliedFilter.key !== folderQualifierKey);
246
- const nextAppliedFilters = folderId == null
247
- ? nonFolderFilters
248
- : [
249
- ...nonFolderFilters,
250
- {
251
- key: folderQualifierKey,
252
- negated: false,
253
- value: { folderId, kind: "folder" },
254
- },
255
- ];
256
- handleQueryFilterChange(serializeFilterQuery({ ...parsedQuery, appliedFilters: nextAppliedFilters }, queryFilterConfig));
257
- }, [filterQueryDraft, handleQueryFilterChange, queryFilterConfig]);
258
- React.useEffect(() => {
259
- if (!queryFilterControlsRef) {
260
- return;
261
- }
262
- queryFilterControlsRef.current = {
263
- getQueryText: () => filterQueryDraft,
264
- setQueryText: handleQueryFilterChange,
265
- setSingleFolderFilter,
266
- };
267
- return () => {
268
- queryFilterControlsRef.current = null;
269
- };
270
- }, [
271
- filterQueryDraft,
272
- handleQueryFilterChange,
273
- queryFilterControlsRef,
274
- setSingleFolderFilter,
275
- ]);
276
- const addExcludeFilterValue = React.useCallback((rawExcludeFilterValue, options = {}) => {
277
- const normalizedCandidate = normalizeFilterToken(rawExcludeFilterValue);
278
- if (!normalizedCandidate) {
279
- setExcludeFilterErrorMessage(EMPTY_EXCLUDE_FILTER_ERROR_MESSAGE);
280
- return false;
281
- }
282
- const hasDuplicateExcludeFilter = resolvedExcludeFilterValues.some((excludeFilterValue) => normalizeFilterToken(excludeFilterValue) === normalizedCandidate);
283
- if (hasDuplicateExcludeFilter) {
284
- setExcludeFilterErrorMessage(DUPLICATE_EXCLUDE_FILTER_ERROR_MESSAGE);
285
- return false;
286
- }
287
- handleExcludeFilterValuesChange([
288
- ...resolvedExcludeFilterValues,
289
- rawExcludeFilterValue.trim(),
290
- ]);
291
- if (options.clearExcludeInputValue ?? true) {
292
- setExcludeFilterInputValue("");
293
- }
294
- setExcludeFilterErrorMessage(null);
295
- return true;
296
- }, [handleExcludeFilterValuesChange, resolvedExcludeFilterValues]);
297
- const removeExcludeFilterValue = React.useCallback((excludeFilterValueToRemove) => {
298
- handleExcludeFilterValuesChange(resolvedExcludeFilterValues.filter((excludeFilterValue) => excludeFilterValue !== excludeFilterValueToRemove));
299
- }, [handleExcludeFilterValuesChange, resolvedExcludeFilterValues]);
300
- const handleClearAllExcludeFilters = React.useCallback(() => {
301
- handleExcludeFilterValuesChange([]);
302
- setExcludeFilterInputValue("");
303
- setExcludeFilterErrorMessage(null);
304
- }, [handleExcludeFilterValuesChange]);
305
- const handleSortingChange = React.useCallback((updater) => {
306
- const nextSorting = typeof updater === "function" ? updater(sorting) : updater;
307
- if (controlledSorting == null) {
308
- setUncontrolledSorting(nextSorting);
309
- }
310
- onSortingChange?.(nextSorting);
311
- }, [controlledSorting, onSortingChange, sorting]);
312
- const handleColumnVisibilityChange = React.useCallback((updater) => {
313
- if (controlledColumnVisibility == null) {
314
- setUncontrolledColumnVisibility(updater);
315
- }
316
- onColumnVisibilityChange?.(updater);
317
- }, [controlledColumnVisibility, onColumnVisibilityChange]);
318
- // TanStack Table manages internal reactive state through this hook.
319
- const table = useReactTable({
320
- columns,
321
- data,
322
- // La tabla no pagina (no hay `getPaginationRowModel`). Con el auto-reset por
323
- // defecto, cada cambio de filtro dispara `resetPageIndex` → `setPagination`,
324
- // que vuelve a renderizar y re-dispara el reset en bucle: "Maximum update
325
- // depth exceeded" y la página colgada al tipear. Se desactivan los
326
- // auto-resets porque no hay estado de paginado/expansión que reiniciar.
327
- autoResetPageIndex: false,
328
- autoResetExpanded: false,
329
- getCoreRowModel: getCoreRowModel(),
330
- getFilteredRowModel: getFilteredRowModel(),
331
- getSortedRowModel: getSortedRowModel(),
332
- onColumnFiltersChange: setColumnFilters,
333
- onSortingChange: handleSortingChange,
334
- onColumnVisibilityChange: handleColumnVisibilityChange,
335
- state: {
336
- columnFilters,
337
- columnVisibility,
338
- sorting,
339
- },
340
- });
341
- const hideableColumns = table
342
- .getAllLeafColumns()
343
- .filter((column) => column.getCanHide());
344
- const getHideableColumnDefaultVisibility = React.useCallback((columnId) => hideableColumnsDefaultVisibility?.[columnId] ?? true, [hideableColumnsDefaultVisibility]);
345
- const areHideableColumnsAtDefaultVisibility = hideableColumns.every((column) => column.getIsVisible() === getHideableColumnDefaultVisibility(column.id));
346
- const areSomeHideableColumnsVisible = hideableColumns.some((column) => column.getIsVisible());
347
- const shouldShowColumnVisibilityToggle = showColumnVisibilityToggle && hideableColumns.length > 0;
348
- const hasModifiedColumnVisibility = shouldShowColumnVisibilityToggle && !areHideableColumnsAtDefaultVisibility;
349
- const hasToolbarChanges = hasModifiedColumnVisibility;
350
- const shouldShowToolbarActions = shouldShowColumnVisibilityToggle || toolbarActions != null;
351
- const shouldShowToolbar = shouldShowQueryFilter ||
352
- Boolean(filterColumnId) ||
353
- shouldShowToolbarActions;
354
- const filterColumn = filterColumnId ? table.getColumn(filterColumnId) : undefined;
355
- const activeSortingEntry = sorting.find((sortingEntry) => !sortingBadgeIgnoredColumnIds?.has(sortingEntry.id));
356
- const activeSortingColumn = activeSortingEntry
357
- ? table.getColumn(activeSortingEntry.id)
358
- : undefined;
359
- const activeSortingColumnMeta = activeSortingColumn?.columnDef.meta;
360
- const activeSortingColumnLabel = (activeSortingEntry
361
- ? sortingBadgeLabelOverrides?.[activeSortingEntry.id]
362
- : undefined) ??
363
- activeSortingColumnMeta?.label ??
364
- (typeof activeSortingColumn?.columnDef.header === "string"
365
- ? activeSortingColumn.columnDef.header
366
- : activeSortingEntry?.id);
367
- const shouldShowSortingBadge = activeSortingEntry != null && activeSortingColumnLabel != null;
368
- const activeSortingDirectionSymbol = activeSortingEntry?.desc ? "↓" : "↑";
369
- const activeSortingDirectionLabel = activeSortingEntry?.desc
370
- ? "descendente"
371
- : "ascendente";
372
- const footerGroups = table.getFooterGroups();
373
- const hasFooterContent = footerGroups.some((footerGroup) => footerGroup.headers.some((footer) => !footer.isPlaceholder && footer.column.columnDef.footer != null));
374
- const handleResetSorting = React.useCallback(() => {
375
- handleSortingChange([]);
376
- }, [handleSortingChange]);
377
- const handleClearMainFilter = React.useCallback(() => {
378
- if (!isFilterValueControlled) {
379
- setUncontrolledFilterValue("");
380
- }
381
- onFilterValueChange?.("");
382
- filterColumn?.setFilterValue("");
383
- }, [filterColumn, isFilterValueControlled, onFilterValueChange]);
384
- React.useEffect(() => {
385
- if (!filterColumn) {
386
- return;
387
- }
388
- const currentFilterValue = String(filterColumn.getFilterValue() ?? "");
389
- if (currentFilterValue === tableFilterValue) {
390
- return;
391
- }
392
- filterColumn.setFilterValue(tableFilterValue);
393
- }, [filterColumn, tableFilterValue]);
394
- // Mientras la barra está enfocada, el usuario es la fuente de verdad y no se
395
- // pisa su texto; al perder el foco (o al editar los controles clásicos) la
396
- // barra se re-sincroniza con la forma canónica de los filtros activos.
397
- React.useEffect(() => {
398
- if (!queryFilterConfig || isQueryFilterFocused) {
399
- return;
400
- }
401
- setFilterQueryDraft((previousDraft) => previousDraft === canonicalQueryFromFilters
402
- ? previousDraft
403
- : canonicalQueryFromFilters);
404
- }, [canonicalQueryFromFilters, isQueryFilterFocused, queryFilterConfig]);
405
- React.useEffect(() => {
406
- if (advancedFilterColumnIds.size === 0 &&
407
- Object.keys(advancedFiltersAppliedByColumn).length === 0) {
408
- return;
409
- }
410
- setColumnFilters((previousColumnFilters) => {
411
- const nextColumnFilters = previousColumnFilters.filter((columnFilter) => !advancedFilterColumnIds.has(columnFilter.id));
412
- for (const [columnId, filterValue] of Object.entries(advancedFiltersAppliedByColumn)) {
413
- nextColumnFilters.push({
414
- id: columnId,
415
- value: filterValue,
416
- });
417
- }
418
- if (areColumnFiltersEqual(previousColumnFilters, nextColumnFilters)) {
419
- return previousColumnFilters;
420
- }
421
- return nextColumnFilters;
422
- });
423
- }, [advancedFilterColumnIds, advancedFiltersAppliedByColumn]);
424
- React.useEffect(() => {
425
- onVisibleRowsChange?.(table.getRowModel().rows.map((row) => row.original));
426
- }, [
427
- columnFilters,
428
- columnVisibility,
429
- data,
430
- onVisibleRowsChange,
431
- sorting,
432
- table,
433
- ]);
434
- const sortingBadgeNode = shouldShowSortingBadge ? (_jsxs(Badge, { "aria-live": "polite", className: "inline-flex w-fit items-center gap-1.5", variant: "secondary", children: [_jsx("span", { children: `Ordenado por: ${activeSortingColumnLabel} ${activeSortingDirectionSymbol}` }), _jsx("span", { className: "sr-only", children: `Orden ${activeSortingDirectionLabel}` }), _jsx("button", { "aria-label": "Quitar orden", className: "inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", onClick: handleResetSorting, type: "button", children: _jsx(X, { "aria-hidden": "true", className: "size-3" }) })] })) : null;
435
- const filterExtraContentNode = filterExtraContent ? (_jsx("div", { className: "grid gap-1", children: filterExtraContent })) : null;
436
- const tableRowModelRows = table.getRowModel().rows;
437
- const visibleLeafColumnsCount = table.getVisibleLeafColumns().length;
438
- // El cuerpo de la tabla es la parte más cara de renderizar (cientos de celdas
439
- // con popovers, tooltips y menús). Se memoiza para que los cambios de estado
440
- // locales del toolbar —el draft de la barra de query, el foco, los inputs de
441
- // filtros— no vuelvan a renderizar todas las filas. Solo se recalcula cuando
442
- // cambia el row model (datos/filtros/orden), la visibilidad de columnas o los
443
- // callbacks de fila. Sin esto, cada tecla disparaba varios re-render completos
444
- // del cuerpo y colgaba la página. Se sigue el patrón de memoización de
445
- // TanStack: no se depende de la identidad de `table`, que cambia en cada render.
446
- const tableBodyRows = React.useMemo(() => {
447
- // Con agrupado activo, el conteo por grupo se calcula sobre el row model
448
- // visible (post filtros/orden) para que el encabezado refleje lo que se ve.
449
- const groupRowCounts = new Map();
450
- if (rowGroups) {
451
- for (const row of tableRowModelRows) {
452
- const groupKey = rowGroups.getGroupKey(row.original);
453
- groupRowCounts.set(groupKey, (groupRowCounts.get(groupKey) ?? 0) + 1);
454
- }
455
- }
456
- // Partición por grupo: garantiza un único bloque por clave aunque el row
457
- // model llegue intercalado (orden manual, sorting sin la columna fantasma,
458
- // etc.). El orden interno de cada grupo respeta el row model; el orden de
459
- // los grupos usa getGroupSortValue (del primer row) o la primera aparición.
460
- let rowsToRender = tableRowModelRows;
461
- if (rowGroups) {
462
- const rowsByGroupKey = new Map();
463
- for (const row of tableRowModelRows) {
464
- const groupKey = rowGroups.getGroupKey(row.original);
465
- const groupRows = rowsByGroupKey.get(groupKey);
466
- if (groupRows) {
467
- groupRows.push(row);
468
- }
469
- else {
470
- rowsByGroupKey.set(groupKey, [row]);
471
- }
472
- }
473
- const groupEntries = [...rowsByGroupKey.values()];
474
- const getGroupSortValue = rowGroups.getGroupSortValue;
475
- if (getGroupSortValue) {
476
- groupEntries.sort((leftRows, rightRows) => {
477
- const leftValue = getGroupSortValue(leftRows[0].original);
478
- const rightValue = getGroupSortValue(rightRows[0].original);
479
- if (leftValue === rightValue) {
480
- return 0;
481
- }
482
- return leftValue < rightValue ? -1 : 1;
483
- });
484
- }
485
- rowsToRender = groupEntries.flat();
486
- }
487
- const renderedRows = [];
488
- let previousGroupKey = null;
489
- for (const row of rowsToRender) {
490
- if (rowGroups) {
491
- const groupKey = rowGroups.getGroupKey(row.original);
492
- const isGroupCollapsed = rowGroups.collapsedGroupKeys?.has(groupKey) ?? false;
493
- if (groupKey !== previousGroupKey) {
494
- const groupHeaderContent = rowGroups.renderGroupHeader(groupKey, groupRowCounts.get(groupKey) ?? 0);
495
- const handleGroupToggle = rowGroups.onGroupToggle;
496
- renderedRows.push(_jsx(TableRow, { className: "hover:bg-transparent", "data-slot": "table-group-header-row", children: _jsx(TableCell, { className: "bg-muted/40", colSpan: Math.max(visibleLeafColumnsCount, 1), children: handleGroupToggle ? (_jsxs("button", { "aria-expanded": !isGroupCollapsed, className: "flex w-full items-center gap-1.5 text-left", onClick: () => handleGroupToggle(groupKey), type: "button", children: [isGroupCollapsed ? (_jsx(ChevronRight, { "aria-hidden": "true", className: "size-4 shrink-0" })) : (_jsx(ChevronDown, { "aria-hidden": "true", className: "size-4 shrink-0" })), groupHeaderContent] })) : (groupHeaderContent) }) }, `group-header-${groupKey}-${row.id}`));
497
- previousGroupKey = groupKey;
498
- }
499
- // Grupo colapsado: solo queda el encabezado; sus filas no se renderizan.
500
- if (isGroupCollapsed) {
501
- continue;
502
- }
503
- }
504
- const rowClassName = getRowClassName?.(row.original);
505
- renderedRows.push(_jsx(TableRow, { className: rowClassName, children: row.getVisibleCells().map((cell) => {
506
- const columnMeta = cell.column.columnDef.meta;
507
- const handleCellClick = onCellClick && columnMeta?.isClickable
508
- ? (event) => {
509
- onCellClick(event, row.original, cell.column.id);
510
- }
511
- : undefined;
512
- return (_jsx(TableCell, { className: columnMeta?.cellClassName, "data-label": columnMeta?.label, onClick: handleCellClick, children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
513
- }) }, row.id));
514
- }
515
- return renderedRows;
516
- // `columnVisibility` se incluye a propósito: alterna qué celdas devuelve
517
- // `row.getVisibleCells()` sin cambiar la referencia de `tableRowModelRows`,
518
- // por lo que es necesaria para invalidar la memo al mostrar/ocultar columnas
519
- // aunque el linter no la vea referenciada léxicamente.
520
- // `emptyMessage` se mantiene FUERA de esta memo a propósito: cambia al pasar
521
- // de "sin filtro" a "con filtro" y, si estuviera acá, invalidaría la memo y
522
- // re-renderizaría todas las filas en la primera tecla aunque no cambien.
523
- // eslint-disable-next-line react-hooks/exhaustive-deps
524
- }, [
525
- columnVisibility,
526
- getRowClassName,
527
- onCellClick,
528
- rowGroups,
529
- tableRowModelRows,
530
- visibleLeafColumnsCount,
531
- ]);
532
- const tableBodyContent = tableBodyRows.length ? (tableBodyRows) : (_jsx(TableRow, { children: _jsx(TableCell, { className: "h-24 text-center", colSpan: Math.max(visibleLeafColumnsCount, 1), children: emptyMessage }) }));
533
- return (_jsxs("div", { className: "grid gap-4", children: [shouldShowToolbar ? (_jsxs("div", { className: "grid gap-2", children: [shouldShowQueryFilter ? (_jsx(FilterQueryBar, { ariaLabel: queryFilterLabel, configs: queryFilterConfig, onFocusChange: setIsQueryFilterFocused, onValueChange: handleQueryFilterChange, placeholder: queryFilterPlaceholder, trailingAction: queryFilterTrailingAction, value: filterQueryDraft })) : null, _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [filterColumnId ? (_jsxs("div", { className: "grid w-full max-w-sm gap-2", children: [!shouldShowQueryFilter ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "relative w-full", children: [_jsx(Input, { "aria-label": filterLabel, className: `w-full pr-16 ${hasPendingReverseFilter ? "text-red-400" : ""}`, onChange: (event) => {
534
- const nextFilterValue = event.target.value;
535
- if (!isFilterValueControlled) {
536
- setUncontrolledFilterValue(nextFilterValue);
537
- }
538
- onFilterValueChange?.(nextFilterValue);
539
- filterColumn?.setFilterValue(getTableFilterValue(nextFilterValue, showExcludeFilterToggle));
540
- }, onKeyDown: (event) => {
541
- if (event.key !== "Enter") {
542
- return;
543
- }
544
- const trimmedResolvedFilterValue = resolvedFilterValue.trimStart();
545
- if (!showExcludeFilterToggle ||
546
- !trimmedResolvedFilterValue.startsWith("-")) {
547
- return;
548
- }
549
- event.preventDefault();
550
- const excludeFilterCandidate = trimmedResolvedFilterValue.slice(1);
551
- const wasExcludeFilterAdded = addExcludeFilterValue(excludeFilterCandidate, {
552
- clearExcludeInputValue: false,
553
- });
554
- if (wasExcludeFilterAdded) {
555
- handleClearMainFilter();
556
- }
557
- }, placeholder: filterPlaceholder, type: "text", value: resolvedFilterValue }), _jsxs("div", { className: "absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5", children: [resolvedFilterValue ? (_jsx(Button, { "aria-label": CLEAR_FILTER_ARIA_LABEL, className: "active:-translate-y-0", onClick: handleClearMainFilter, size: "icon-xs", type: "button", variant: "ghost", children: _jsx(X, { "aria-hidden": "true" }) })) : null, showExcludeFilterToggle ? (_jsxs(Button, { "aria-expanded": isExcludeFilterInputVisible, "aria-label": isExcludeFilterInputVisible
558
- ? HIDE_EXCLUDE_FILTERS_ARIA_LABEL
559
- : excludeFilterToggleLabel, className: "relative", onClick: () => {
560
- setIsExcludeFilterInputVisible((previousState) => {
561
- const nextState = !previousState;
562
- if (!nextState) {
563
- setExcludeFilterInputValue("");
564
- setExcludeFilterErrorMessage(null);
565
- }
566
- return nextState;
567
- });
568
- }, size: "icon-xs", type: "button", variant: "ghost", children: [_jsx(Ellipsis, { "aria-hidden": "true" }), hasActiveExcludeFilterValues ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive ring-2 ring-background" }), _jsx("span", { className: "sr-only", children: ACTIVE_EXCLUSIONS_SR_LABEL })] })) : null] })) : null] })] }), showExcludeFilterToggle && isExcludeFilterInputVisible ? (_jsxs("div", { className: "grid gap-1", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { "aria-invalid": excludeFilterErrorMessage != null, "aria-label": excludeFilterLabel, onChange: (event) => {
569
- setExcludeFilterInputValue(event.target.value);
570
- if (excludeFilterErrorMessage != null) {
571
- setExcludeFilterErrorMessage(null);
572
- }
573
- }, onKeyDown: (event) => {
574
- if (event.key !== "Enter") {
575
- return;
576
- }
577
- event.preventDefault();
578
- addExcludeFilterValue(excludeFilterInputValue);
579
- }, placeholder: excludeFilterPlaceholder, type: "text", value: excludeFilterInputValue }), _jsx(Button, { "aria-label": CLEAR_ALL_EXCLUSIONS_FROM_INPUT_ARIA_LABEL, className: "shrink-0", disabled: !hasActiveExcludeFilterValues, onClick: handleClearAllExcludeFilters, size: "icon-xs", type: "button", variant: "ghost", children: _jsx(Eraser, { "aria-hidden": "true" }) })] }), excludeFilterErrorMessage ? (_jsx("p", { "aria-live": "polite", className: "text-sm text-destructive", children: excludeFilterErrorMessage })) : null] })) : null, hasPendingReverseFilter ? (_jsxs("div", { "aria-live": "polite", className: "rounded-md border border-border/80 bg-muted/40 px-3 py-2 text-muted-foreground", role: "status", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-foreground/80", children: [_jsx(CircleAlert, { "aria-hidden": "true", className: "size-4" }), _jsx("span", { children: "Exclusi\u00F3n pendiente" })] }), _jsx("p", { className: "mt-1 text-xs", children: REVERSE_FILTER_PENDING_MESSAGE })] })) : null] })) : null, showExcludeFilterToggle && resolvedExcludeFilterValues.length > 0 ? (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [excludeFilterUniqueRowsCount != null ? (_jsxs(Badge, { "aria-label": `Total de filas excluidas únicas: ${excludeFilterUniqueRowsCount}`, className: "inline-flex h-6 items-center gap-1.5 px-2.5", variant: "destructive", children: [_jsx("span", { children: `${EXCLUDED_ROWS_SUMMARY_LABEL}:` }), _jsx("span", { className: "inline-flex min-w-5 items-center justify-center rounded-full bg-destructive/20 px-1.5 text-[10px] leading-4 font-semibold", children: excludeFilterUniqueRowsCount }), _jsx("button", { "aria-label": CLEAR_ALL_EXCLUSIONS_ARIA_LABEL, className: "inline-flex size-4 items-center justify-center rounded-sm text-destructive transition-colors hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40", onClick: handleClearAllExcludeFilters, type: "button", children: _jsx(X, { "aria-hidden": "true", className: "size-3" }) })] })) : null, resolvedExcludeFilterValues.map((excludeFilterValue) => {
580
- const excludedRowsCount = excludeFilterRowsCountByValue?.[excludeFilterValue];
581
- return (_jsxs(Badge, { className: "inline-flex h-6 items-center gap-1.5 px-2.5", variant: "destructive", children: [_jsx("span", { children: `− ${excludeFilterValue}` }), excludedRowsCount != null ? (_jsx("span", { "aria-label": `Filas excluidas por ${excludeFilterValue}: ${excludedRowsCount}`, className: "inline-flex min-w-5 items-center justify-center rounded-full bg-destructive/20 px-1.5 text-[10px] leading-4 font-semibold", children: excludedRowsCount })) : null, _jsx("button", { "aria-label": `Quitar exclusión ${excludeFilterValue}`, className: "inline-flex size-4 items-center justify-center rounded-sm text-destructive transition-colors hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40", onClick: () => removeExcludeFilterValue(excludeFilterValue), type: "button", children: _jsx(X, { "aria-hidden": "true", className: "size-3" }) })] }, excludeFilterValue));
582
- })] })) : null, sortingBadgeNode, filterExtraContentNode] })) : null, shouldShowToolbarActions ? (_jsxs("div", { className: "ml-auto flex flex-wrap items-center gap-2", children: [toolbarActions, shouldShowColumnVisibilityToggle ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { "aria-label": columnVisibilityButtonLabel, className: "relative", type: "button", variant: "outline", children: [columnVisibilityButtonLabel, _jsx(ChevronDown, { "aria-hidden": "true" }), hasToolbarChanges ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive ring-2 ring-background" }), _jsx("span", { className: "sr-only", children: "Columnas modificadas" })] })) : null] }) }), _jsxs(DropdownMenuContent, { align: "end", children: [columnVisibilityMenuExtraContent ? (_jsxs(_Fragment, { children: [columnVisibilityMenuExtraContent, _jsx(DropdownMenuSeparator, {})] })) : null, _jsx(DropdownMenuLabel, { children: columnVisibilityMenuLabel }), _jsx(DropdownMenuItem, { disabled: areHideableColumnsAtDefaultVisibility, onSelect: (event) => {
583
- event.preventDefault();
584
- hideableColumns.forEach((column) => {
585
- column.toggleVisibility(getHideableColumnDefaultVisibility(column.id));
586
- });
587
- }, children: selectAllColumnsLabel }), _jsx(DropdownMenuItem, { disabled: !areSomeHideableColumnsVisible, onSelect: (event) => {
588
- event.preventDefault();
589
- hideableColumns.forEach((column) => {
590
- column.toggleVisibility(false);
591
- });
592
- }, children: deselectAllColumnsLabel }), _jsx(DropdownMenuSeparator, {}), hideableColumns.map((column) => {
593
- const columnMeta = column.columnDef.meta;
594
- const isColumnVisible = column.getIsVisible();
595
- const label = columnMeta?.label ??
596
- (typeof column.columnDef.header === "string"
597
- ? column.columnDef.header
598
- : column.id);
599
- return (_jsxs(DropdownMenuCheckboxItem, { checked: isColumnVisible, onSelect: (event) => {
600
- event.preventDefault();
601
- }, onCheckedChange: (nextVisible) => {
602
- column.toggleVisibility(Boolean(nextVisible));
603
- }, children: [label, !isColumnVisible ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "absolute right-2 top-1.5 size-2 rounded-full bg-destructive ring-2 ring-background" }), _jsx("span", { className: "sr-only", children: "Columna deseleccionada" })] })) : null] }, column.id));
604
- })] })] })) : null] })) : null] })] })) : null, _jsx("div", { className: "relative w-full overflow-x-auto", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { children: headerGroup.headers.map((header) => {
605
- const columnMeta = header.column.columnDef.meta;
606
- return (_jsx(TableHead, { className: columnMeta?.headerClassName, onClick: columnMeta?.onHeaderClick, children: header.isPlaceholder
607
- ? null
608
- : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
609
- }) }, headerGroup.id))) }), _jsx(TableBody, { children: tableBodyContent }), hasFooterContent ? (_jsx(TableFooter, { children: footerGroups.map((footerGroup) => (_jsx(TableRow, { className: "hover:bg-transparent", children: footerGroup.headers.map((footer) => (_jsx(TableCell, { "data-label": footer.column.columnDef.meta?.label, children: footer.isPlaceholder
610
- ? null
611
- : flexRender(footer.column.columnDef.footer, footer.getContext()) }, footer.id))) }, footerGroup.id))) })) : null] }) })] }));
74
+ export function DataTable({ columns, data, emptyMessage, toolbarActions, onVisibleRowsChange, onCellClick, getRowClassName, sorting: controlledSorting, onSortingChange, columnVisibility: controlledColumnVisibility, onColumnVisibilityChange, filterColumnId, filterLabel = "Filtrar", filterPlaceholder = "Filtrar...", filterExtraContent, filterValue: controlledFilterValue, onFilterValueChange, queryFilterConfig, queryFilterPlaceholder, queryFilterLabel, onAppliedFiltersChange, queryFilterControlsRef, queryFilterTrailingAction, rowGroups, showExcludeFilterToggle = false, excludeFilterValues: controlledExcludeFilterValues, onExcludeFilterValuesChange, excludeFilterPlaceholder = "Excluir por descripción", excludeFilterLabel = "Filtro de exclusión", excludeFilterToggleLabel = "Mostrar filtros de exclusión", excludeFilterRowsCountByValue, excludeFilterUniqueRowsCount, showColumnVisibilityToggle = false, columnVisibilityButtonLabel = "Columnas", columnVisibilityMenuLabel = "Mostrar columnas", columnVisibilityMenuExtraContent, sortingBadgeLabelOverrides, sortingBadgeIgnoredColumnIds, selectAllColumnsLabel = "Mostrar todas", deselectAllColumnsLabel = "Ocultar todas", hideableColumnsDefaultVisibility }) {
75
+ const [uncontrolledSorting, setUncontrolledSorting] = React.useState([]);
76
+ const [columnFilters, setColumnFilters] = React.useState([]);
77
+ const [uncontrolledFilterValue, setUncontrolledFilterValue] = React.useState("");
78
+ const [filterQueryDraft, setFilterQueryDraft] = React.useState("");
79
+ // Filtros estructurados de la última query parseada. Se preservan para que la
80
+ // re-sincronización canónica (al perder foco) NO descarte los qualifiers sin
81
+ // columna (textMatch/folder) ni los negados, que no viven en
82
+ // `advancedFiltersAppliedByColumn` y quedarían como filtros activos invisibles.
83
+ const [appliedFiltersDraft, setAppliedFiltersDraft] = React.useState([]);
84
+ const [isQueryFilterFocused, setIsQueryFilterFocused] = React.useState(false);
85
+ const [uncontrolledExcludeFilterValues, setUncontrolledExcludeFilterValues] = React.useState([]);
86
+ const [excludeFilterInputValue, setExcludeFilterInputValue] = React.useState("");
87
+ const [excludeFilterErrorMessage, setExcludeFilterErrorMessage] = React.useState(null);
88
+ const [isExcludeFilterInputVisible, setIsExcludeFilterInputVisible] = React.useState(false);
89
+ const [uncontrolledColumnVisibility, setUncontrolledColumnVisibility] = React.useState({});
90
+ const [advancedFiltersAppliedByColumn, setAdvancedFiltersAppliedByColumn] = React.useState({});
91
+ const sorting = controlledSorting ?? uncontrolledSorting;
92
+ const columnVisibility = controlledColumnVisibility ?? uncontrolledColumnVisibility;
93
+ const shouldShowQueryFilter = queryFilterConfig != null;
94
+ const isFilterValueControlled = controlledFilterValue != null;
95
+ const isExcludeFilterValuesControlled = controlledExcludeFilterValues != null;
96
+ const resolvedFilterValue = isFilterValueControlled ? controlledFilterValue : uncontrolledFilterValue;
97
+ const resolvedExcludeFilterValues = isExcludeFilterValuesControlled ? controlledExcludeFilterValues : uncontrolledExcludeFilterValues;
98
+ const hasActiveExcludeFilterValues = resolvedExcludeFilterValues.length > 0;
99
+ const tableFilterValue = getTableFilterValue(resolvedFilterValue, showExcludeFilterToggle);
100
+ const hasPendingReverseFilter = showExcludeFilterToggle && resolvedFilterValue.trimStart().startsWith("-");
101
+ const advancedFilterColumnIds = React.useMemo(() => {
102
+ const columnIds = new Set();
103
+ for (const queryQualifier of queryFilterConfig ?? []) {
104
+ if (queryQualifier.columnId) {
105
+ columnIds.add(queryQualifier.columnId);
106
+ }
107
+ }
108
+ return columnIds;
109
+ }, [queryFilterConfig]);
110
+ const handleExcludeFilterValuesChange = React.useCallback((nextExcludeFilterValues) => {
111
+ if (!isExcludeFilterValuesControlled) {
112
+ setUncontrolledExcludeFilterValues(nextExcludeFilterValues);
113
+ }
114
+ onExcludeFilterValuesChange?.(nextExcludeFilterValues);
115
+ }, [isExcludeFilterValuesControlled, onExcludeFilterValuesChange]);
116
+ // La barra de query es la fuente de verdad mientras se tipea: parsea el texto
117
+ // y escribe en los mismos canales que usan los controles clásicos (descripción,
118
+ // exclusiones y avanzados), de modo que ambas vistas queden sincronizadas.
119
+ const handleQueryFilterChange = React.useCallback((nextQuery) => {
120
+ // Update urgente: mantiene el input y el dropdown de sugerencias
121
+ // respondiendo de inmediato a cada tecla.
122
+ setFilterQueryDraft(nextQuery);
123
+ if (!queryFilterConfig) {
124
+ return;
125
+ }
126
+ const parsedQuery = parseFilterQuery(nextQuery, queryFilterConfig);
127
+ // El re-filtrado vuelve a renderizar toda la tabla (celdas con popovers,
128
+ // tooltips y menús por fila), lo que bloquearía el commit del dropdown si
129
+ // corriera urgente. Se difiere como transición de baja prioridad para que
130
+ // el tipeo y las sugerencias no se cuelguen.
131
+ React.startTransition(() => {
132
+ // Cada canal se actualiza solo si cambió. Propagar valores iguales
133
+ // (p. ej. una lista de exclusiones vacía nueva en cada tecla) dispararía
134
+ // re-cálculos de memos y un re-render extra de todas las filas sin
135
+ // ningún cambio observable.
136
+ if (parsedQuery.descriptionFilter !== resolvedFilterValue) {
137
+ if (!isFilterValueControlled) {
138
+ setUncontrolledFilterValue(parsedQuery.descriptionFilter);
139
+ }
140
+ onFilterValueChange?.(parsedQuery.descriptionFilter);
141
+ }
142
+ if (!areStringArraysEqual(parsedQuery.excludedDescriptionFilters, resolvedExcludeFilterValues)) {
143
+ handleExcludeFilterValuesChange(parsedQuery.excludedDescriptionFilters);
144
+ }
145
+ if (!areAdvancedFilterMapsEqual(parsedQuery.advancedFiltersByColumn, advancedFiltersAppliedByColumn)) {
146
+ setAdvancedFiltersAppliedByColumn(parsedQuery.advancedFiltersByColumn);
147
+ }
148
+ setAppliedFiltersDraft(parsedQuery.appliedFilters);
149
+ onAppliedFiltersChange?.(parsedQuery.appliedFilters);
150
+ });
151
+ }, [
152
+ advancedFiltersAppliedByColumn,
153
+ handleExcludeFilterValuesChange,
154
+ isFilterValueControlled,
155
+ onAppliedFiltersChange,
156
+ onFilterValueChange,
157
+ queryFilterConfig,
158
+ resolvedExcludeFilterValues,
159
+ resolvedFilterValue
160
+ ]);
161
+ // Forma canónica de los filtros activos, para reflejarlos en la barra de query.
162
+ // Se serializa desde los filtros aplicados id-backed (no desde el texto de la
163
+ // barra), de modo que un filtro de carpeta apunte siempre por `folderId`: si la
164
+ // carpeta se renombra, su slug se re-deriva del nombre nuevo; si desaparece, el
165
+ // filtro se descarta. Definida antes del efecto de reparse para que este pueda
166
+ // re-propagar la forma id-backed cuando cambia la config.
167
+ const canonicalQueryFromFilters = React.useMemo(() => {
168
+ if (!queryFilterConfig) {
169
+ return "";
170
+ }
171
+ return serializeFilterQuery({
172
+ advancedFiltersByColumn: advancedFiltersAppliedByColumn,
173
+ appliedFilters: appliedFiltersDraft,
174
+ descriptionFilter: tableFilterValue,
175
+ excludedDescriptionFilters: resolvedExcludeFilterValues,
176
+ invalidTokens: []
177
+ }, queryFilterConfig);
178
+ }, [
179
+ advancedFiltersAppliedByColumn,
180
+ appliedFiltersDraft,
181
+ queryFilterConfig,
182
+ resolvedExcludeFilterValues,
183
+ tableFilterValue
184
+ ]);
185
+ // Firma estructural de la config de qualifiers: dispara la reconciliación solo
186
+ // cuando su CONTENIDO cambia (no en cada render con una referencia de array
187
+ // nueva), evitando re-propagar la forma canónica mientras el usuario tipea.
188
+ const queryFilterConfigSignature = React.useMemo(() => queryFilterConfig ? JSON.stringify(queryFilterConfig) : "", [queryFilterConfig]);
189
+ const previousQueryFilterConfigSignatureRef = React.useRef(null);
190
+ // Si la config de qualifiers cambia (p. ej. se renombra una carpeta y su opción
191
+ // pasa de `carpeta:hogar` a `carpeta:casa`, o se borra y desaparece), se
192
+ // re-propaga la forma canónica id-backed contra la NUEVA config en vez de
193
+ // re-parsear el texto con el slug viejo. Así un filtro de carpeta seleccionado
194
+ // sobrevive a renames y colisiones de slug (se vuelve a serializar por
195
+ // `folderId`), mientras que un filtro realmente huérfano (cuyo `folderId` ya no
196
+ // existe) se descarta del predicado en vez de seguir filtrando de forma
197
+ // invisible.
198
+ React.useEffect(() => {
199
+ const previousSignature = previousQueryFilterConfigSignatureRef.current;
200
+ previousQueryFilterConfigSignatureRef.current = queryFilterConfigSignature;
201
+ // Primer render o config sin contenido: nada que reconciliar todavía.
202
+ if (previousSignature === null) {
203
+ return;
204
+ }
205
+ if (!queryFilterConfig || !filterQueryDraft || previousSignature === queryFilterConfigSignature) {
206
+ return;
207
+ }
208
+ handleQueryFilterChange(canonicalQueryFromFilters);
209
+ // Solo debe re-correr cuando cambia el contenido de la config; el resto de los
210
+ // valores se leen como estado actual y no como disparadores para no re-parsear
211
+ // en cada tecla.
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ }, [queryFilterConfigSignature]);
214
+ // Reemplaza los filtros de carpeta de la barra por un único `carpeta:<id>` (o
215
+ // ninguno si `folderId` es `null`), preservando el resto. Lo usa el consumidor
216
+ // al clickear un chip de carpeta, para que la barra sea la única fuente de
217
+ // verdad y no queden filtros de carpeta inconsistentes.
218
+ const setSingleFolderFilter = React.useCallback((folderId) => {
219
+ if (!queryFilterConfig) {
220
+ return;
221
+ }
222
+ const folderQualifierKey = queryFilterConfig.find((config) => config.kind === "folder")?.key;
223
+ if (!folderQualifierKey) {
224
+ return;
225
+ }
226
+ const parsedQuery = parseFilterQuery(filterQueryDraft, queryFilterConfig);
227
+ // Limpia TODO filtro que apunte a la key de carpeta —tanto `carpeta:<id>`
228
+ // (kind "folder") como la presencia `tiene:carpeta`/`no:carpeta`—, para que
229
+ // el chip sea la única fuente de verdad y no queden filtros contradictorios
230
+ // (p. ej. `no:carpeta` + `carpeta:hogar`, que oculta todas las filas).
231
+ const nonFolderFilters = parsedQuery.appliedFilters.filter((appliedFilter) => appliedFilter.value.kind !== "folder" && appliedFilter.key !== folderQualifierKey);
232
+ const nextAppliedFilters = folderId == null ? nonFolderFilters : [...nonFolderFilters, {
233
+ key: folderQualifierKey,
234
+ negated: false,
235
+ value: {
236
+ folderId,
237
+ kind: "folder"
238
+ }
239
+ }];
240
+ handleQueryFilterChange(serializeFilterQuery({
241
+ ...parsedQuery,
242
+ appliedFilters: nextAppliedFilters
243
+ }, queryFilterConfig));
244
+ }, [
245
+ filterQueryDraft,
246
+ handleQueryFilterChange,
247
+ queryFilterConfig
248
+ ]);
249
+ React.useEffect(() => {
250
+ if (!queryFilterControlsRef) {
251
+ return;
252
+ }
253
+ queryFilterControlsRef.current = {
254
+ getQueryText: () => filterQueryDraft,
255
+ setQueryText: handleQueryFilterChange,
256
+ setSingleFolderFilter
257
+ };
258
+ return () => {
259
+ queryFilterControlsRef.current = null;
260
+ };
261
+ }, [
262
+ filterQueryDraft,
263
+ handleQueryFilterChange,
264
+ queryFilterControlsRef,
265
+ setSingleFolderFilter
266
+ ]);
267
+ const addExcludeFilterValue = React.useCallback((rawExcludeFilterValue, options = {}) => {
268
+ const normalizedCandidate = normalizeFilterToken(rawExcludeFilterValue);
269
+ if (!normalizedCandidate) {
270
+ setExcludeFilterErrorMessage(EMPTY_EXCLUDE_FILTER_ERROR_MESSAGE);
271
+ return false;
272
+ }
273
+ const hasDuplicateExcludeFilter = resolvedExcludeFilterValues.some((excludeFilterValue) => normalizeFilterToken(excludeFilterValue) === normalizedCandidate);
274
+ if (hasDuplicateExcludeFilter) {
275
+ setExcludeFilterErrorMessage(DUPLICATE_EXCLUDE_FILTER_ERROR_MESSAGE);
276
+ return false;
277
+ }
278
+ handleExcludeFilterValuesChange([...resolvedExcludeFilterValues, rawExcludeFilterValue.trim()]);
279
+ if (options.clearExcludeInputValue ?? true) {
280
+ setExcludeFilterInputValue("");
281
+ }
282
+ setExcludeFilterErrorMessage(null);
283
+ return true;
284
+ }, [handleExcludeFilterValuesChange, resolvedExcludeFilterValues]);
285
+ const removeExcludeFilterValue = React.useCallback((excludeFilterValueToRemove) => {
286
+ handleExcludeFilterValuesChange(resolvedExcludeFilterValues.filter((excludeFilterValue) => excludeFilterValue !== excludeFilterValueToRemove));
287
+ }, [handleExcludeFilterValuesChange, resolvedExcludeFilterValues]);
288
+ const handleClearAllExcludeFilters = React.useCallback(() => {
289
+ handleExcludeFilterValuesChange([]);
290
+ setExcludeFilterInputValue("");
291
+ setExcludeFilterErrorMessage(null);
292
+ }, [handleExcludeFilterValuesChange]);
293
+ const handleSortingChange = React.useCallback((updater) => {
294
+ const nextSorting = typeof updater === "function" ? updater(sorting) : updater;
295
+ if (controlledSorting == null) {
296
+ setUncontrolledSorting(nextSorting);
297
+ }
298
+ onSortingChange?.(nextSorting);
299
+ }, [
300
+ controlledSorting,
301
+ onSortingChange,
302
+ sorting
303
+ ]);
304
+ const handleColumnVisibilityChange = React.useCallback((updater) => {
305
+ if (controlledColumnVisibility == null) {
306
+ setUncontrolledColumnVisibility(updater);
307
+ }
308
+ onColumnVisibilityChange?.(updater);
309
+ }, [controlledColumnVisibility, onColumnVisibilityChange]);
310
+ // TanStack Table manages internal reactive state through this hook.
311
+ const table = useReactTable({
312
+ columns,
313
+ data,
314
+ // La tabla no pagina (no hay `getPaginationRowModel`). Con el auto-reset por
315
+ // defecto, cada cambio de filtro dispara `resetPageIndex` → `setPagination`,
316
+ // que vuelve a renderizar y re-dispara el reset en bucle: "Maximum update
317
+ // depth exceeded" y la página colgada al tipear. Se desactivan los
318
+ // auto-resets porque no hay estado de paginado/expansión que reiniciar.
319
+ autoResetPageIndex: false,
320
+ autoResetExpanded: false,
321
+ getCoreRowModel: getCoreRowModel(),
322
+ getFilteredRowModel: getFilteredRowModel(),
323
+ getSortedRowModel: getSortedRowModel(),
324
+ onColumnFiltersChange: setColumnFilters,
325
+ onSortingChange: handleSortingChange,
326
+ onColumnVisibilityChange: handleColumnVisibilityChange,
327
+ state: {
328
+ columnFilters,
329
+ columnVisibility,
330
+ sorting
331
+ }
332
+ });
333
+ const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide());
334
+ const getHideableColumnDefaultVisibility = React.useCallback((columnId) => hideableColumnsDefaultVisibility?.[columnId] ?? true, [hideableColumnsDefaultVisibility]);
335
+ const areHideableColumnsAtDefaultVisibility = hideableColumns.every((column) => column.getIsVisible() === getHideableColumnDefaultVisibility(column.id));
336
+ const areSomeHideableColumnsVisible = hideableColumns.some((column) => column.getIsVisible());
337
+ const shouldShowColumnVisibilityToggle = showColumnVisibilityToggle && hideableColumns.length > 0;
338
+ const hasModifiedColumnVisibility = shouldShowColumnVisibilityToggle && !areHideableColumnsAtDefaultVisibility;
339
+ const hasToolbarChanges = hasModifiedColumnVisibility;
340
+ const shouldShowToolbarActions = shouldShowColumnVisibilityToggle || toolbarActions != null;
341
+ const shouldShowToolbar = shouldShowQueryFilter || Boolean(filterColumnId) || shouldShowToolbarActions;
342
+ const filterColumn = filterColumnId ? table.getColumn(filterColumnId) : undefined;
343
+ const activeSortingEntry = sorting.find((sortingEntry) => !sortingBadgeIgnoredColumnIds?.has(sortingEntry.id));
344
+ const activeSortingColumn = activeSortingEntry ? table.getColumn(activeSortingEntry.id) : undefined;
345
+ const activeSortingColumnMeta = activeSortingColumn?.columnDef.meta;
346
+ const activeSortingColumnLabel = (activeSortingEntry ? sortingBadgeLabelOverrides?.[activeSortingEntry.id] : undefined) ?? activeSortingColumnMeta?.label ?? (typeof activeSortingColumn?.columnDef.header === "string" ? activeSortingColumn.columnDef.header : activeSortingEntry?.id);
347
+ const shouldShowSortingBadge = activeSortingEntry != null && activeSortingColumnLabel != null;
348
+ const activeSortingDirectionSymbol = activeSortingEntry?.desc ? "↓" : "↑";
349
+ const activeSortingDirectionLabel = activeSortingEntry?.desc ? "descendente" : "ascendente";
350
+ const footerGroups = table.getFooterGroups();
351
+ const hasFooterContent = footerGroups.some((footerGroup) => footerGroup.headers.some((footer) => !footer.isPlaceholder && footer.column.columnDef.footer != null));
352
+ const handleResetSorting = React.useCallback(() => {
353
+ handleSortingChange([]);
354
+ }, [handleSortingChange]);
355
+ const handleClearMainFilter = React.useCallback(() => {
356
+ if (!isFilterValueControlled) {
357
+ setUncontrolledFilterValue("");
358
+ }
359
+ onFilterValueChange?.("");
360
+ filterColumn?.setFilterValue("");
361
+ }, [
362
+ filterColumn,
363
+ isFilterValueControlled,
364
+ onFilterValueChange
365
+ ]);
366
+ React.useEffect(() => {
367
+ if (!filterColumn) {
368
+ return;
369
+ }
370
+ const currentFilterValue = String(filterColumn.getFilterValue() ?? "");
371
+ if (currentFilterValue === tableFilterValue) {
372
+ return;
373
+ }
374
+ filterColumn.setFilterValue(tableFilterValue);
375
+ }, [filterColumn, tableFilterValue]);
376
+ // Mientras la barra está enfocada, el usuario es la fuente de verdad y no se
377
+ // pisa su texto; al perder el foco (o al editar los controles clásicos) la
378
+ // barra se re-sincroniza con la forma canónica de los filtros activos.
379
+ React.useEffect(() => {
380
+ if (!queryFilterConfig || isQueryFilterFocused) {
381
+ return;
382
+ }
383
+ setFilterQueryDraft((previousDraft) => previousDraft === canonicalQueryFromFilters ? previousDraft : canonicalQueryFromFilters);
384
+ }, [
385
+ canonicalQueryFromFilters,
386
+ isQueryFilterFocused,
387
+ queryFilterConfig
388
+ ]);
389
+ React.useEffect(() => {
390
+ if (advancedFilterColumnIds.size === 0 && Object.keys(advancedFiltersAppliedByColumn).length === 0) {
391
+ return;
392
+ }
393
+ setColumnFilters((previousColumnFilters) => {
394
+ const nextColumnFilters = previousColumnFilters.filter((columnFilter) => !advancedFilterColumnIds.has(columnFilter.id));
395
+ for (const [columnId, filterValue] of Object.entries(advancedFiltersAppliedByColumn)) {
396
+ nextColumnFilters.push({
397
+ id: columnId,
398
+ value: filterValue
399
+ });
400
+ }
401
+ if (areColumnFiltersEqual(previousColumnFilters, nextColumnFilters)) {
402
+ return previousColumnFilters;
403
+ }
404
+ return nextColumnFilters;
405
+ });
406
+ }, [advancedFilterColumnIds, advancedFiltersAppliedByColumn]);
407
+ React.useEffect(() => {
408
+ onVisibleRowsChange?.(table.getRowModel().rows.map((row) => row.original));
409
+ }, [
410
+ columnFilters,
411
+ columnVisibility,
412
+ data,
413
+ onVisibleRowsChange,
414
+ sorting,
415
+ table
416
+ ]);
417
+ const sortingBadgeNode = shouldShowSortingBadge ? /* @__PURE__ */ _jsxs(Badge, {
418
+ "aria-live": "polite",
419
+ className: "inline-flex w-fit items-center gap-1.5",
420
+ variant: "secondary",
421
+ children: [
422
+ /* @__PURE__ */ _jsx("span", { children: `Ordenado por: ${activeSortingColumnLabel} ${activeSortingDirectionSymbol}` }),
423
+ /* @__PURE__ */ _jsx("span", {
424
+ className: "sr-only",
425
+ children: `Orden ${activeSortingDirectionLabel}`
426
+ }),
427
+ /* @__PURE__ */ _jsx("button", {
428
+ "aria-label": "Quitar orden",
429
+ className: "inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
430
+ onClick: handleResetSorting,
431
+ type: "button",
432
+ children: /* @__PURE__ */ _jsx(X, {
433
+ "aria-hidden": "true",
434
+ className: "size-3"
435
+ })
436
+ })
437
+ ]
438
+ }) : null;
439
+ const filterExtraContentNode = filterExtraContent ? /* @__PURE__ */ _jsx("div", {
440
+ className: "grid gap-1",
441
+ children: filterExtraContent
442
+ }) : null;
443
+ const tableRowModelRows = table.getRowModel().rows;
444
+ const visibleLeafColumnsCount = table.getVisibleLeafColumns().length;
445
+ // El cuerpo de la tabla es la parte más cara de renderizar (cientos de celdas
446
+ // con popovers, tooltips y menús). Se memoiza para que los cambios de estado
447
+ // locales del toolbar —el draft de la barra de query, el foco, los inputs de
448
+ // filtros— no vuelvan a renderizar todas las filas. Solo se recalcula cuando
449
+ // cambia el row model (datos/filtros/orden), la visibilidad de columnas o los
450
+ // callbacks de fila. Sin esto, cada tecla disparaba varios re-render completos
451
+ // del cuerpo y colgaba la página. Se sigue el patrón de memoización de
452
+ // TanStack: no se depende de la identidad de `table`, que cambia en cada render.
453
+ const tableBodyRows = React.useMemo(() => {
454
+ // Con agrupado activo, el conteo por grupo se calcula sobre el row model
455
+ // visible (post filtros/orden) para que el encabezado refleje lo que se ve.
456
+ const groupRowCounts = new Map();
457
+ if (rowGroups) {
458
+ for (const row of tableRowModelRows) {
459
+ const groupKey = rowGroups.getGroupKey(row.original);
460
+ groupRowCounts.set(groupKey, (groupRowCounts.get(groupKey) ?? 0) + 1);
461
+ }
462
+ }
463
+ // Partición por grupo: garantiza un único bloque por clave aunque el row
464
+ // model llegue intercalado (orden manual, sorting sin la columna fantasma,
465
+ // etc.). El orden interno de cada grupo respeta el row model; el orden de
466
+ // los grupos usa getGroupSortValue (del primer row) o la primera aparición.
467
+ let rowsToRender = tableRowModelRows;
468
+ if (rowGroups) {
469
+ const rowsByGroupKey = new Map();
470
+ for (const row of tableRowModelRows) {
471
+ const groupKey = rowGroups.getGroupKey(row.original);
472
+ const groupRows = rowsByGroupKey.get(groupKey);
473
+ if (groupRows) {
474
+ groupRows.push(row);
475
+ } else {
476
+ rowsByGroupKey.set(groupKey, [row]);
477
+ }
478
+ }
479
+ const groupEntries = [...rowsByGroupKey.values()];
480
+ const getGroupSortValue = rowGroups.getGroupSortValue;
481
+ if (getGroupSortValue) {
482
+ groupEntries.sort((leftRows, rightRows) => {
483
+ const leftValue = getGroupSortValue(leftRows[0].original);
484
+ const rightValue = getGroupSortValue(rightRows[0].original);
485
+ if (leftValue === rightValue) {
486
+ return 0;
487
+ }
488
+ return leftValue < rightValue ? -1 : 1;
489
+ });
490
+ }
491
+ rowsToRender = groupEntries.flat();
492
+ }
493
+ const renderedRows = [];
494
+ let previousGroupKey = null;
495
+ for (const row of rowsToRender) {
496
+ if (rowGroups) {
497
+ const groupKey = rowGroups.getGroupKey(row.original);
498
+ const isGroupCollapsed = rowGroups.collapsedGroupKeys?.has(groupKey) ?? false;
499
+ if (groupKey !== previousGroupKey) {
500
+ const groupHeaderContent = rowGroups.renderGroupHeader(groupKey, groupRowCounts.get(groupKey) ?? 0);
501
+ const handleGroupToggle = rowGroups.onGroupToggle;
502
+ renderedRows.push(/* @__PURE__ */ _jsx(TableRow, {
503
+ className: "hover:bg-transparent",
504
+ "data-slot": "table-group-header-row",
505
+ children: /* @__PURE__ */ _jsx(TableCell, {
506
+ className: "bg-muted/40",
507
+ colSpan: Math.max(visibleLeafColumnsCount, 1),
508
+ children: handleGroupToggle ? /* @__PURE__ */ _jsxs("button", {
509
+ "aria-expanded": !isGroupCollapsed,
510
+ className: "flex w-full items-center gap-1.5 text-left",
511
+ onClick: () => handleGroupToggle(groupKey),
512
+ type: "button",
513
+ children: [isGroupCollapsed ? /* @__PURE__ */ _jsx(ChevronRight, {
514
+ "aria-hidden": "true",
515
+ className: "size-4 shrink-0"
516
+ }) : /* @__PURE__ */ _jsx(ChevronDown, {
517
+ "aria-hidden": "true",
518
+ className: "size-4 shrink-0"
519
+ }), groupHeaderContent]
520
+ }) : groupHeaderContent
521
+ })
522
+ }, `group-header-${groupKey}-${row.id}`));
523
+ previousGroupKey = groupKey;
524
+ }
525
+ // Grupo colapsado: solo queda el encabezado; sus filas no se renderizan.
526
+ if (isGroupCollapsed) {
527
+ continue;
528
+ }
529
+ }
530
+ const rowClassName = getRowClassName?.(row.original);
531
+ renderedRows.push(/* @__PURE__ */ _jsx(TableRow, {
532
+ className: rowClassName,
533
+ children: row.getVisibleCells().map((cell) => {
534
+ const columnMeta = cell.column.columnDef.meta;
535
+ const handleCellClick = onCellClick && columnMeta?.isClickable ? (event) => {
536
+ onCellClick(event, row.original, cell.column.id);
537
+ } : undefined;
538
+ return /* @__PURE__ */ _jsx(TableCell, {
539
+ className: columnMeta?.cellClassName,
540
+ "data-label": columnMeta?.label,
541
+ onClick: handleCellClick,
542
+ children: flexRender(cell.column.columnDef.cell, cell.getContext())
543
+ }, cell.id);
544
+ })
545
+ }, row.id));
546
+ }
547
+ return renderedRows;
548
+ // `columnVisibility` se incluye a propósito: alterna qué celdas devuelve
549
+ // `row.getVisibleCells()` sin cambiar la referencia de `tableRowModelRows`,
550
+ // por lo que es necesaria para invalidar la memo al mostrar/ocultar columnas
551
+ // aunque el linter no la vea referenciada léxicamente.
552
+ // `emptyMessage` se mantiene FUERA de esta memo a propósito: cambia al pasar
553
+ // de "sin filtro" a "con filtro" y, si estuviera acá, invalidaría la memo y
554
+ // re-renderizaría todas las filas en la primera tecla aunque no cambien.
555
+ // eslint-disable-next-line react-hooks/exhaustive-deps
556
+ }, [
557
+ columnVisibility,
558
+ getRowClassName,
559
+ onCellClick,
560
+ rowGroups,
561
+ tableRowModelRows,
562
+ visibleLeafColumnsCount
563
+ ]);
564
+ const tableBodyContent = tableBodyRows.length ? tableBodyRows : /* @__PURE__ */ _jsx(TableRow, { children: /* @__PURE__ */ _jsx(TableCell, {
565
+ className: "h-24 text-center",
566
+ colSpan: Math.max(visibleLeafColumnsCount, 1),
567
+ children: emptyMessage
568
+ }) });
569
+ return /* @__PURE__ */ _jsxs("div", {
570
+ className: "grid gap-4",
571
+ children: [shouldShowToolbar ? /* @__PURE__ */ _jsxs("div", {
572
+ className: "grid gap-2",
573
+ children: [shouldShowQueryFilter ? /* @__PURE__ */ _jsx(FilterQueryBar, {
574
+ ariaLabel: queryFilterLabel,
575
+ configs: queryFilterConfig,
576
+ onFocusChange: setIsQueryFilterFocused,
577
+ onValueChange: handleQueryFilterChange,
578
+ placeholder: queryFilterPlaceholder,
579
+ trailingAction: queryFilterTrailingAction,
580
+ value: filterQueryDraft
581
+ }) : null, /* @__PURE__ */ _jsxs("div", {
582
+ className: "flex flex-wrap items-center gap-3",
583
+ children: [filterColumnId ? /* @__PURE__ */ _jsxs("div", {
584
+ className: "grid w-full max-w-sm gap-2",
585
+ children: [
586
+ !shouldShowQueryFilter ? /* @__PURE__ */ _jsxs(_Fragment, { children: [
587
+ /* @__PURE__ */ _jsxs("div", {
588
+ className: "relative w-full",
589
+ children: [/* @__PURE__ */ _jsx(Input, {
590
+ "aria-label": filterLabel,
591
+ className: `w-full pr-16 ${hasPendingReverseFilter ? "text-red-400" : ""}`,
592
+ onChange: (event) => {
593
+ const nextFilterValue = event.target.value;
594
+ if (!isFilterValueControlled) {
595
+ setUncontrolledFilterValue(nextFilterValue);
596
+ }
597
+ onFilterValueChange?.(nextFilterValue);
598
+ filterColumn?.setFilterValue(getTableFilterValue(nextFilterValue, showExcludeFilterToggle));
599
+ },
600
+ onKeyDown: (event) => {
601
+ if (event.key !== "Enter") {
602
+ return;
603
+ }
604
+ const trimmedResolvedFilterValue = resolvedFilterValue.trimStart();
605
+ if (!showExcludeFilterToggle || !trimmedResolvedFilterValue.startsWith("-")) {
606
+ return;
607
+ }
608
+ event.preventDefault();
609
+ const excludeFilterCandidate = trimmedResolvedFilterValue.slice(1);
610
+ const wasExcludeFilterAdded = addExcludeFilterValue(excludeFilterCandidate, { clearExcludeInputValue: false });
611
+ if (wasExcludeFilterAdded) {
612
+ handleClearMainFilter();
613
+ }
614
+ },
615
+ placeholder: filterPlaceholder,
616
+ type: "text",
617
+ value: resolvedFilterValue
618
+ }), /* @__PURE__ */ _jsxs("div", {
619
+ className: "absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5",
620
+ children: [resolvedFilterValue ? /* @__PURE__ */ _jsx(Button, {
621
+ "aria-label": CLEAR_FILTER_ARIA_LABEL,
622
+ className: "active:-translate-y-0",
623
+ onClick: handleClearMainFilter,
624
+ size: "icon-xs",
625
+ type: "button",
626
+ variant: "ghost",
627
+ children: /* @__PURE__ */ _jsx(X, { "aria-hidden": "true" })
628
+ }) : null, showExcludeFilterToggle ? /* @__PURE__ */ _jsxs(Button, {
629
+ "aria-expanded": isExcludeFilterInputVisible,
630
+ "aria-label": isExcludeFilterInputVisible ? HIDE_EXCLUDE_FILTERS_ARIA_LABEL : excludeFilterToggleLabel,
631
+ className: "relative",
632
+ onClick: () => {
633
+ setIsExcludeFilterInputVisible((previousState) => {
634
+ const nextState = !previousState;
635
+ if (!nextState) {
636
+ setExcludeFilterInputValue("");
637
+ setExcludeFilterErrorMessage(null);
638
+ }
639
+ return nextState;
640
+ });
641
+ },
642
+ size: "icon-xs",
643
+ type: "button",
644
+ variant: "ghost",
645
+ children: [/* @__PURE__ */ _jsx(Ellipsis, { "aria-hidden": "true" }), hasActiveExcludeFilterValues ? /* @__PURE__ */ _jsxs(_Fragment, { children: [/* @__PURE__ */ _jsx("span", {
646
+ "aria-hidden": "true",
647
+ className: "absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive ring-2 ring-background"
648
+ }), /* @__PURE__ */ _jsx("span", {
649
+ className: "sr-only",
650
+ children: ACTIVE_EXCLUSIONS_SR_LABEL
651
+ })] }) : null]
652
+ }) : null]
653
+ })]
654
+ }),
655
+ showExcludeFilterToggle && isExcludeFilterInputVisible ? /* @__PURE__ */ _jsxs("div", {
656
+ className: "grid gap-1",
657
+ children: [/* @__PURE__ */ _jsxs("div", {
658
+ className: "flex items-center gap-1",
659
+ children: [/* @__PURE__ */ _jsx(Input, {
660
+ "aria-invalid": excludeFilterErrorMessage != null,
661
+ "aria-label": excludeFilterLabel,
662
+ onChange: (event) => {
663
+ setExcludeFilterInputValue(event.target.value);
664
+ if (excludeFilterErrorMessage != null) {
665
+ setExcludeFilterErrorMessage(null);
666
+ }
667
+ },
668
+ onKeyDown: (event) => {
669
+ if (event.key !== "Enter") {
670
+ return;
671
+ }
672
+ event.preventDefault();
673
+ addExcludeFilterValue(excludeFilterInputValue);
674
+ },
675
+ placeholder: excludeFilterPlaceholder,
676
+ type: "text",
677
+ value: excludeFilterInputValue
678
+ }), /* @__PURE__ */ _jsx(Button, {
679
+ "aria-label": CLEAR_ALL_EXCLUSIONS_FROM_INPUT_ARIA_LABEL,
680
+ className: "shrink-0",
681
+ disabled: !hasActiveExcludeFilterValues,
682
+ onClick: handleClearAllExcludeFilters,
683
+ size: "icon-xs",
684
+ type: "button",
685
+ variant: "ghost",
686
+ children: /* @__PURE__ */ _jsx(Eraser, { "aria-hidden": "true" })
687
+ })]
688
+ }), excludeFilterErrorMessage ? /* @__PURE__ */ _jsx("p", {
689
+ "aria-live": "polite",
690
+ className: "text-sm text-destructive",
691
+ children: excludeFilterErrorMessage
692
+ }) : null]
693
+ }) : null,
694
+ hasPendingReverseFilter ? /* @__PURE__ */ _jsxs("div", {
695
+ "aria-live": "polite",
696
+ className: "rounded-md border border-border/80 bg-muted/40 px-3 py-2 text-muted-foreground",
697
+ role: "status",
698
+ children: [/* @__PURE__ */ _jsxs("div", {
699
+ className: "flex items-center gap-2 text-sm font-medium text-foreground/80",
700
+ children: [/* @__PURE__ */ _jsx(CircleAlert, {
701
+ "aria-hidden": "true",
702
+ className: "size-4"
703
+ }), /* @__PURE__ */ _jsx("span", { children: "Exclusión pendiente" })]
704
+ }), /* @__PURE__ */ _jsx("p", {
705
+ className: "mt-1 text-xs",
706
+ children: REVERSE_FILTER_PENDING_MESSAGE
707
+ })]
708
+ }) : null
709
+ ] }) : null,
710
+ showExcludeFilterToggle && resolvedExcludeFilterValues.length > 0 ? /* @__PURE__ */ _jsxs("div", {
711
+ className: "flex flex-wrap items-center gap-2",
712
+ children: [excludeFilterUniqueRowsCount != null ? /* @__PURE__ */ _jsxs(Badge, {
713
+ "aria-label": `Total de filas excluidas únicas: ${excludeFilterUniqueRowsCount}`,
714
+ className: "inline-flex h-6 items-center gap-1.5 px-2.5",
715
+ variant: "destructive",
716
+ children: [
717
+ /* @__PURE__ */ _jsx("span", { children: `${EXCLUDED_ROWS_SUMMARY_LABEL}:` }),
718
+ /* @__PURE__ */ _jsx("span", {
719
+ className: "inline-flex min-w-5 items-center justify-center rounded-full bg-destructive/20 px-1.5 text-[10px] leading-4 font-semibold",
720
+ children: excludeFilterUniqueRowsCount
721
+ }),
722
+ /* @__PURE__ */ _jsx("button", {
723
+ "aria-label": CLEAR_ALL_EXCLUSIONS_ARIA_LABEL,
724
+ className: "inline-flex size-4 items-center justify-center rounded-sm text-destructive transition-colors hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40",
725
+ onClick: handleClearAllExcludeFilters,
726
+ type: "button",
727
+ children: /* @__PURE__ */ _jsx(X, {
728
+ "aria-hidden": "true",
729
+ className: "size-3"
730
+ })
731
+ })
732
+ ]
733
+ }) : null, resolvedExcludeFilterValues.map((excludeFilterValue) => {
734
+ const excludedRowsCount = excludeFilterRowsCountByValue?.[excludeFilterValue];
735
+ return /* @__PURE__ */ _jsxs(Badge, {
736
+ className: "inline-flex h-6 items-center gap-1.5 px-2.5",
737
+ variant: "destructive",
738
+ children: [
739
+ /* @__PURE__ */ _jsx("span", { children: `− ${excludeFilterValue}` }),
740
+ excludedRowsCount != null ? /* @__PURE__ */ _jsx("span", {
741
+ "aria-label": `Filas excluidas por ${excludeFilterValue}: ${excludedRowsCount}`,
742
+ className: "inline-flex min-w-5 items-center justify-center rounded-full bg-destructive/20 px-1.5 text-[10px] leading-4 font-semibold",
743
+ children: excludedRowsCount
744
+ }) : null,
745
+ /* @__PURE__ */ _jsx("button", {
746
+ "aria-label": `Quitar exclusión ${excludeFilterValue}`,
747
+ className: "inline-flex size-4 items-center justify-center rounded-sm text-destructive transition-colors hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40",
748
+ onClick: () => removeExcludeFilterValue(excludeFilterValue),
749
+ type: "button",
750
+ children: /* @__PURE__ */ _jsx(X, {
751
+ "aria-hidden": "true",
752
+ className: "size-3"
753
+ })
754
+ })
755
+ ]
756
+ }, excludeFilterValue);
757
+ })]
758
+ }) : null,
759
+ sortingBadgeNode,
760
+ filterExtraContentNode
761
+ ]
762
+ }) : null, shouldShowToolbarActions ? /* @__PURE__ */ _jsxs("div", {
763
+ className: "ml-auto flex flex-wrap items-center gap-2",
764
+ children: [toolbarActions, shouldShowColumnVisibilityToggle ? /* @__PURE__ */ _jsxs(DropdownMenu, { children: [/* @__PURE__ */ _jsx(DropdownMenuTrigger, {
765
+ asChild: true,
766
+ children: /* @__PURE__ */ _jsxs(Button, {
767
+ "aria-label": columnVisibilityButtonLabel,
768
+ className: "relative",
769
+ type: "button",
770
+ variant: "outline",
771
+ children: [
772
+ columnVisibilityButtonLabel,
773
+ /* @__PURE__ */ _jsx(ChevronDown, { "aria-hidden": "true" }),
774
+ hasToolbarChanges ? /* @__PURE__ */ _jsxs(_Fragment, { children: [/* @__PURE__ */ _jsx("span", {
775
+ "aria-hidden": "true",
776
+ className: "absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive ring-2 ring-background"
777
+ }), /* @__PURE__ */ _jsx("span", {
778
+ className: "sr-only",
779
+ children: "Columnas modificadas"
780
+ })] }) : null
781
+ ]
782
+ })
783
+ }), /* @__PURE__ */ _jsxs(DropdownMenuContent, {
784
+ align: "end",
785
+ children: [
786
+ columnVisibilityMenuExtraContent ? /* @__PURE__ */ _jsxs(_Fragment, { children: [columnVisibilityMenuExtraContent, /* @__PURE__ */ _jsx(DropdownMenuSeparator, {})] }) : null,
787
+ /* @__PURE__ */ _jsx(DropdownMenuLabel, { children: columnVisibilityMenuLabel }),
788
+ /* @__PURE__ */ _jsx(DropdownMenuItem, {
789
+ disabled: areHideableColumnsAtDefaultVisibility,
790
+ onSelect: (event) => {
791
+ event.preventDefault();
792
+ hideableColumns.forEach((column) => {
793
+ column.toggleVisibility(getHideableColumnDefaultVisibility(column.id));
794
+ });
795
+ },
796
+ children: selectAllColumnsLabel
797
+ }),
798
+ /* @__PURE__ */ _jsx(DropdownMenuItem, {
799
+ disabled: !areSomeHideableColumnsVisible,
800
+ onSelect: (event) => {
801
+ event.preventDefault();
802
+ hideableColumns.forEach((column) => {
803
+ column.toggleVisibility(false);
804
+ });
805
+ },
806
+ children: deselectAllColumnsLabel
807
+ }),
808
+ /* @__PURE__ */ _jsx(DropdownMenuSeparator, {}),
809
+ hideableColumns.map((column) => {
810
+ const columnMeta = column.columnDef.meta;
811
+ const isColumnVisible = column.getIsVisible();
812
+ const label = columnMeta?.label ?? (typeof column.columnDef.header === "string" ? column.columnDef.header : column.id);
813
+ return /* @__PURE__ */ _jsxs(DropdownMenuCheckboxItem, {
814
+ checked: isColumnVisible,
815
+ onSelect: (event) => {
816
+ event.preventDefault();
817
+ },
818
+ onCheckedChange: (nextVisible) => {
819
+ column.toggleVisibility(Boolean(nextVisible));
820
+ },
821
+ children: [label, !isColumnVisible ? /* @__PURE__ */ _jsxs(_Fragment, { children: [/* @__PURE__ */ _jsx("span", {
822
+ "aria-hidden": "true",
823
+ className: "absolute right-2 top-1.5 size-2 rounded-full bg-destructive ring-2 ring-background"
824
+ }), /* @__PURE__ */ _jsx("span", {
825
+ className: "sr-only",
826
+ children: "Columna deseleccionada"
827
+ })] }) : null]
828
+ }, column.id);
829
+ })
830
+ ]
831
+ })] }) : null]
832
+ }) : null]
833
+ })]
834
+ }) : null, /* @__PURE__ */ _jsx("div", {
835
+ className: "relative w-full overflow-x-auto",
836
+ children: /* @__PURE__ */ _jsxs(Table, { children: [
837
+ /* @__PURE__ */ _jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => /* @__PURE__ */ _jsx(TableRow, { children: headerGroup.headers.map((header) => {
838
+ const columnMeta = header.column.columnDef.meta;
839
+ return /* @__PURE__ */ _jsx(TableHead, {
840
+ className: columnMeta?.headerClassName,
841
+ onClick: columnMeta?.onHeaderClick,
842
+ children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())
843
+ }, header.id);
844
+ }) }, headerGroup.id)) }),
845
+ /* @__PURE__ */ _jsx(TableBody, { children: tableBodyContent }),
846
+ hasFooterContent ? /* @__PURE__ */ _jsx(TableFooter, { children: footerGroups.map((footerGroup) => /* @__PURE__ */ _jsx(TableRow, {
847
+ className: "hover:bg-transparent",
848
+ children: footerGroup.headers.map((footer) => /* @__PURE__ */ _jsx(TableCell, {
849
+ "data-label": footer.column.columnDef.meta?.label,
850
+ children: footer.isPlaceholder ? null : flexRender(footer.column.columnDef.footer, footer.getContext())
851
+ }, footer.id))
852
+ }, footerGroup.id)) }) : null
853
+ ] })
854
+ })]
855
+ });
612
856
  }