beez-ui 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +113 -0
  4. package/assets/fonts/geist-LICENSE.txt +93 -0
  5. package/assets/fonts/geist-latin-variable.woff2 +0 -0
  6. package/assets/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  7. package/assets/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  8. package/assets/fonts/ibmplexmono-LICENSE.txt +93 -0
  9. package/assets/fonts/poppins-LICENSE.txt +93 -0
  10. package/assets/fonts/poppins-latin-600.woff2 +0 -0
  11. package/dist/adapters/unpic-image.d.ts +3 -0
  12. package/dist/adapters/unpic-image.js +20 -0
  13. package/dist/components/alert-dialog.d.ts +18 -0
  14. package/dist/components/alert-dialog.js +42 -0
  15. package/dist/components/alert.d.ts +10 -0
  16. package/dist/components/alert.js +28 -0
  17. package/dist/components/animated-theme-toggler.d.ts +8 -0
  18. package/dist/components/animated-theme-toggler.js +88 -0
  19. package/dist/components/avatar.d.ts +16 -0
  20. package/dist/components/avatar.js +26 -0
  21. package/dist/components/badge.d.ts +9 -0
  22. package/dist/components/badge.js +24 -0
  23. package/dist/components/button.d.ts +10 -0
  24. package/dist/components/button.js +37 -0
  25. package/dist/components/calendar.d.ts +10 -0
  26. package/dist/components/calendar.js +77 -0
  27. package/dist/components/card.d.ts +11 -0
  28. package/dist/components/card.js +24 -0
  29. package/dist/components/carousel.d.ts +28 -0
  30. package/dist/components/carousel.js +98 -0
  31. package/dist/components/checkbox.d.ts +4 -0
  32. package/dist/components/checkbox.js +9 -0
  33. package/dist/components/data-table.d.ts +117 -0
  34. package/dist/components/data-table.js +612 -0
  35. package/dist/components/dialog.d.ts +17 -0
  36. package/dist/components/dialog.js +37 -0
  37. package/dist/components/dropdown-menu.d.ts +29 -0
  38. package/dist/components/dropdown-menu.js +51 -0
  39. package/dist/components/filter-query-bar.d.ts +21 -0
  40. package/dist/components/filter-query-bar.js +526 -0
  41. package/dist/components/filter-query-grammar.d.ts +188 -0
  42. package/dist/components/filter-query-grammar.js +830 -0
  43. package/dist/components/form.d.ts +24 -0
  44. package/dist/components/form.js +59 -0
  45. package/dist/components/highlighter.d.ts +15 -0
  46. package/dist/components/highlighter.js +91 -0
  47. package/dist/components/hover-card.d.ts +6 -0
  48. package/dist/components/hover-card.js +14 -0
  49. package/dist/components/input-group.d.ts +16 -0
  50. package/dist/components/input-group.js +57 -0
  51. package/dist/components/input.d.ts +3 -0
  52. package/dist/components/input.js +7 -0
  53. package/dist/components/label.d.ts +4 -0
  54. package/dist/components/label.js +8 -0
  55. package/dist/components/link.d.ts +9 -0
  56. package/dist/components/link.js +11 -0
  57. package/dist/components/pagination.d.ts +18 -0
  58. package/dist/components/pagination.js +28 -0
  59. package/dist/components/popover.d.ts +10 -0
  60. package/dist/components/popover.js +26 -0
  61. package/dist/components/radio-group.d.ts +5 -0
  62. package/dist/components/radio-group.js +11 -0
  63. package/dist/components/select.d.ts +15 -0
  64. package/dist/components/select.js +36 -0
  65. package/dist/components/separator.d.ts +3 -0
  66. package/dist/components/separator.js +8 -0
  67. package/dist/components/sheet.d.ts +14 -0
  68. package/dist/components/sheet.js +37 -0
  69. package/dist/components/sidebar.d.ts +71 -0
  70. package/dist/components/sidebar.js +221 -0
  71. package/dist/components/skeleton.d.ts +2 -0
  72. package/dist/components/skeleton.js +6 -0
  73. package/dist/components/svgs/whatsappIcon.d.ts +3 -0
  74. package/dist/components/svgs/whatsappIcon.js +3 -0
  75. package/dist/components/switch.d.ts +6 -0
  76. package/dist/components/switch.js +8 -0
  77. package/dist/components/table.d.ts +10 -0
  78. package/dist/components/table.js +28 -0
  79. package/dist/components/tabs.d.ts +11 -0
  80. package/dist/components/tabs.js +29 -0
  81. package/dist/components/textarea.d.ts +3 -0
  82. package/dist/components/textarea.js +6 -0
  83. package/dist/components/themed-toaster.d.ts +4 -0
  84. package/dist/components/themed-toaster.js +27 -0
  85. package/dist/components/tooltip.d.ts +7 -0
  86. package/dist/components/tooltip.js +17 -0
  87. package/dist/components/typing-animation.d.ts +34 -0
  88. package/dist/components/typing-animation.js +155 -0
  89. package/dist/constants/sidebar.d.ts +9 -0
  90. package/dist/constants/sidebar.js +20 -0
  91. package/dist/hooks/use-mobile.d.ts +1 -0
  92. package/dist/hooks/use-mobile.js +19 -0
  93. package/dist/hooks/use-sidebar-persistence.d.ts +4 -0
  94. package/dist/hooks/use-sidebar-persistence.js +45 -0
  95. package/dist/index.d.ts +48 -0
  96. package/dist/index.js +47 -0
  97. package/dist/lib/image-dimensions.d.ts +2 -0
  98. package/dist/lib/image-dimensions.js +7 -0
  99. package/dist/lib/utils.d.ts +2 -0
  100. package/dist/lib/utils.js +2 -0
  101. package/dist/lib/year-month-filter.d.ts +16 -0
  102. package/dist/lib/year-month-filter.js +48 -0
  103. package/dist/next.d.ts +14 -0
  104. package/dist/next.js +27 -0
  105. package/dist/providers/beez-ui-provider.d.ts +27 -0
  106. package/dist/providers/beez-ui-provider.js +18 -0
  107. package/dist/providers/native-provider.d.ts +4 -0
  108. package/dist/providers/native-provider.js +11 -0
  109. package/dist/sonner.d.ts +2 -0
  110. package/dist/sonner.js +3 -0
  111. package/dist/styles.css +2 -0
  112. package/dist/tanstack.d.ts +11 -0
  113. package/dist/tanstack.js +38 -0
  114. package/dist/theme.d.ts +3 -0
  115. package/dist/theme.js +1 -0
  116. package/dist/types/data-table.d.ts +27 -0
  117. package/dist/types/data-table.js +1 -0
  118. package/fonts.css +5 -0
  119. package/package.json +335 -0
  120. package/styles.css +4 -0
  121. package/theme.css +87 -0
@@ -0,0 +1,830 @@
1
+ /**
2
+ * Gramática pura (sin React) para una barra de filtro estilo GitHub Issues.
3
+ *
4
+ * La barra acepta una query de texto con tokens `clave:valor` más texto libre.
5
+ * Este módulo tokeniza la query, la parsea contra una configuración de
6
+ * qualifiers y la traduce a los tres canales de filtrado que ya consume la
7
+ * tabla: búsqueda fuzzy por descripción, exclusiones de descripción y filtros
8
+ * avanzados por columna (`DataTableColumnFilterValue`).
9
+ */
10
+ const DIACRITICS_PATTERN = /[̀-ͯ]/g;
11
+ const YEAR_MONTH_SLUG_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])$/;
12
+ const RANGE_SEPARATOR = "..";
13
+ /** Slug de valor para los modos de presencia/rango mes-año sin cota. */
14
+ export const YEAR_MONTH_NO_VALUE_SLUG = "sin-fechas";
15
+ export const YEAR_MONTH_HAS_VALUE_SLUG = "con-fechas";
16
+ export const PRESENCE_TRUE_SLUG = "si";
17
+ export const PRESENCE_FALSE_SLUG = "no";
18
+ /**
19
+ * GitHub-style meta-keys to filter by presence/absence of ANY field:
20
+ * `tiene:<field>` (the field has a value) and `no:<field>` (the field is empty).
21
+ * The token value is the key of the target qualifier (e.g. `tiene:enviados`,
22
+ * `no:carpeta`). They unify presence across every kind into a single syntax,
23
+ * instead of a `<field>:si/no` per qualifier.
24
+ */
25
+ export const PRESENCE_HAS_META_KEY = "tiene";
26
+ export const PRESENCE_NO_META_KEY = "no";
27
+ /** Identificador de "sin carpeta asignada" en un qualifier de carpeta. */
28
+ export const UNASSIGNED_FOLDER_FILTER_VALUE = "__unassigned__";
29
+ /** Normaliza una clave o slug: sin acentos, minúsculas, recortado. */
30
+ export function normalizeFilterSlug(value) {
31
+ return value
32
+ .normalize("NFD")
33
+ .replace(DIACRITICS_PATTERN, "")
34
+ .toLocaleLowerCase()
35
+ .trim();
36
+ }
37
+ function stripSurroundingQuotes(value) {
38
+ if (value.length >= 2) {
39
+ const first = value[0];
40
+ const last = value[value.length - 1];
41
+ if ((first === '"' || first === "'") && first === last) {
42
+ return value.slice(1, -1);
43
+ }
44
+ }
45
+ // Comilla de apertura sin cierre (token a medio tipear).
46
+ if (value.startsWith('"') || value.startsWith("'")) {
47
+ return value.slice(1);
48
+ }
49
+ return value;
50
+ }
51
+ /**
52
+ * Envuelve en comillas un valor que, sin comillas, se re-tokenizaría distinto:
53
+ * espacios, un `:` (se interpretaría como `clave:valor`) o un `-` inicial (se
54
+ * interpretaría como exclusión). Mantiene la query como fuente de verdad sin
55
+ * pérdida entre la barra unificada y los controles clásicos.
56
+ */
57
+ function quoteTokenIfNeeded(value) {
58
+ return /[\s:]/.test(value) || value.startsWith("-")
59
+ ? `"${value}"`
60
+ : value;
61
+ }
62
+ /**
63
+ * Serializa el texto libre de descripción citando, palabra por palabra, los
64
+ * segmentos que se parsearían como un token (por ejemplo `total:100`), de modo
65
+ * que un filtro de descripción no se transforme en un filtro avanzado al
66
+ * re-parsear la query.
67
+ */
68
+ function serializeDescriptionFilter(descriptionFilter) {
69
+ return descriptionFilter
70
+ .split(/\s+/)
71
+ .filter((word) => word.length > 0)
72
+ .map(quoteTokenIfNeeded)
73
+ .join(" ");
74
+ }
75
+ /**
76
+ * Divide la query en tokens respetando comillas (`"..."` / `'...'` agrupan
77
+ * espacios). Conserva los índices de cada token para que el autocompletado
78
+ * pueda ubicar el token bajo el caret.
79
+ */
80
+ export function tokenizeFilterQuery(query) {
81
+ const tokens = [];
82
+ let index = 0;
83
+ const { length } = query;
84
+ while (index < length) {
85
+ if (/\s/.test(query[index])) {
86
+ index += 1;
87
+ continue;
88
+ }
89
+ const startIndex = index;
90
+ let quoteChar = null;
91
+ while (index < length) {
92
+ const character = query[index];
93
+ if (quoteChar) {
94
+ if (character === quoteChar) {
95
+ quoteChar = null;
96
+ }
97
+ index += 1;
98
+ continue;
99
+ }
100
+ if (character === '"' || character === "'") {
101
+ quoteChar = character;
102
+ index += 1;
103
+ continue;
104
+ }
105
+ if (/\s/.test(character)) {
106
+ break;
107
+ }
108
+ index += 1;
109
+ }
110
+ const raw = query.slice(startIndex, index);
111
+ tokens.push(buildToken(raw, startIndex, index));
112
+ }
113
+ return tokens;
114
+ }
115
+ function findKeyColonIndex(text) {
116
+ for (let position = 0; position < text.length; position += 1) {
117
+ const character = text[position];
118
+ if (character === '"' || character === "'") {
119
+ // Una comilla antes del `:` significa que no hay clave (es texto libre).
120
+ return -1;
121
+ }
122
+ if (character === ":") {
123
+ return position;
124
+ }
125
+ }
126
+ return -1;
127
+ }
128
+ function buildToken(raw, startIndex, endIndex) {
129
+ // A leading `-` marks exclusion, even when it is the only character: this way,
130
+ // when starting an exclusion from the bar (`-`), the token is already negated
131
+ // and autocompletion can offer the fields to exclude. A lone `-` is ignored
132
+ // when parsing (empty value) and filters nothing.
133
+ const negated = raw.startsWith("-");
134
+ const remainder = negated ? raw.slice(1) : raw;
135
+ const colonIndex = findKeyColonIndex(remainder);
136
+ if (colonIndex === -1) {
137
+ return {
138
+ endIndex,
139
+ hasColon: false,
140
+ negated,
141
+ raw,
142
+ rawKey: null,
143
+ startIndex,
144
+ value: stripSurroundingQuotes(remainder),
145
+ };
146
+ }
147
+ return {
148
+ endIndex,
149
+ hasColon: true,
150
+ negated,
151
+ raw,
152
+ rawKey: remainder.slice(0, colonIndex),
153
+ startIndex,
154
+ value: stripSurroundingQuotes(remainder.slice(colonIndex + 1)),
155
+ };
156
+ }
157
+ function buildQualifierLookup(configs) {
158
+ const lookup = new Map();
159
+ for (const config of configs) {
160
+ if (config.kind === "text" || !config.key) {
161
+ continue;
162
+ }
163
+ lookup.set(normalizeFilterSlug(config.key), config);
164
+ for (const alias of config.aliases ?? []) {
165
+ lookup.set(normalizeFilterSlug(alias), config);
166
+ }
167
+ }
168
+ return lookup;
169
+ }
170
+ function parseNumber(value) {
171
+ const normalized = value.trim();
172
+ if (!normalized) {
173
+ return null;
174
+ }
175
+ const parsed = Number(normalized);
176
+ return Number.isFinite(parsed) ? parsed : null;
177
+ }
178
+ /**
179
+ * Parsea el valor de un qualifier numérico. Acepta comparadores (`>`, `>=`,
180
+ * `<`, `<=`, `=`), igualdad implícita (`N`) y rangos (`A..B`, `A..`, `..B`).
181
+ * `>` y `>=` son ambos inclusivos para preservar la semántica del matcher
182
+ * existente (`value < min → false`); `<` y `<=` mapean a `max`.
183
+ */
184
+ function parseNumberRangeValue(value) {
185
+ const normalized = value.trim();
186
+ if (!normalized) {
187
+ return null;
188
+ }
189
+ if (normalized.includes(RANGE_SEPARATOR)) {
190
+ const [rawMin, rawMax] = normalized.split(RANGE_SEPARATOR, 2);
191
+ const hasMin = rawMin.trim().length > 0;
192
+ const hasMax = rawMax.trim().length > 0;
193
+ if (!hasMin && !hasMax) {
194
+ return null;
195
+ }
196
+ const min = hasMin ? parseNumber(rawMin) : null;
197
+ const max = hasMax ? parseNumber(rawMax) : null;
198
+ if ((hasMin && min == null) || (hasMax && max == null)) {
199
+ return null;
200
+ }
201
+ // Un rango invertido (`100..50`) no puede matchear ninguna fila: es inválido.
202
+ if (min != null && max != null && min > max) {
203
+ return null;
204
+ }
205
+ return {
206
+ kind: "numberRange",
207
+ ...(max != null ? { max } : {}),
208
+ ...(min != null ? { min } : {}),
209
+ };
210
+ }
211
+ if (normalized.startsWith(">=") || normalized.startsWith(">")) {
212
+ const min = parseNumber(normalized.replace(/^>=?/, ""));
213
+ return min == null ? null : { kind: "numberRange", min };
214
+ }
215
+ if (normalized.startsWith("<=") || normalized.startsWith("<")) {
216
+ const max = parseNumber(normalized.replace(/^<=?/, ""));
217
+ return max == null ? null : { kind: "numberRange", max };
218
+ }
219
+ const exact = parseNumber(normalized.replace(/^=/, ""));
220
+ return exact == null ? null : { kind: "numberRange", max: exact, min: exact };
221
+ }
222
+ /** Convierte un slug `AAAA-MM` a su valor numérico comparable `AAAAMM`. */
223
+ export function parseYearMonthSlug(value) {
224
+ const match = YEAR_MONTH_SLUG_PATTERN.exec(value.trim());
225
+ if (!match) {
226
+ return null;
227
+ }
228
+ const [, year, month] = match;
229
+ return Number(`${year}${month}`);
230
+ }
231
+ /** Formatea un valor numérico `AAAAMM` de vuelta a su slug `AAAA-MM`. */
232
+ export function formatYearMonthSlug(value) {
233
+ const year = Math.floor(value / 100);
234
+ const month = value % 100;
235
+ return `${year}-${String(month).padStart(2, "0")}`;
236
+ }
237
+ function parseYearMonthRangeValue(value) {
238
+ const normalized = normalizeFilterSlug(value);
239
+ if (!normalized) {
240
+ return null;
241
+ }
242
+ if (normalized === YEAR_MONTH_NO_VALUE_SLUG) {
243
+ return { kind: "yearMonthRange", mode: "noValue" };
244
+ }
245
+ if (normalized === YEAR_MONTH_HAS_VALUE_SLUG) {
246
+ return { kind: "yearMonthRange", mode: "hasValue" };
247
+ }
248
+ // Comparators `>=`/`>` (from) and `<=`/`<` (to), analogous to the numeric
249
+ // ones, to bound dates with a single endpoint without writing an `A..` range.
250
+ if (normalized.startsWith(">=") || normalized.startsWith(">")) {
251
+ const min = parseYearMonthSlug(normalized.replace(/^>=?/, ""));
252
+ return min == null ? null : { kind: "yearMonthRange", min, mode: "from" };
253
+ }
254
+ if (normalized.startsWith("<=") || normalized.startsWith("<")) {
255
+ const max = parseYearMonthSlug(normalized.replace(/^<=?/, ""));
256
+ return max == null ? null : { kind: "yearMonthRange", max, mode: "to" };
257
+ }
258
+ if (normalized.includes(RANGE_SEPARATOR)) {
259
+ const [rawFrom, rawTo] = normalized.split(RANGE_SEPARATOR, 2);
260
+ const hasFrom = rawFrom.trim().length > 0;
261
+ const hasTo = rawTo.trim().length > 0;
262
+ if (!hasFrom && !hasTo) {
263
+ return null;
264
+ }
265
+ const min = hasFrom ? parseYearMonthSlug(rawFrom) : null;
266
+ const max = hasTo ? parseYearMonthSlug(rawTo) : null;
267
+ if ((hasFrom && min == null) || (hasTo && max == null)) {
268
+ return null;
269
+ }
270
+ if (min != null && max != null) {
271
+ // Rango invertido (`2026-12..2026-06`): imposible de matchear, inválido.
272
+ if (min > max) {
273
+ return null;
274
+ }
275
+ return { kind: "yearMonthRange", max, min, mode: "range" };
276
+ }
277
+ if (min != null) {
278
+ return { kind: "yearMonthRange", min, mode: "from" };
279
+ }
280
+ return { kind: "yearMonthRange", max: max, mode: "to" };
281
+ }
282
+ const exact = parseYearMonthSlug(normalized);
283
+ if (exact == null) {
284
+ return null;
285
+ }
286
+ return { kind: "yearMonthRange", max: exact, min: exact, mode: "range" };
287
+ }
288
+ function buildColumnFilterValue(config, value) {
289
+ if (config.kind === "numberRange") {
290
+ return parseNumberRangeValue(value);
291
+ }
292
+ if (config.kind === "yearMonthRange") {
293
+ return parseYearMonthRangeValue(value);
294
+ }
295
+ if (config.kind === "enum") {
296
+ // A leading `@` mention-style prefix is tolerated (e.g. `prestamista:@vero`).
297
+ const normalized = normalizeFilterSlug(value).replace(/^@/, "");
298
+ const option = (config.options ?? []).find((candidate) => normalizeFilterSlug(candidate.slug) === normalized);
299
+ return option ? { kind: "enum", value: option.value } : null;
300
+ }
301
+ if (config.kind === "presence") {
302
+ const normalized = normalizeFilterSlug(value);
303
+ if (normalized === PRESENCE_TRUE_SLUG) {
304
+ return { kind: "presence", value: "hasValue" };
305
+ }
306
+ if (normalized === PRESENCE_FALSE_SLUG) {
307
+ return { kind: "presence", value: "noValue" };
308
+ }
309
+ return null;
310
+ }
311
+ return null;
312
+ }
313
+ /**
314
+ * Parses the value of a text qualifier (`textMatch`) with glob syntax. Accepts
315
+ * presence (`si`/`no`) and `*` wildcards: `text*` starts with, `*text` ends
316
+ * with, `*text*` contains, and `text` (no wildcard) is exact equality. The text
317
+ * is normalized (no accents, lowercase) to compare case/accent-insensitively.
318
+ */
319
+ export function parseTextMatchValue(value) {
320
+ const trimmed = value.trim();
321
+ if (!trimmed) {
322
+ return null;
323
+ }
324
+ const normalizedSlug = normalizeFilterSlug(trimmed);
325
+ if (normalizedSlug === PRESENCE_TRUE_SLUG) {
326
+ return { kind: "textMatch", op: "has" };
327
+ }
328
+ if (normalizedSlug === PRESENCE_FALSE_SLUG) {
329
+ return { kind: "textMatch", op: "notHas" };
330
+ }
331
+ const startsWithStar = trimmed.startsWith("*");
332
+ const endsWithStar = trimmed.endsWith("*");
333
+ if (startsWithStar && endsWithStar) {
334
+ const text = normalizeFilterSlug(trimmed.slice(1, -1));
335
+ return text ? { kind: "textMatch", op: "contains", text } : null;
336
+ }
337
+ if (endsWithStar) {
338
+ const text = normalizeFilterSlug(trimmed.slice(0, -1));
339
+ return text ? { kind: "textMatch", op: "startsWith", text } : null;
340
+ }
341
+ if (startsWithStar) {
342
+ const text = normalizeFilterSlug(trimmed.slice(1));
343
+ return text ? { kind: "textMatch", op: "endsWith", text } : null;
344
+ }
345
+ const text = normalizeFilterSlug(trimmed);
346
+ return text ? { kind: "textMatch", op: "equals", text } : null;
347
+ }
348
+ /**
349
+ * Parsea el valor de un qualifier de carpeta (`folder`) contra sus opciones
350
+ * dinámicas (slug→folderId). `sin-carpeta` mapea al sentinel de "sin asignar".
351
+ */
352
+ export function parseFolderValue(config, value) {
353
+ // Se tolera un `@` inicial al estilo "mención" (p. ej. `carpeta:@hogar`),
354
+ // igual que el parser de enum, para que completar el token tipeando/Enter
355
+ // coincida con la sugerencia que filtra strip-eando ese `@`.
356
+ const normalized = normalizeFilterSlug(value).replace(/^@/, "");
357
+ if (!normalized) {
358
+ return null;
359
+ }
360
+ const option = (config.options ?? []).find((candidate) => normalizeFilterSlug(candidate.slug) === normalized);
361
+ return option ? { kind: "folder", folderId: option.value } : null;
362
+ }
363
+ /** Construye el valor estructurado de un qualifier según su kind. */
364
+ function buildAppliedFilterValue(config, value) {
365
+ if (config.kind === "textMatch") {
366
+ return parseTextMatchValue(value);
367
+ }
368
+ if (config.kind === "folder") {
369
+ return parseFolderValue(config, value);
370
+ }
371
+ return buildColumnFilterValue(config, value);
372
+ }
373
+ /** Discrimina un valor que vive en el path de columnas de TanStack. */
374
+ function isColumnFilterValue(value) {
375
+ return value.kind !== "textMatch" && value.kind !== "folder";
376
+ }
377
+ function mergeColumnFilterValue(existing, next) {
378
+ if (existing &&
379
+ existing.kind === "numberRange" &&
380
+ next.kind === "numberRange") {
381
+ // Qualifiers repetidos se ANDean: nos quedamos con la cota más ajustada
382
+ // (mayor mínimo, menor máximo) para que el resultado no dependa del orden.
383
+ const mergedMin = existing.min != null && next.min != null
384
+ ? Math.max(existing.min, next.min)
385
+ : (next.min ?? existing.min);
386
+ const mergedMax = existing.max != null && next.max != null
387
+ ? Math.min(existing.max, next.max)
388
+ : (next.max ?? existing.max);
389
+ return {
390
+ kind: "numberRange",
391
+ ...(mergedMax != null ? { max: mergedMax } : {}),
392
+ ...(mergedMin != null ? { min: mergedMin } : {}),
393
+ };
394
+ }
395
+ if (existing &&
396
+ existing.kind === "yearMonthRange" &&
397
+ next.kind === "yearMonthRange") {
398
+ const isBounded = (mode) => mode === "range" || mode === "from" || mode === "to";
399
+ // Los modos de presencia (con/sin fechas) no combinan con cotas: gana el
400
+ // último. Para range/from/to se intersectan las cotas (mayor desde, menor
401
+ // hasta), igual que `numberRange`, en vez de descartar la primera cota.
402
+ if (!isBounded(existing.mode) || !isBounded(next.mode)) {
403
+ return next;
404
+ }
405
+ const mergedMin = existing.min != null && next.min != null
406
+ ? Math.max(existing.min, next.min)
407
+ : (next.min ?? existing.min);
408
+ const mergedMax = existing.max != null && next.max != null
409
+ ? Math.min(existing.max, next.max)
410
+ : (next.max ?? existing.max);
411
+ const mode = mergedMin != null && mergedMax != null
412
+ ? "range"
413
+ : mergedMin != null
414
+ ? "from"
415
+ : "to";
416
+ return {
417
+ kind: "yearMonthRange",
418
+ mode,
419
+ ...(mergedMax != null ? { max: mergedMax } : {}),
420
+ ...(mergedMin != null ? { min: mergedMin } : {}),
421
+ };
422
+ }
423
+ return next;
424
+ }
425
+ /**
426
+ * Detecta un rango imposible (`min > max`), por ejemplo tras combinar
427
+ * `total:>100 total:<50`. El matcher rechazaría toda fila, así que se reporta el
428
+ * token como inválido en vez de aplicar un filtro vacío.
429
+ */
430
+ function isImpossibleRange(value) {
431
+ if (value.kind === "numberRange") {
432
+ return value.min != null && value.max != null && value.min > value.max;
433
+ }
434
+ if (value.kind === "yearMonthRange") {
435
+ return (value.mode === "range" &&
436
+ value.min != null &&
437
+ value.max != null &&
438
+ value.min > value.max);
439
+ }
440
+ return false;
441
+ }
442
+ /** Quita duplicados de exclusiones comparando por su forma normalizada. */
443
+ function dedupeByNormalizedSlug(values) {
444
+ const seenKeys = new Set();
445
+ const dedupedValues = [];
446
+ for (const value of values) {
447
+ const normalizedKey = normalizeFilterSlug(value);
448
+ if (!normalizedKey || seenKeys.has(normalizedKey)) {
449
+ continue;
450
+ }
451
+ seenKeys.add(normalizedKey);
452
+ dedupedValues.push(value);
453
+ }
454
+ return dedupedValues;
455
+ }
456
+ /** Parsea la query completa contra la configuración de qualifiers. */
457
+ export function parseFilterQuery(query, configs) {
458
+ const tokens = tokenizeFilterQuery(query);
459
+ const lookup = buildQualifierLookup(configs);
460
+ const descriptionParts = [];
461
+ const excludedDescriptionFilters = [];
462
+ const advancedFiltersByColumn = {};
463
+ const appliedFilters = [];
464
+ // Índice en `appliedFilters` del último filtro de rango (no negado) por clave,
465
+ // para mergear y validar rangos repetidos de forma uniforme tengan o no columna.
466
+ const rangeAppliedIndexByKey = new Map();
467
+ const invalidTokens = [];
468
+ for (const token of tokens) {
469
+ if (token.hasColon && token.rawKey) {
470
+ const normalizedKey = normalizeFilterSlug(token.rawKey);
471
+ // Presence meta-keys (`tiene:<field>` / `no:<field>`): the value is the
472
+ // key of the target field. They produce a presence filter that the domain
473
+ // predicate evaluates per field; they do not go through the column path.
474
+ if (normalizedKey === PRESENCE_HAS_META_KEY ||
475
+ normalizedKey === PRESENCE_NO_META_KEY) {
476
+ const fieldKey = normalizeFilterSlug(token.value ?? "");
477
+ if (!fieldKey) {
478
+ continue;
479
+ }
480
+ const fieldConfig = lookup.get(fieldKey);
481
+ if (!fieldConfig || fieldConfig.supportsPresence === false) {
482
+ invalidTokens.push({ raw: token.raw, reason: "invalidValue" });
483
+ continue;
484
+ }
485
+ // Token negation (`-tiene:x`) flips the meaning, leaving a single
486
+ // canonical presence filter (without residual negation).
487
+ const hasValue = (normalizedKey === PRESENCE_HAS_META_KEY) !== token.negated;
488
+ appliedFilters.push({
489
+ key: fieldConfig.key,
490
+ negated: false,
491
+ value: { kind: "presence", value: hasValue ? "hasValue" : "noValue" },
492
+ });
493
+ continue;
494
+ }
495
+ const config = lookup.get(normalizedKey);
496
+ if (!config) {
497
+ invalidTokens.push({ raw: token.raw, reason: "unknownKey" });
498
+ const freeText = token.negated ? token.raw.slice(1) : token.raw;
499
+ if (token.negated) {
500
+ excludedDescriptionFilters.push(freeText.trim());
501
+ }
502
+ else if (freeText.trim()) {
503
+ descriptionParts.push(freeText.trim());
504
+ }
505
+ continue;
506
+ }
507
+ const value = token.value ?? "";
508
+ const filterValue = buildAppliedFilterValue(config, value);
509
+ if (!filterValue) {
510
+ if (value.trim()) {
511
+ invalidTokens.push({ raw: token.raw, reason: "invalidValue" });
512
+ }
513
+ continue;
514
+ }
515
+ if (isColumnFilterValue(filterValue)) {
516
+ const isRange = filterValue.kind === "numberRange" ||
517
+ filterValue.kind === "yearMonthRange";
518
+ // Los rangos repetidos de una misma clave (con o sin columna) se mergean
519
+ // y validan igual: una combinación imposible (p. ej.
520
+ // `subtotal:>1000 subtotal:<50`) se reporta inválida y conserva la cota
521
+ // previa, en vez de filtrar todas las filas en silencio.
522
+ if (!token.negated && isRange) {
523
+ const existingIndex = rangeAppliedIndexByKey.get(config.key);
524
+ if (existingIndex != null) {
525
+ const existingValue = appliedFilters[existingIndex]
526
+ .value;
527
+ const mergedValue = mergeColumnFilterValue(existingValue, filterValue);
528
+ if (isImpossibleRange(mergedValue)) {
529
+ invalidTokens.push({ raw: token.raw, reason: "invalidValue" });
530
+ continue;
531
+ }
532
+ appliedFilters[existingIndex] = {
533
+ ...appliedFilters[existingIndex],
534
+ value: mergedValue,
535
+ };
536
+ if (config.columnId) {
537
+ advancedFiltersByColumn[config.columnId] = mergedValue;
538
+ }
539
+ continue;
540
+ }
541
+ appliedFilters.push({
542
+ key: config.key,
543
+ negated: false,
544
+ value: filterValue,
545
+ });
546
+ rangeAppliedIndexByKey.set(config.key, appliedFilters.length - 1);
547
+ if (config.columnId) {
548
+ advancedFiltersByColumn[config.columnId] = filterValue;
549
+ }
550
+ continue;
551
+ }
552
+ // enum/presence (not range) or negated: added without merging. The
553
+ // non-negated ones with a column are projected to the classic TanStack
554
+ // path.
555
+ appliedFilters.push({
556
+ key: config.key,
557
+ negated: token.negated,
558
+ value: filterValue,
559
+ });
560
+ // Presence (`<field>:si/no`) is unified with the meta-keys and resolved
561
+ // by the domain predicate, so it is never projected to a column.
562
+ if (!token.negated &&
563
+ config.columnId &&
564
+ filterValue.kind !== "presence") {
565
+ advancedFiltersByColumn[config.columnId] = mergeColumnFilterValue(advancedFiltersByColumn[config.columnId], filterValue);
566
+ }
567
+ continue;
568
+ }
569
+ // textMatch / folder: sin columna y sin merge de rango.
570
+ appliedFilters.push({
571
+ key: config.key,
572
+ negated: token.negated,
573
+ value: filterValue,
574
+ });
575
+ continue;
576
+ }
577
+ const text = (token.value ?? "").trim();
578
+ if (!text) {
579
+ continue;
580
+ }
581
+ if (token.negated) {
582
+ excludedDescriptionFilters.push(text);
583
+ }
584
+ else {
585
+ descriptionParts.push(text);
586
+ }
587
+ }
588
+ return {
589
+ advancedFiltersByColumn,
590
+ appliedFilters,
591
+ descriptionFilter: descriptionParts.join(" "),
592
+ excludedDescriptionFilters: dedupeByNormalizedSlug(excludedDescriptionFilters),
593
+ invalidTokens,
594
+ };
595
+ }
596
+ function serializeNumberRange(key, value) {
597
+ const { min, max } = value;
598
+ if (min != null && max != null) {
599
+ return min === max ? `${key}:=${min}` : `${key}:${min}..${max}`;
600
+ }
601
+ if (min != null) {
602
+ return `${key}:>=${min}`;
603
+ }
604
+ if (max != null) {
605
+ return `${key}:<=${max}`;
606
+ }
607
+ return null;
608
+ }
609
+ function serializeYearMonthRange(key, value) {
610
+ if (value.mode === "noValue") {
611
+ return `${key}:${YEAR_MONTH_NO_VALUE_SLUG}`;
612
+ }
613
+ if (value.mode === "hasValue") {
614
+ return `${key}:${YEAR_MONTH_HAS_VALUE_SLUG}`;
615
+ }
616
+ if (value.mode === "from" && value.min != null) {
617
+ return `${key}:${formatYearMonthSlug(value.min)}..`;
618
+ }
619
+ if (value.mode === "to" && value.max != null) {
620
+ return `${key}:..${formatYearMonthSlug(value.max)}`;
621
+ }
622
+ if (value.mode === "range" && value.min != null && value.max != null) {
623
+ if (value.min === value.max) {
624
+ return `${key}:${formatYearMonthSlug(value.min)}`;
625
+ }
626
+ return `${key}:${formatYearMonthSlug(value.min)}..${formatYearMonthSlug(value.max)}`;
627
+ }
628
+ return null;
629
+ }
630
+ function serializeTextMatch(key, value) {
631
+ if (value.op === "has") {
632
+ return `${key}:${PRESENCE_TRUE_SLUG}`;
633
+ }
634
+ if (value.op === "notHas") {
635
+ return `${key}:${PRESENCE_FALSE_SLUG}`;
636
+ }
637
+ const text = value.text ?? "";
638
+ const decorated = value.op === "startsWith"
639
+ ? `${text}*`
640
+ : value.op === "endsWith"
641
+ ? `*${text}`
642
+ : value.op === "contains"
643
+ ? `*${text}*`
644
+ : text;
645
+ return `${key}:${quoteTokenIfNeeded(decorated)}`;
646
+ }
647
+ function serializeFolderValue(config, value) {
648
+ const option = (config.options ?? []).find((candidate) => candidate.value === value.folderId);
649
+ return option ? `${config.key}:${option.slug}` : null;
650
+ }
651
+ function serializeAppliedFilterValue(config, value) {
652
+ if (value.kind === "numberRange") {
653
+ return serializeNumberRange(config.key, value);
654
+ }
655
+ if (value.kind === "yearMonthRange") {
656
+ return serializeYearMonthRange(config.key, value);
657
+ }
658
+ if (value.kind === "enum") {
659
+ const option = (config.options ?? []).find((candidate) => candidate.value === value.value);
660
+ return option ? `${config.key}:${option.slug}` : null;
661
+ }
662
+ if (value.kind === "textMatch") {
663
+ return serializeTextMatch(config.key, value);
664
+ }
665
+ if (value.kind === "folder") {
666
+ return serializeFolderValue(config, value);
667
+ }
668
+ // Presence: canonical form with meta-keys (`tiene:<field>` / `no:<field>`).
669
+ const metaKey = value.value === "hasValue" ? PRESENCE_HAS_META_KEY : PRESENCE_NO_META_KEY;
670
+ return `${metaKey}:${config.key}`;
671
+ }
672
+ /**
673
+ * `true` when the filter is already emitted by the classic serialization from
674
+ * `advancedFiltersByColumn` (not negated, with a column, and of a column kind),
675
+ * so it is not serialized twice while also iterating `appliedFilters`.
676
+ *
677
+ * `textMatch` and `folder` never live in the column map, so they are never
678
+ * covered. Presence is special: parsing keeps it only in `appliedFilters`, but a
679
+ * consumer (the advanced dialog) may also place it in the column map. It counts
680
+ * as covered only when that same column actually holds a value, so a presence
681
+ * that lives solely in `appliedFilters` is still serialized once and one that
682
+ * lives in both is not duplicated.
683
+ */
684
+ function isCoveredByColumnSerialization(appliedFilter, config, advancedFiltersByColumn) {
685
+ if (appliedFilter.negated ||
686
+ config.columnId == null ||
687
+ appliedFilter.value.kind === "textMatch" ||
688
+ appliedFilter.value.kind === "folder") {
689
+ return false;
690
+ }
691
+ if (appliedFilter.value.kind === "presence") {
692
+ return advancedFiltersByColumn[config.columnId] != null;
693
+ }
694
+ return true;
695
+ }
696
+ /** Reconstruye una query canónica a partir de un resultado parseado. */
697
+ export function serializeFilterQuery(parsed, configs) {
698
+ const segments = [];
699
+ const configsByKey = new Map(configs.map((config) => [normalizeFilterSlug(config.key), config]));
700
+ const descriptionFilter = parsed.descriptionFilter.trim();
701
+ if (descriptionFilter) {
702
+ segments.push(serializeDescriptionFilter(descriptionFilter));
703
+ }
704
+ // Classic per-column filters (config order), for compatibility with consumers
705
+ // that build the parsed result only from `advancedFiltersByColumn`.
706
+ for (const config of configs) {
707
+ if (!config.columnId) {
708
+ continue;
709
+ }
710
+ const value = parsed.advancedFiltersByColumn[config.columnId];
711
+ if (!value) {
712
+ continue;
713
+ }
714
+ const serialized = serializeAppliedFilterValue(config, value);
715
+ if (serialized) {
716
+ segments.push(serialized);
717
+ }
718
+ }
719
+ // Extras the column path does not cover: negated, textMatch, folder, and
720
+ // qualifiers without a column.
721
+ for (const appliedFilter of parsed.appliedFilters ?? []) {
722
+ const config = configsByKey.get(normalizeFilterSlug(appliedFilter.key));
723
+ if (!config ||
724
+ isCoveredByColumnSerialization(appliedFilter, config, parsed.advancedFiltersByColumn)) {
725
+ continue;
726
+ }
727
+ const serialized = serializeAppliedFilterValue(config, appliedFilter.value);
728
+ if (serialized) {
729
+ segments.push(appliedFilter.negated ? `-${serialized}` : serialized);
730
+ }
731
+ }
732
+ for (const excluded of parsed.excludedDescriptionFilters) {
733
+ const trimmed = excluded.trim();
734
+ if (trimmed) {
735
+ segments.push(`-${quoteTokenIfNeeded(trimmed)}`);
736
+ }
737
+ }
738
+ return segments.join(" ");
739
+ }
740
+ /**
741
+ * Returns the character ranges of the VALUES (whatever comes after the `:`) that
742
+ * are valid for their qualifier, to highlight them in the bar. An invalid,
743
+ * unknown, or incomplete value produces no range (no highlight). Covers the
744
+ * presence meta-keys (`tiene:`/`no:` with an existing field) and the normal
745
+ * qualifiers (value parseable per its kind: enum, range, text, etc.).
746
+ */
747
+ export function getValueHighlightRanges(query, configs) {
748
+ const tokens = tokenizeFilterQuery(query);
749
+ const lookup = buildQualifierLookup(configs);
750
+ const ranges = [];
751
+ for (const token of tokens) {
752
+ if (!token.hasColon || !token.rawKey) {
753
+ continue;
754
+ }
755
+ const negationOffset = token.negated ? 1 : 0;
756
+ const colonIndex = findKeyColonIndex(token.raw.slice(negationOffset));
757
+ if (colonIndex === -1) {
758
+ continue;
759
+ }
760
+ const valueStart = token.startIndex + negationOffset + colonIndex + 1;
761
+ // No value yet (`key:`): nothing to highlight.
762
+ if (valueStart >= token.endIndex) {
763
+ continue;
764
+ }
765
+ const normalizedKey = normalizeFilterSlug(token.rawKey);
766
+ const rawValue = token.value ?? "";
767
+ let isValid = false;
768
+ if (normalizedKey === PRESENCE_HAS_META_KEY ||
769
+ normalizedKey === PRESENCE_NO_META_KEY) {
770
+ // A meta-key's value is the key of an existing field.
771
+ isValid = lookup.has(normalizeFilterSlug(rawValue));
772
+ }
773
+ else {
774
+ const config = lookup.get(normalizedKey);
775
+ isValid =
776
+ config != null && buildAppliedFilterValue(config, rawValue) != null;
777
+ }
778
+ if (isValid) {
779
+ ranges.push({ end: token.endIndex, start: valueStart });
780
+ }
781
+ }
782
+ return ranges;
783
+ }
784
+ /**
785
+ * Describe el token bajo el caret para alimentar el autocompletado. Decide si
786
+ * el usuario está tipeando una clave (sin `:`) o el valor de una clave.
787
+ */
788
+ export function getActiveFilterToken(query, caretIndex) {
789
+ const tokens = tokenizeFilterQuery(query);
790
+ const activeToken = tokens.find((token) => caretIndex >= token.startIndex && caretIndex <= token.endIndex);
791
+ if (!activeToken) {
792
+ return {
793
+ keyPart: "",
794
+ mode: "key",
795
+ negated: false,
796
+ replaceEnd: caretIndex,
797
+ replaceStart: caretIndex,
798
+ resolvedKey: null,
799
+ tokenStart: caretIndex,
800
+ valuePart: "",
801
+ };
802
+ }
803
+ const negationOffset = activeToken.negated ? 1 : 0;
804
+ const contentStart = activeToken.startIndex + negationOffset;
805
+ const remainder = activeToken.raw.slice(negationOffset);
806
+ const colonIndex = findKeyColonIndex(remainder);
807
+ if (colonIndex === -1) {
808
+ return {
809
+ keyPart: remainder,
810
+ mode: "key",
811
+ negated: activeToken.negated,
812
+ replaceEnd: activeToken.endIndex,
813
+ replaceStart: activeToken.startIndex,
814
+ resolvedKey: null,
815
+ tokenStart: activeToken.startIndex,
816
+ valuePart: "",
817
+ };
818
+ }
819
+ const valueStart = contentStart + colonIndex + 1;
820
+ return {
821
+ keyPart: remainder.slice(0, colonIndex),
822
+ mode: "value",
823
+ negated: activeToken.negated,
824
+ replaceEnd: activeToken.endIndex,
825
+ replaceStart: valueStart,
826
+ resolvedKey: remainder.slice(0, colonIndex),
827
+ tokenStart: activeToken.startIndex,
828
+ valuePart: remainder.slice(colonIndex + 1),
829
+ };
830
+ }