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,526 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Calendar, Check, CircleDot, CircleMinus, CircleSlash, Folder, Hash, List, Type, User, X, } from "lucide-react";
5
+ import { Button } from "./button.js";
6
+ import { Input } from "./input.js";
7
+ import { Popover, PopoverAnchor, PopoverContent, } from "./popover.js";
8
+ import { cn } from "../lib/utils.js";
9
+ import { getActiveFilterToken, getValueHighlightRanges, normalizeFilterSlug, parseFilterQuery, PRESENCE_HAS_META_KEY, PRESENCE_NO_META_KEY, UNASSIGNED_FOLDER_FILTER_VALUE, } from "./filter-query-grammar.js";
10
+ const DEFAULT_PLACEHOLDER = "Filtrar por campo o palabra clave";
11
+ const DEFAULT_ARIA_LABEL = "Filtrar gastos";
12
+ const CLEAR_FILTER_ARIA_LABEL = "Limpiar todos los filtros";
13
+ const KEY_GROUP_LABEL = "Campos";
14
+ const VALUE_GROUP_LABEL = "Valores";
15
+ const LISTBOX_ID = "filter-query-bar-listbox";
16
+ const OPTION_ID_PREFIX = "filter-query-bar-option";
17
+ const NUMBER_RANGE_VALUE_SUGGESTIONS = [
18
+ { label: "Mayor o igual (≥)", slug: ">=" },
19
+ { label: "Menor o igual (≤)", slug: "<=" },
20
+ { label: "Rango (100..500)", slug: ".." },
21
+ ];
22
+ /**
23
+ * Operadores para qualifiers de fecha (mes-año). Reflejan los numéricos: cota
24
+ * inferior (`>=`), cota superior (`<=`) y rango (`..`). La presencia/ausencia de
25
+ * fecha se cubre con las meta-claves `tiene:`/`no:` del bloque universal.
26
+ */
27
+ const YEAR_MONTH_VALUE_SUGGESTIONS = [
28
+ { keepOpen: true, label: "Desde (≥)", slug: ">=" },
29
+ { keepOpen: true, label: "Hasta (≤)", slug: "<=" },
30
+ { keepOpen: true, label: "Rango (2026-01..2026-06)", slug: ".." },
31
+ ];
32
+ const TEXT_MATCH_VALUE_SUGGESTIONS = [
33
+ // `texto*`: el caret queda antes del `*` para escribir el prefijo.
34
+ { caretOffset: 0, insertText: "*", keepOpen: true, label: "Empieza por…", name: "starts", slug: "" },
35
+ // `*texto*`: el caret queda entre los dos `*`.
36
+ { caretOffset: 1, insertText: "**", keepOpen: true, label: "Contiene…", name: "contains", slug: "" },
37
+ // `*texto`: el caret queda después del `*` para escribir el sufijo.
38
+ { insertText: "*", keepOpen: true, label: "Termina con…", name: "ends", slug: "" },
39
+ ];
40
+ /** Meta-claves de presencia ofrecidas también como campos de primer nivel. */
41
+ const PRESENCE_META_KEY_SUGGESTIONS = [
42
+ { icon: Check, key: PRESENCE_HAS_META_KEY, label: "Tiene…" },
43
+ { icon: CircleSlash, key: PRESENCE_NO_META_KEY, label: "No tiene…" },
44
+ ];
45
+ /** Icono representativo de cada tipo de qualifier, mostrado a su izquierda. */
46
+ function getKindIcon(kind) {
47
+ switch (kind) {
48
+ case "numberRange":
49
+ return Hash;
50
+ case "textMatch":
51
+ return Type;
52
+ case "enum":
53
+ return List;
54
+ case "presence":
55
+ return CircleDot;
56
+ case "yearMonthRange":
57
+ return Calendar;
58
+ case "folder":
59
+ return Folder;
60
+ default:
61
+ return Type;
62
+ }
63
+ }
64
+ /** Iconos por nombre lógico declarado en el qualifier (`config.iconName`). */
65
+ const ICON_BY_NAME = {
66
+ user: User,
67
+ };
68
+ /** Icono de un qualifier: override por `iconName`, o el icono por defecto del kind. */
69
+ function getQualifierIcon(config) {
70
+ if (config.iconName && ICON_BY_NAME[config.iconName]) {
71
+ return ICON_BY_NAME[config.iconName];
72
+ }
73
+ return getKindIcon(config.kind);
74
+ }
75
+ function startsWithNormalized(candidate, prefix) {
76
+ if (!prefix) {
77
+ return true;
78
+ }
79
+ return normalizeFilterSlug(candidate).startsWith(normalizeFilterSlug(prefix));
80
+ }
81
+ /** Etiqueta de la opción raíz de exclusión (arranca un token `-campo:`). */
82
+ const EXCLUDE_ROOT_LABEL = "Excluir";
83
+ function buildKeySuggestions(configs, keyPart, { negated, includeExclude, appliedFilters }) {
84
+ // En modo exclusión el campo elegido conserva el `-` para formar `-campo:`.
85
+ const prefix = negated ? "-" : "";
86
+ const fieldSuggestions = configs
87
+ .filter((config) => config.kind !== "text" && config.key)
88
+ .filter((config) => getAppliedPresenceValue(config, appliedFilters) !== "noValue")
89
+ .filter((config) => startsWithNormalized(config.key, keyPart) ||
90
+ startsWithNormalized(config.label, keyPart))
91
+ .map((config) => ({
92
+ group: "key",
93
+ icon: getQualifierIcon(config),
94
+ id: `${OPTION_ID_PREFIX}-key-${negated ? "neg-" : ""}${config.key}`,
95
+ insertText: `${prefix}${config.key}:`,
96
+ keepOpen: true,
97
+ label: config.label,
98
+ }));
99
+ // Meta-claves de presencia como campos de primer nivel (`tiene:` / `no:`); no
100
+ // se ofrecen en modo exclusión (la ausencia ya se cubre con `no:`) ni cuando
101
+ // ningún campo soporta presencia (todos tienen valor siempre).
102
+ const hasPresenceCapableFields = configs.some((config) => config.kind !== "text" &&
103
+ Boolean(config.key) &&
104
+ config.supportsPresence !== false);
105
+ const metaSuggestions = negated || !hasPresenceCapableFields
106
+ ? []
107
+ : PRESENCE_META_KEY_SUGGESTIONS.filter((meta) => startsWithNormalized(meta.key, keyPart) ||
108
+ startsWithNormalized(meta.label, keyPart)).map((meta) => ({
109
+ group: "meta",
110
+ icon: meta.icon,
111
+ id: `${OPTION_ID_PREFIX}-key-${meta.key}`,
112
+ insertText: `${meta.key}:`,
113
+ keepOpen: true,
114
+ label: meta.label,
115
+ }));
116
+ // "Excluir" al final (estilo GitHub): inserta `-` y reabre la lista de campos
117
+ // para elegir cuál excluir.
118
+ const excludeSuggestion = includeExclude
119
+ ? [
120
+ {
121
+ group: "meta",
122
+ icon: CircleMinus,
123
+ id: `${OPTION_ID_PREFIX}-exclude-root`,
124
+ insertText: "-",
125
+ keepOpen: true,
126
+ label: EXCLUDE_ROOT_LABEL,
127
+ },
128
+ ]
129
+ : [];
130
+ return [...fieldSuggestions, ...metaSuggestions, ...excludeSuggestion];
131
+ }
132
+ /**
133
+ * Sugerencias de valor para una meta-clave de presencia (`tiene:` / `no:`): el
134
+ * "valor" es la clave del campo destino (`tiene:enviados`, `no:carpeta`).
135
+ */
136
+ function buildMetaValueSuggestions(configs, metaKey, valuePart, appliedFilters) {
137
+ return configs
138
+ .filter((config) => config.kind !== "text" && config.key)
139
+ .filter((config) => config.supportsPresence !== false)
140
+ .filter((config) => !hasPresenceMetaFilterAlreadyApplied(config, appliedFilters))
141
+ .filter((config) => startsWithNormalized(config.key, valuePart) ||
142
+ startsWithNormalized(config.label, valuePart))
143
+ .map((config) => ({
144
+ group: "value",
145
+ icon: getQualifierIcon(config),
146
+ id: `${OPTION_ID_PREFIX}-meta-${metaKey}-${config.key}`,
147
+ insertText: `${config.key} `,
148
+ keepOpen: false,
149
+ label: config.label,
150
+ }));
151
+ }
152
+ /**
153
+ * Bloque universal que aparece al entrar a cualquier filtro: presencia
154
+ * (`tiene:<clave>`), ausencia (`no:<clave>`) y exclusión de un valor
155
+ * (`-<clave>:`). Reescriben el token completo, por eso usan `replaceTokenStart`.
156
+ */
157
+ function buildPresenceAndExcludeSuggestions(config, appliedFilters) {
158
+ const appliedPresenceValue = getAppliedPresenceValue(config, appliedFilters);
159
+ const hasAppliedPresence = appliedPresenceValue != null;
160
+ const suggestions = hasAppliedPresence || config.supportsPresence === false
161
+ ? []
162
+ : [
163
+ {
164
+ group: "meta",
165
+ icon: CircleSlash,
166
+ id: `${OPTION_ID_PREFIX}-presence-no-${config.key}`,
167
+ insertText: `${PRESENCE_NO_META_KEY}:${config.key} `,
168
+ keepOpen: false,
169
+ label: `No tiene ${config.label}`,
170
+ replaceTokenStart: true,
171
+ },
172
+ {
173
+ group: "meta",
174
+ icon: Check,
175
+ id: `${OPTION_ID_PREFIX}-presence-has-${config.key}`,
176
+ insertText: `${PRESENCE_HAS_META_KEY}:${config.key} `,
177
+ keepOpen: false,
178
+ label: `Tiene ${config.label}`,
179
+ replaceTokenStart: true,
180
+ },
181
+ ];
182
+ // Excluir un valor concreto solo aplica a kinds con valores; en presencia pura
183
+ // (deuda) el único filtro es tener/no tener, ya cubierto arriba.
184
+ if (config.kind !== "presence" && appliedPresenceValue !== "noValue") {
185
+ suggestions.push({
186
+ group: "meta",
187
+ icon: CircleMinus,
188
+ id: `${OPTION_ID_PREFIX}-exclude-${config.key}`,
189
+ insertText: `-${config.key}:`,
190
+ keepOpen: true,
191
+ label: `Excluir ${config.label}…`,
192
+ replaceTokenStart: true,
193
+ });
194
+ }
195
+ return suggestions;
196
+ }
197
+ function getValueSuggestionSource(config) {
198
+ if (config.kind === "enum" || config.kind === "folder") {
199
+ return (config.options ?? []).map((option) => ({
200
+ label: option.label,
201
+ slug: option.slug,
202
+ value: option.value,
203
+ }));
204
+ }
205
+ if (config.kind === "presence") {
206
+ // La presencia se ofrece con el bloque universal No/Tiene, no como valores.
207
+ return [];
208
+ }
209
+ if (config.kind === "yearMonthRange") {
210
+ return YEAR_MONTH_VALUE_SUGGESTIONS;
211
+ }
212
+ if (config.kind === "numberRange") {
213
+ return NUMBER_RANGE_VALUE_SUGGESTIONS;
214
+ }
215
+ if (config.kind === "textMatch") {
216
+ return TEXT_MATCH_VALUE_SUGGESTIONS;
217
+ }
218
+ return [];
219
+ }
220
+ function hasPresenceMetaFilterAlreadyApplied(config, appliedFilters) {
221
+ return appliedFilters.some((appliedFilter) => appliedFilter.key === config.key &&
222
+ !appliedFilter.negated &&
223
+ appliedFilter.value.kind === "presence");
224
+ }
225
+ function hasSelectableValueAlreadyApplied(config, option, appliedFilters) {
226
+ if (config.kind === "enum") {
227
+ return appliedFilters.some((appliedFilter) => appliedFilter.key === config.key &&
228
+ appliedFilter.value.kind === "enum" &&
229
+ appliedFilter.value.value === option.value);
230
+ }
231
+ if (config.kind === "folder") {
232
+ return appliedFilters.some((appliedFilter) => appliedFilter.key === config.key &&
233
+ appliedFilter.value.kind === "folder" &&
234
+ appliedFilter.value.folderId === option.value);
235
+ }
236
+ return false;
237
+ }
238
+ function getAppliedPresenceValue(config, appliedFilters) {
239
+ const presenceFilter = appliedFilters.find((appliedFilter) => appliedFilter.key === config.key &&
240
+ !appliedFilter.negated &&
241
+ appliedFilter.value.kind === "presence");
242
+ return presenceFilter?.value.kind === "presence"
243
+ ? presenceFilter.value.value
244
+ : null;
245
+ }
246
+ function isSelectableValueCompatibleWithPresence(config, option, appliedFilters) {
247
+ const presenceValue = getAppliedPresenceValue(config, appliedFilters);
248
+ if (presenceValue === "noValue") {
249
+ return false;
250
+ }
251
+ if (config.kind !== "folder") {
252
+ return true;
253
+ }
254
+ if (presenceValue === "hasValue") {
255
+ return option.value !== UNASSIGNED_FOLDER_FILTER_VALUE;
256
+ }
257
+ return true;
258
+ }
259
+ function buildValueSuggestions(config, valuePart, negated, appliedFilters) {
260
+ // Los operadores numéricos no cierran el popover: el usuario completa el número.
261
+ const defaultKeepOpen = config.kind === "numberRange";
262
+ // Para texto, los operadores (empieza/contiene/termina) son un menú inicial:
263
+ // aparecen al entrar al filtro y desaparecen apenas se escribe el texto, igual
264
+ // que en GitHub. El resto de los kinds filtra sus valores a medida que se tipea.
265
+ const sourceOptions = config.kind === "textMatch" && valuePart !== ""
266
+ ? []
267
+ : getValueSuggestionSource(config);
268
+ // Para listas seleccionables (enum/folder) se tolera un `@` inicial al estilo
269
+ // "mención" (p. ej. `prestamista:@vero`) al filtrar las opciones.
270
+ const filterValuePart = config.kind === "enum" || config.kind === "folder"
271
+ ? valuePart.replace(/^@/, "")
272
+ : valuePart;
273
+ const valueSuggestions = sourceOptions
274
+ .filter((option) => !hasSelectableValueAlreadyApplied(config, option, appliedFilters) &&
275
+ isSelectableValueCompatibleWithPresence(config, option, appliedFilters))
276
+ .filter((option) => startsWithNormalized(option.slug, filterValuePart))
277
+ .map((option) => {
278
+ const keepOpen = option.keepOpen ?? defaultKeepOpen;
279
+ const insertText = option.insertText ?? (keepOpen ? option.slug : `${option.slug} `);
280
+ return {
281
+ caretOffset: option.caretOffset,
282
+ group: "value",
283
+ icon: getQualifierIcon(config),
284
+ id: `${OPTION_ID_PREFIX}-value-${config.key}-${option.name ?? option.slug}`,
285
+ insertText,
286
+ keepOpen,
287
+ label: option.label,
288
+ };
289
+ });
290
+ // El bloque universal (No tiene / Tiene / Excluir) encabeza las sugerencias al
291
+ // entrar al filtro (valor vacío) y solo en tokens no negados: dentro de
292
+ // `-clave:` el usuario ya excluye y elige directamente el valor a excluir.
293
+ if (negated || valuePart.length > 0) {
294
+ return valueSuggestions;
295
+ }
296
+ return [
297
+ ...buildPresenceAndExcludeSuggestions(config, appliedFilters),
298
+ ...valueSuggestions,
299
+ ];
300
+ }
301
+ function resolveConfigByKey(configs, rawKey) {
302
+ const normalized = normalizeFilterSlug(rawKey);
303
+ return configs.find((config) => {
304
+ if (config.kind === "text" || !config.key) {
305
+ return false;
306
+ }
307
+ if (normalizeFilterSlug(config.key) === normalized) {
308
+ return true;
309
+ }
310
+ return (config.aliases ?? []).some((alias) => normalizeFilterSlug(alias) === normalized);
311
+ });
312
+ }
313
+ /**
314
+ * Parte la query en nodos para el overlay de resaltado: los rangos de valores
315
+ * válidos se pintan con color de acento; el resto queda con el color normal.
316
+ */
317
+ function renderHighlightedQuery(value, ranges) {
318
+ if (ranges.length === 0) {
319
+ return value;
320
+ }
321
+ const nodes = [];
322
+ let cursor = 0;
323
+ ranges.forEach((range, index) => {
324
+ if (range.start > cursor) {
325
+ nodes.push(value.slice(cursor, range.start));
326
+ }
327
+ nodes.push(_jsx("span", { className: "text-primary", children: value.slice(range.start, range.end) }, `highlight-${index}`));
328
+ cursor = range.end;
329
+ });
330
+ if (cursor < value.length) {
331
+ nodes.push(value.slice(cursor));
332
+ }
333
+ return nodes;
334
+ }
335
+ /**
336
+ * Barra de filtro estilo GitHub Issues: input único con autocompletado de
337
+ * `clave:valor`. Sugiere primero los campos disponibles y luego los valores de
338
+ * cada campo, con navegación por teclado y semántica ARIA de combobox/listbox.
339
+ * La navegación es virtual: el foco real nunca abandona el input.
340
+ */
341
+ export function FilterQueryBar({ configs, value, onValueChange, placeholder = DEFAULT_PLACEHOLDER, ariaLabel = DEFAULT_ARIA_LABEL, className, onFocusChange, trailingAction, }) {
342
+ // Ids únicos por instancia: puede haber más de una barra montada a la vez
343
+ // (p. ej. la barra principal y el editor de un preset guardado).
344
+ const instanceId = React.useId();
345
+ const listboxId = `${instanceId}-${LISTBOX_ID}`;
346
+ const inputRef = React.useRef(null);
347
+ const highlightOverlayRef = React.useRef(null);
348
+ const pendingCaretRef = React.useRef(null);
349
+ const [caretIndex, setCaretIndex] = React.useState(0);
350
+ const [isOpen, setIsOpen] = React.useState(false);
351
+ const [activeIndex, setActiveIndex] = React.useState(0);
352
+ // El popover se abre al enfocar; sin esta marca, Tab insertaría la primera
353
+ // sugerencia en vez de mover el foco. Solo interceptamos Tab cuando el usuario
354
+ // navegó explícitamente las sugerencias con las flechas.
355
+ const [hasNavigatedSuggestions, setHasNavigatedSuggestions] = React.useState(false);
356
+ const suggestions = React.useMemo(() => {
357
+ const activeToken = getActiveFilterToken(value, caretIndex);
358
+ const appliedFilters = parseFilterQuery(value, configs).appliedFilters;
359
+ if (activeToken.mode === "key") {
360
+ // En modo clave negado (`-` / `-cam`) se sugieren los campos a excluir, que
361
+ // arman `-campo:`. El texto libre `-palabra` sigue funcionando porque una
362
+ // palabra que no matchea ninguna clave no produce sugerencias. "Excluir"
363
+ // solo aparece con la barra vacía (sin clave a medio tipear ni negación).
364
+ return buildKeySuggestions(configs, activeToken.keyPart, {
365
+ appliedFilters,
366
+ includeExclude: !activeToken.negated && activeToken.keyPart === "",
367
+ negated: activeToken.negated,
368
+ });
369
+ }
370
+ const normalizedResolvedKey = normalizeFilterSlug(activeToken.resolvedKey ?? "");
371
+ // Meta-claves (`tiene:` / `no:`): el valor a sugerir es la clave del campo.
372
+ if (normalizedResolvedKey === PRESENCE_HAS_META_KEY ||
373
+ normalizedResolvedKey === PRESENCE_NO_META_KEY) {
374
+ return buildMetaValueSuggestions(configs, normalizedResolvedKey, activeToken.valuePart, appliedFilters);
375
+ }
376
+ const config = resolveConfigByKey(configs, activeToken.resolvedKey ?? "");
377
+ if (!config) {
378
+ return [];
379
+ }
380
+ return buildValueSuggestions(config, activeToken.valuePart, activeToken.negated, appliedFilters);
381
+ }, [caretIndex, configs, value]);
382
+ const invalidTokenCount = React.useMemo(() => parseFilterQuery(value, configs).invalidTokens.length, [configs, value]);
383
+ const highlightRanges = React.useMemo(() => getValueHighlightRanges(value, configs), [configs, value]);
384
+ // El overlay de resaltado debe seguir el scroll horizontal del input cuando el
385
+ // texto excede el ancho visible, para que el color quede alineado al caret.
386
+ const syncOverlayScroll = React.useCallback(() => {
387
+ if (highlightOverlayRef.current && inputRef.current) {
388
+ highlightOverlayRef.current.scrollLeft = inputRef.current.scrollLeft;
389
+ }
390
+ }, []);
391
+ React.useEffect(() => {
392
+ setActiveIndex((previousIndex) => previousIndex < suggestions.length ? previousIndex : 0);
393
+ }, [suggestions]);
394
+ React.useLayoutEffect(() => {
395
+ const pendingCaret = pendingCaretRef.current;
396
+ if (pendingCaret == null || !inputRef.current) {
397
+ return;
398
+ }
399
+ inputRef.current.setSelectionRange(pendingCaret, pendingCaret);
400
+ setCaretIndex(pendingCaret);
401
+ pendingCaretRef.current = null;
402
+ }, [value]);
403
+ React.useLayoutEffect(() => {
404
+ syncOverlayScroll();
405
+ }, [syncOverlayScroll, value]);
406
+ const isPopoverOpen = isOpen && suggestions.length > 0;
407
+ const activeSuggestion = isPopoverOpen ? suggestions[activeIndex] : undefined;
408
+ const syncCaretFromInput = React.useCallback(() => {
409
+ if (inputRef.current) {
410
+ setCaretIndex(inputRef.current.selectionStart ?? 0);
411
+ }
412
+ }, []);
413
+ const applySuggestion = React.useCallback((suggestion) => {
414
+ const activeToken = getActiveFilterToken(value, inputRef.current?.selectionStart ?? caretIndex);
415
+ // Las opciones universales reescriben el token entero (clave incluida); el
416
+ // resto solo reemplaza el tramo del valor bajo el caret.
417
+ const replaceStart = suggestion.replaceTokenStart
418
+ ? activeToken.tokenStart
419
+ : activeToken.replaceStart;
420
+ const before = value.slice(0, replaceStart);
421
+ const after = value.slice(activeToken.replaceEnd);
422
+ const nextValue = before + suggestion.insertText + after;
423
+ // El caret queda dentro del texto insertado (p. ej. entre los `*` de `**`).
424
+ pendingCaretRef.current =
425
+ before.length + (suggestion.caretOffset ?? suggestion.insertText.length);
426
+ onValueChange(nextValue);
427
+ setIsOpen(true);
428
+ setActiveIndex(0);
429
+ setHasNavigatedSuggestions(false);
430
+ if (!suggestion.keepOpen) {
431
+ // El valor quedó completo; el siguiente token reabrirá en modo claves.
432
+ setIsOpen(false);
433
+ }
434
+ }, [caretIndex, onValueChange, value]);
435
+ const handleKeyDown = React.useCallback((event) => {
436
+ if (event.key === "ArrowDown") {
437
+ event.preventDefault();
438
+ setHasNavigatedSuggestions(true);
439
+ if (!isPopoverOpen) {
440
+ setIsOpen(true);
441
+ return;
442
+ }
443
+ setActiveIndex((previousIndex) => suggestions.length === 0
444
+ ? 0
445
+ : (previousIndex + 1) % suggestions.length);
446
+ return;
447
+ }
448
+ if (event.key === "ArrowUp") {
449
+ event.preventDefault();
450
+ setHasNavigatedSuggestions(true);
451
+ if (!isPopoverOpen) {
452
+ setIsOpen(true);
453
+ return;
454
+ }
455
+ setActiveIndex((previousIndex) => suggestions.length === 0
456
+ ? 0
457
+ : (previousIndex - 1 + suggestions.length) % suggestions.length);
458
+ return;
459
+ }
460
+ if (event.key === "Enter" && activeSuggestion) {
461
+ event.preventDefault();
462
+ applySuggestion(activeSuggestion);
463
+ return;
464
+ }
465
+ // Tab solo acepta una sugerencia si el usuario la resaltó con las flechas;
466
+ // de lo contrario deja que el foco se mueva con normalidad.
467
+ if (event.key === "Tab" &&
468
+ hasNavigatedSuggestions &&
469
+ activeSuggestion) {
470
+ event.preventDefault();
471
+ applySuggestion(activeSuggestion);
472
+ return;
473
+ }
474
+ if (event.key === "Escape" && isPopoverOpen) {
475
+ event.preventDefault();
476
+ event.stopPropagation();
477
+ setIsOpen(false);
478
+ }
479
+ }, [
480
+ activeSuggestion,
481
+ applySuggestion,
482
+ hasNavigatedSuggestions,
483
+ isPopoverOpen,
484
+ suggestions.length,
485
+ ]);
486
+ const handleClear = React.useCallback(() => {
487
+ onValueChange("");
488
+ pendingCaretRef.current = 0;
489
+ setIsOpen(false);
490
+ inputRef.current?.focus();
491
+ }, [onValueChange]);
492
+ return (_jsxs("div", { className: cn("grid w-full gap-2", className), children: [_jsxs(Popover, { onOpenChange: setIsOpen, open: isPopoverOpen, children: [_jsx(PopoverAnchor, { asChild: true, children: _jsxs("div", { className: "relative w-full", children: [_jsx(Input, { "aria-activedescendant": activeSuggestion
493
+ ? `${instanceId}-${activeSuggestion.id}`
494
+ : undefined, "aria-autocomplete": "list", "aria-controls": listboxId, "aria-expanded": isPopoverOpen, "aria-label": ariaLabel, autoComplete: "off",
495
+ // El texto se vuelve transparente: lo dibuja el overlay coloreado de
496
+ // abajo. El caret queda visible con el color de texto normal. El
497
+ // padding derecho deja lugar a los iconos (limpiar + acción opcional).
498
+ className: cn("w-full text-transparent", trailingAction ? "pr-16" : "pr-9"), onChange: (event) => {
499
+ onValueChange(event.target.value);
500
+ setIsOpen(true);
501
+ setActiveIndex(0);
502
+ setHasNavigatedSuggestions(false);
503
+ setCaretIndex(event.target.selectionStart ?? event.target.value.length);
504
+ }, onClick: syncCaretFromInput, onBlur: () => {
505
+ setHasNavigatedSuggestions(false);
506
+ onFocusChange?.(false);
507
+ }, onFocus: () => {
508
+ syncCaretFromInput();
509
+ setIsOpen(true);
510
+ onFocusChange?.(true);
511
+ }, onKeyDown: handleKeyDown, onKeyUp: syncCaretFromInput, onScroll: syncOverlayScroll, placeholder: placeholder, ref: inputRef, role: "combobox", style: { caretColor: "var(--foreground)" }, type: "text", value: value }), _jsx("div", { "aria-hidden": "true", className: cn("pointer-events-none absolute inset-0 flex items-center overflow-hidden rounded-lg border border-transparent px-2.5 text-base text-foreground select-none md:text-sm", trailingAction ? "pr-16" : "pr-9"), ref: highlightOverlayRef, children: _jsx("span", { className: "whitespace-pre", children: renderHighlightedQuery(value, highlightRanges) }) }), _jsxs("div", { className: "absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5", children: [value ? (_jsx(Button, { "aria-label": CLEAR_FILTER_ARIA_LABEL, className: "active:-translate-y-0", onClick: handleClear, size: "icon-xs", type: "button", variant: "ghost", children: _jsx(X, { "aria-hidden": "true" }) })) : null, trailingAction] })] }) }), _jsx(PopoverContent, { align: "start", className: "w-[250px] max-h-[280px] gap-0 overflow-y-auto p-1", onCloseAutoFocus: (event) => event.preventDefault(), onOpenAutoFocus: (event) => event.preventDefault(), children: _jsx("ul", { "aria-label": suggestions.some((suggestion) => suggestion.group === "value")
512
+ ? VALUE_GROUP_LABEL
513
+ : KEY_GROUP_LABEL, className: "grid gap-0.5", id: listboxId, role: "listbox", children: suggestions.map((suggestion, index) => {
514
+ const SuggestionIcon = suggestion.icon;
515
+ // Separador entre grupos lógicos (p. ej. campos | presencia/exclusión).
516
+ const showSeparator = index > 0 && suggestions[index - 1].group !== suggestion.group;
517
+ return (_jsxs(React.Fragment, { children: [showSeparator ? (_jsx("li", { "aria-hidden": "true", className: "my-1 border-t border-border", role: "presentation" })) : null, _jsxs("li", { "aria-selected": index === activeIndex, className: cn("flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm", index === activeIndex
518
+ ? "bg-accent text-accent-foreground"
519
+ : "text-foreground"), id: `${instanceId}-${suggestion.id}`, onMouseDown: (event) => {
520
+ event.preventDefault();
521
+ applySuggestion(suggestion);
522
+ }, role: "option", children: [_jsx(SuggestionIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: suggestion.label })] })] }, suggestion.id));
523
+ }) }) })] }), _jsxs("span", { "aria-live": "polite", className: "sr-only", children: [isPopoverOpen ? `${suggestions.length} sugerencias disponibles` : "", invalidTokenCount > 0
524
+ ? ` ${invalidTokenCount} filtros no aplicados`
525
+ : ""] })] }));
526
+ }