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.
- package/CHANGELOG.md +34 -0
- package/LICENSE.md +21 -0
- package/README.md +113 -0
- package/assets/fonts/geist-LICENSE.txt +93 -0
- package/assets/fonts/geist-latin-variable.woff2 +0 -0
- package/assets/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/assets/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/assets/fonts/ibmplexmono-LICENSE.txt +93 -0
- package/assets/fonts/poppins-LICENSE.txt +93 -0
- package/assets/fonts/poppins-latin-600.woff2 +0 -0
- package/dist/adapters/unpic-image.d.ts +3 -0
- package/dist/adapters/unpic-image.js +20 -0
- package/dist/components/alert-dialog.d.ts +18 -0
- package/dist/components/alert-dialog.js +42 -0
- package/dist/components/alert.d.ts +10 -0
- package/dist/components/alert.js +28 -0
- package/dist/components/animated-theme-toggler.d.ts +8 -0
- package/dist/components/animated-theme-toggler.js +88 -0
- package/dist/components/avatar.d.ts +16 -0
- package/dist/components/avatar.js +26 -0
- package/dist/components/badge.d.ts +9 -0
- package/dist/components/badge.js +24 -0
- package/dist/components/button.d.ts +10 -0
- package/dist/components/button.js +37 -0
- package/dist/components/calendar.d.ts +10 -0
- package/dist/components/calendar.js +77 -0
- package/dist/components/card.d.ts +11 -0
- package/dist/components/card.js +24 -0
- package/dist/components/carousel.d.ts +28 -0
- package/dist/components/carousel.js +98 -0
- package/dist/components/checkbox.d.ts +4 -0
- package/dist/components/checkbox.js +9 -0
- package/dist/components/data-table.d.ts +117 -0
- package/dist/components/data-table.js +612 -0
- package/dist/components/dialog.d.ts +17 -0
- package/dist/components/dialog.js +37 -0
- package/dist/components/dropdown-menu.d.ts +29 -0
- package/dist/components/dropdown-menu.js +51 -0
- package/dist/components/filter-query-bar.d.ts +21 -0
- package/dist/components/filter-query-bar.js +526 -0
- package/dist/components/filter-query-grammar.d.ts +188 -0
- package/dist/components/filter-query-grammar.js +830 -0
- package/dist/components/form.d.ts +24 -0
- package/dist/components/form.js +59 -0
- package/dist/components/highlighter.d.ts +15 -0
- package/dist/components/highlighter.js +91 -0
- package/dist/components/hover-card.d.ts +6 -0
- package/dist/components/hover-card.js +14 -0
- package/dist/components/input-group.d.ts +16 -0
- package/dist/components/input-group.js +57 -0
- package/dist/components/input.d.ts +3 -0
- package/dist/components/input.js +7 -0
- package/dist/components/label.d.ts +4 -0
- package/dist/components/label.js +8 -0
- package/dist/components/link.d.ts +9 -0
- package/dist/components/link.js +11 -0
- package/dist/components/pagination.d.ts +18 -0
- package/dist/components/pagination.js +28 -0
- package/dist/components/popover.d.ts +10 -0
- package/dist/components/popover.js +26 -0
- package/dist/components/radio-group.d.ts +5 -0
- package/dist/components/radio-group.js +11 -0
- package/dist/components/select.d.ts +15 -0
- package/dist/components/select.js +36 -0
- package/dist/components/separator.d.ts +3 -0
- package/dist/components/separator.js +8 -0
- package/dist/components/sheet.d.ts +14 -0
- package/dist/components/sheet.js +37 -0
- package/dist/components/sidebar.d.ts +71 -0
- package/dist/components/sidebar.js +221 -0
- package/dist/components/skeleton.d.ts +2 -0
- package/dist/components/skeleton.js +6 -0
- package/dist/components/svgs/whatsappIcon.d.ts +3 -0
- package/dist/components/svgs/whatsappIcon.js +3 -0
- package/dist/components/switch.d.ts +6 -0
- package/dist/components/switch.js +8 -0
- package/dist/components/table.d.ts +10 -0
- package/dist/components/table.js +28 -0
- package/dist/components/tabs.d.ts +11 -0
- package/dist/components/tabs.js +29 -0
- package/dist/components/textarea.d.ts +3 -0
- package/dist/components/textarea.js +6 -0
- package/dist/components/themed-toaster.d.ts +4 -0
- package/dist/components/themed-toaster.js +27 -0
- package/dist/components/tooltip.d.ts +7 -0
- package/dist/components/tooltip.js +17 -0
- package/dist/components/typing-animation.d.ts +34 -0
- package/dist/components/typing-animation.js +155 -0
- package/dist/constants/sidebar.d.ts +9 -0
- package/dist/constants/sidebar.js +20 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +19 -0
- package/dist/hooks/use-sidebar-persistence.d.ts +4 -0
- package/dist/hooks/use-sidebar-persistence.js +45 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +47 -0
- package/dist/lib/image-dimensions.d.ts +2 -0
- package/dist/lib/image-dimensions.js +7 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +2 -0
- package/dist/lib/year-month-filter.d.ts +16 -0
- package/dist/lib/year-month-filter.js +48 -0
- package/dist/next.d.ts +14 -0
- package/dist/next.js +27 -0
- package/dist/providers/beez-ui-provider.d.ts +27 -0
- package/dist/providers/beez-ui-provider.js +18 -0
- package/dist/providers/native-provider.d.ts +4 -0
- package/dist/providers/native-provider.js +11 -0
- package/dist/sonner.d.ts +2 -0
- package/dist/sonner.js +3 -0
- package/dist/styles.css +2 -0
- package/dist/tanstack.d.ts +11 -0
- package/dist/tanstack.js +38 -0
- package/dist/theme.d.ts +3 -0
- package/dist/theme.js +1 -0
- package/dist/types/data-table.d.ts +27 -0
- package/dist/types/data-table.js +1 -0
- package/fonts.css +5 -0
- package/package.json +335 -0
- package/styles.css +4 -0
- 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
|
+
}
|