@sincpro/web-ui 1.2.0 → 1.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 (130) hide show
  1. package/dist/components/activity/Activity.js +1 -1
  2. package/dist/components/activity/Messages.d.ts +4 -2
  3. package/dist/components/activity/Messages.js +4 -4
  4. package/dist/components/activity/Timeline.js +1 -1
  5. package/dist/components/assist/AssistMark.js +1 -1
  6. package/dist/components/assist/AssistSuggestion.js +1 -1
  7. package/dist/components/assist/ChangeReview.js +2 -2
  8. package/dist/components/calendar/RecordCalendar.js +4 -1
  9. package/dist/components/form/Sheet.js +5 -2
  10. package/dist/components/lines/LineItems.d.ts +1 -1
  11. package/dist/components/lines/LineItems.js +2 -2
  12. package/dist/components/listing/ListingBar.js +1 -1
  13. package/dist/components/listing/RecordCard.d.ts +4 -0
  14. package/dist/components/listing/RecordCard.js +4 -1
  15. package/dist/components/listing/Row.d.ts +7 -0
  16. package/dist/components/listing/Row.js +7 -1
  17. package/dist/components/listing/ViewSwitcher.d.ts +7 -2
  18. package/dist/components/listing/index.d.ts +1 -1
  19. package/dist/components/listing/set.d.ts +4 -1
  20. package/dist/components/record/Breadcrumb.js +5 -1
  21. package/dist/components/record/DirtyActions.d.ts +20 -6
  22. package/dist/components/record/DirtyActions.js +55 -4
  23. package/dist/components/record/RecordHeader.d.ts +1 -1
  24. package/dist/components/record/RecordHeader.js +32 -13
  25. package/dist/components/record/RecordSkeleton.js +1 -1
  26. package/dist/components/search/CriteriaBuilder.d.ts +1 -1
  27. package/dist/components/search/CriteriaBuilder.js +3 -3
  28. package/dist/components/search/FilterBuilder.d.ts +1 -18
  29. package/dist/components/search/FilterBuilder.js +4 -4
  30. package/dist/components/search/JsonEditor.js +5 -1
  31. package/dist/components/search/SearchBar.d.ts +1 -2
  32. package/dist/components/search/SearchBar.js +12 -3
  33. package/dist/components/search/criteria.d.ts +17 -1
  34. package/dist/components/search/index.d.ts +2 -1
  35. package/dist/components/search/index.js +1 -0
  36. package/dist/components/shell/Panes.d.ts +18 -0
  37. package/dist/components/shell/Panes.js +36 -0
  38. package/dist/components/shell/index.d.ts +1 -0
  39. package/dist/components/shell/index.js +2 -0
  40. package/dist/foundations/calendar.d.ts +3 -1
  41. package/dist/foundations/catalog.d.ts +27 -0
  42. package/dist/foundations/catalog.js +48 -0
  43. package/dist/foundations/copy.d.ts +40 -62
  44. package/dist/foundations/copy.es.js +10 -0
  45. package/dist/foundations/copy.js +25 -93
  46. package/dist/{primitives/field/context.d.ts → foundations/form.d.ts} +9 -0
  47. package/dist/{primitives/field/mode.js → foundations/form.js} +11 -0
  48. package/dist/foundations/grouping.d.ts +9 -3
  49. package/dist/foundations/index.d.ts +2 -0
  50. package/dist/foundations/index.js +5 -0
  51. package/dist/foundations/sticky.d.ts +4 -0
  52. package/dist/foundations/sticky.js +1 -0
  53. package/dist/primitives/button/Button.d.ts +12 -2
  54. package/dist/primitives/button/Button.js +12 -2
  55. package/dist/primitives/cell/CellStack.js +1 -1
  56. package/dist/primitives/choice/ComboBox.d.ts +1 -1
  57. package/dist/primitives/choice/ComboBox.js +4 -4
  58. package/dist/primitives/choice/RadioGroup.d.ts +1 -1
  59. package/dist/primitives/choice/RadioGroup.js +3 -3
  60. package/dist/primitives/choice/SearchField.js +1 -1
  61. package/dist/primitives/choice/SegmentedControl.d.ts +1 -1
  62. package/dist/primitives/choice/SegmentedControl.js +2 -2
  63. package/dist/primitives/choice/Slider.d.ts +1 -1
  64. package/dist/primitives/choice/Slider.js +2 -2
  65. package/dist/primitives/choice/Switch.d.ts +1 -1
  66. package/dist/primitives/choice/Switch.js +2 -2
  67. package/dist/primitives/date/Calendar.js +1 -1
  68. package/dist/primitives/date/DatePicker.d.ts +1 -1
  69. package/dist/primitives/date/DatePicker.js +2 -2
  70. package/dist/primitives/date/DateTime.d.ts +1 -1
  71. package/dist/primitives/date/DateTime.js +2 -2
  72. package/dist/primitives/field/Checks.d.ts +1 -1
  73. package/dist/primitives/field/Checks.js +4 -4
  74. package/dist/primitives/field/DropZone.d.ts +1 -1
  75. package/dist/primitives/field/DropZone.js +2 -2
  76. package/dist/primitives/field/Field.d.ts +10 -5
  77. package/dist/primitives/field/Field.js +10 -6
  78. package/dist/primitives/field/Suggest.d.ts +1 -1
  79. package/dist/primitives/field/Suggest.js +2 -2
  80. package/dist/primitives/field/index.d.ts +1 -3
  81. package/dist/primitives/field/index.js +4 -4
  82. package/dist/primitives/notice/Notice.js +3 -1
  83. package/dist/primitives/picker/Picker.d.ts +1 -1
  84. package/dist/primitives/picker/Picker.js +4 -4
  85. package/dist/primitives/{tag → picker}/TagsField.d.ts +2 -2
  86. package/dist/primitives/{tag → picker}/TagsField.js +5 -5
  87. package/dist/primitives/picker/index.d.ts +2 -0
  88. package/dist/primitives/picker/index.js +5 -1
  89. package/dist/primitives/table/CellCopy.js +1 -1
  90. package/dist/primitives/table/CellEditor.js +2 -4
  91. package/dist/primitives/table/DataTable.d.ts +11 -1
  92. package/dist/primitives/table/DataTable.js +111 -38
  93. package/dist/primitives/table/EditableTable.js +6 -2
  94. package/dist/primitives/table/PivotTable.js +0 -0
  95. package/dist/primitives/table/ReportTable.js +4 -3
  96. package/dist/primitives/table/SheetTable.js +8 -14
  97. package/dist/primitives/table/Table.js +2 -2
  98. package/dist/primitives/tabs/Tabs.d.ts +22 -4
  99. package/dist/primitives/tabs/Tabs.js +77 -23
  100. package/dist/primitives/tabs/index.d.ts +1 -1
  101. package/dist/primitives/tabs/index.js +1 -1
  102. package/dist/primitives/tag/Tags.js +4 -1
  103. package/dist/primitives/tag/index.d.ts +0 -2
  104. package/dist/primitives/tag/index.js +0 -3
  105. package/dist/primitives/value/Avatar.js +8 -1
  106. package/dist/primitives/value/{Fecha.d.ts → DateValue.d.ts} +5 -2
  107. package/dist/primitives/value/{Fecha.js → DateValue.js} +9 -8
  108. package/dist/primitives/value/Relation.d.ts +1 -1
  109. package/dist/primitives/value/Relation.js +2 -2
  110. package/dist/primitives/value/Value.js +4 -1
  111. package/dist/primitives/value/index.d.ts +1 -1
  112. package/dist/primitives/value/index.js +1 -1
  113. package/dist/tokens.css +42 -0
  114. package/dist/views/list/List.d.ts +22 -2
  115. package/dist/views/list/List.js +28 -6
  116. package/dist/views/list/ListView.d.ts +1 -1
  117. package/dist/views/list/ListView.js +47 -16
  118. package/dist/views/list/index.d.ts +2 -2
  119. package/dist/views/list/presentations.js +3 -1
  120. package/dist/views/list/types.d.ts +57 -1
  121. package/dist/views/list/useNarrowList.d.ts +2 -2
  122. package/dist/views/list/useNarrowList.js +5 -5
  123. package/dist/views/record/Record.js +46 -42
  124. package/package.json +1 -1
  125. package/dist/primitives/field/context.js +0 -13
  126. package/dist/primitives/field/mode.d.ts +0 -9
  127. /package/dist/primitives/field/{copy.d.ts → clipboard.d.ts} +0 -0
  128. /package/dist/primitives/field/{copy.js → clipboard.js} +0 -0
  129. /package/dist/primitives/{tag/choices.d.ts → picker/tagChoices.d.ts} +0 -0
  130. /package/dist/primitives/{tag/choices.js → picker/tagChoices.js} +0 -0
@@ -1,22 +1,5 @@
1
- import type { CriteriaJoin } from "./criteria.js";
1
+ import type { BuiltFilter, CriteriaJoin } from "./criteria.js";
2
2
  import { type ModelDefinition } from "./model.js";
3
- /** Una condición, tal como la declara el motor. `operator` sale del `ops` del campo, así que
4
- * lo que el constructor ofrece es exactamente lo que el modelo acepta. */
5
- export interface BuiltCondition {
6
- field: string;
7
- operator: string;
8
- value: unknown;
9
- }
10
- /** Lo que el filtro puede ser en su forma real: una condición, un grupo `all`/`any` de más
11
- * filtros —lo que el árbol arma, a cualquier profundidad—, o un `negate` que llegó de otro
12
- * lado y que el árbol no fabrica, pero que este componente sabe mostrar y editar como JSON. */
13
- export type BuiltFilter = BuiltCondition | {
14
- all: BuiltFilter[];
15
- } | {
16
- any: BuiltFilter[];
17
- } | {
18
- negate: BuiltFilter;
19
- };
20
3
  /** Si esto es una de las formas que el filtro reconoce — condición, o grupo `all`/`any`/
21
4
  * `negate`. Un JSON válido que no es ninguna de éstas —`{}`, `{"foo": 1}`, un array suelto—
22
5
  * no es un filtro que este componente sepa dibujar, y se trata como error de forma en vez de
@@ -355,13 +355,13 @@ export function WhereTree({ model, group, onChange, depth = 0, focus, }) {
355
355
  ? (node) => node?.scrollIntoView({ block: "nearest" })
356
356
  : undefined, className: `flex min-w-0 flex-wrap items-center gap-2 rounded-control ${focus !== undefined && child.field === focus
357
357
  ? "-m-1 p-1 ring-1 ring-primary/60"
358
- : ""}`, children: [_jsx("select", { value: child.field, onChange: (event) => setChild(index, {
358
+ : ""}`, children: [_jsx("select", { "aria-label": copy.builder.ruleField, value: child.field, onChange: (event) => setChild(index, {
359
359
  ...child,
360
360
  field: event.target.value,
361
361
  operator: model.fields[event.target.value]?.ops.includes(child.operator)
362
362
  ? child.operator
363
363
  : (model.fields[event.target.value]?.ops[0] ?? "="),
364
- }), className: `min-w-0 flex-1 basis-40 rounded-control border px-2 py-1 text-meta ${box}`, children: names.map((name) => (_jsx("option", { value: name, children: name }, name))) }), _jsx("select", { value: child.operator, onChange: (event) => setChild(index, { ...child, operator: event.target.value }), className: `min-w-0 flex-1 basis-36 rounded-control border px-2 py-1 text-meta ${box}`, children: (model.fields[child.field]?.ops ?? []).map((operator) => (_jsx("option", { value: operator, children: opLabel(operator) }, operator))) }), WITHOUT_VALUE.has(child.operator) ? (_jsx("span", { className: "flex-1 text-meta text-muted", children: copy.builder.noValue })) : WITH_RANGE.has(child.operator) ? (_jsxs("span", { className: "flex min-w-0 flex-1 basis-40 items-center gap-1", children: [_jsx("input", { type: inputType(model.fields[child.field]?.type ?? "text"), value: child.value, onChange: (event) => setChild(index, { ...child, value: event.target.value }), placeholder: copy.builder.from, className: `min-w-0 flex-1 rounded-control border px-2 py-1 text-meta ${box}` }), _jsx("span", { className: "shrink-0 text-micro text-muted", children: copy.search.and }), _jsx("input", { type: inputType(model.fields[child.field]?.type ?? "text"), value: child.upper ?? "", onChange: (event) => setChild(index, { ...child, upper: event.target.value }), placeholder: copy.builder.to, className: `min-w-0 flex-1 rounded-control border px-2 py-1 text-meta ${box}` })] })) : (_jsx("input", { type: inputType(model.fields[child.field]?.type ?? "text"), value: child.value, onChange: (event) => setChild(index, { ...child, value: event.target.value }), placeholder: copy.builder.value, className: `min-w-0 flex-1 basis-40 rounded-control border px-2 py-1 text-meta ${box}` })), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": copy.builder.addRule, onClick: () => addConditionAfter(index), className: "text-muted hover:text-ink", children: _jsx(Plus, { className: "size-4" }) }), _jsx("button", { type: "button", "aria-label": copy.builder.addNested, onClick: () => addGroupAfter(index), className: "text-muted hover:text-ink", children: _jsx(FolderTree, { className: "size-4" }) }), _jsx("button", { type: "button", "aria-label": copy.builder.dropRule, disabled: group.children.length === 1, onClick: () => removeAt(index), className: "text-muted disabled:opacity-30", children: _jsx(Trash2, { className: "size-4" }) })] })] }, index)))] }));
364
+ }), className: `min-w-0 flex-1 basis-40 rounded-control border px-2 py-1 text-meta ${box}`, children: names.map((name) => (_jsx("option", { value: name, children: name }, name))) }), _jsx("select", { "aria-label": copy.builder.ruleOperator, value: child.operator, onChange: (event) => setChild(index, { ...child, operator: event.target.value }), className: `min-w-0 flex-1 basis-36 rounded-control border px-2 py-1 text-meta ${box}`, children: (model.fields[child.field]?.ops ?? []).map((operator) => (_jsx("option", { value: operator, children: opLabel(operator) }, operator))) }), WITHOUT_VALUE.has(child.operator) ? (_jsx("span", { className: "flex-1 text-meta text-muted", children: copy.builder.noValue })) : WITH_RANGE.has(child.operator) ? (_jsxs("span", { className: "flex min-w-0 flex-1 basis-40 items-center gap-1", children: [_jsx("input", { type: inputType(model.fields[child.field]?.type ?? "text"), value: child.value, onChange: (event) => setChild(index, { ...child, value: event.target.value }), placeholder: copy.builder.from, className: `min-w-0 flex-1 rounded-control border px-2 py-1 text-meta ${box}` }), _jsx("span", { className: "shrink-0 text-micro text-muted", children: copy.search.and }), _jsx("input", { type: inputType(model.fields[child.field]?.type ?? "text"), value: child.upper ?? "", onChange: (event) => setChild(index, { ...child, upper: event.target.value }), placeholder: copy.builder.to, className: `min-w-0 flex-1 rounded-control border px-2 py-1 text-meta ${box}` })] })) : (_jsx("input", { type: inputType(model.fields[child.field]?.type ?? "text"), value: child.value, onChange: (event) => setChild(index, { ...child, value: event.target.value }), placeholder: copy.builder.value, className: `min-w-0 flex-1 basis-40 rounded-control border px-2 py-1 text-meta ${box}` })), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": copy.builder.addRule, onClick: () => addConditionAfter(index), className: "text-muted hover:text-ink", children: _jsx(Plus, { className: "size-4" }) }), _jsx("button", { type: "button", "aria-label": copy.builder.addNested, onClick: () => addGroupAfter(index), className: "text-muted hover:text-ink", children: _jsx(FolderTree, { className: "size-4" }) }), _jsx("button", { type: "button", "aria-label": copy.builder.dropRule, disabled: group.children.length === 1, onClick: () => removeAt(index), className: "text-muted disabled:opacity-30", children: _jsx(Trash2, { className: "size-4" }) })] })] }, index)))] }));
365
365
  }
366
366
  export function FilterBuilder({ model, initial, onApply, onCancel, }) {
367
367
  const names = filterableNames(model);
@@ -400,11 +400,11 @@ export function FilterBuilder({ model, initial, onApply, onCancel, }) {
400
400
  parsed = JSON.parse(text);
401
401
  }
402
402
  catch {
403
- setRawError(currentCopy().builder.invalidJson);
403
+ setRawError(copy.builder.invalidJson);
404
404
  return;
405
405
  }
406
406
  if (!isRecognizedFilter(parsed)) {
407
- setRawError(currentCopy().builder.notFilter);
407
+ setRawError(copy.builder.notFilter);
408
408
  return;
409
409
  }
410
410
  setRawError(null);
@@ -8,6 +8,7 @@ import { useTheme } from "../../foundations/theme.js";
8
8
  import { Button } from "../../primitives/button/index.js";
9
9
  import CodeMirror, { EditorView, Prec } from "@uiw/react-codemirror";
10
10
  import { Wand2 } from "lucide-react";
11
+ import { useId } from "react";
11
12
  /* ====================================================================== *
12
13
  * UN JSON QUE SE ESCRIBE A MANO, EN UN EDITOR DE VERDAD.
13
14
  *
@@ -56,11 +57,14 @@ const SURFACE_THEME = EditorView.theme({
56
57
  });
57
58
  export function JsonEditor({ value, onChange, error, height = "16rem", label, }) {
58
59
  const copy = useUiCopy();
60
+ const labelId = useId();
59
61
  const surface = useSurface();
60
62
  const { resolved } = useTheme();
61
63
  /* The chrome is dark in both themes; the canvas follows the theme. */
62
64
  const dark = surface === "chrome" || resolved === "dark";
63
- return (_jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("p", { className: "text-micro text-muted", children: label ?? copy.builder.editor }), _jsxs(Button, { role: "ghost", size: "sm", disabled: value.trim() === "", onClick: () => onChange(formatJson(value)), children: [_jsx(Wand2, { className: "size-3" }), " ", copy.builder.format] })] }), _jsx("div", { className: `overflow-hidden rounded-control border ${error == null ? "border-ink/15" : "border-danger"}`, children: _jsx(CodeMirror, { value: value, onChange: onChange, height: height, theme: dark ? "dark" : "light", extensions: [
65
+ return (_jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("p", { id: labelId, className: "text-micro text-muted", children: label ?? copy.builder.editor }), _jsxs(Button, { role: "ghost", size: "sm", disabled: value.trim() === "", onClick: () => onChange(formatJson(value)), children: [_jsx(Wand2, { className: "size-3" }), " ", copy.builder.format] })] }), _jsx("div", { className: `overflow-hidden rounded-control border ${error == null ? "border-ink/15" : "border-danger"}`, children: _jsx(CodeMirror, { value: value, onChange: onChange, height: height, theme: dark ? "dark" : "light", extensions: [
66
+ /* The editable area is CodeMirror's own element: named here or nowhere. */
67
+ EditorView.contentAttributes.of({ "aria-labelledby": labelId }),
64
68
  json(),
65
69
  linter(jsonParseLinter()),
66
70
  lintGutter(),
@@ -1,5 +1,4 @@
1
- import { type CriteriaJoin, type CriteriaPath, type SearchCriteria } from "./criteria.js";
2
- import type { BuiltCondition, BuiltFilter } from "./FilterBuilder.js";
1
+ import { type BuiltCondition, type BuiltFilter, type CriteriaJoin, type CriteriaPath, type SearchCriteria } from "./criteria.js";
3
2
  import type { ModelDefinition } from "./model.js";
4
3
  import type { Surface } from "../../foundations/types.js";
5
4
  import type { MenuGroup } from "../../primitives/menu/Menu.js";
@@ -74,6 +74,11 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
74
74
  const input = useRef(null);
75
75
  const box = useRef(null);
76
76
  const id = useId();
77
+ /* The suggestions are the input's popup (the ARIA combobox pattern): the input keeps the
78
+ focus and says which list it controls and which option is the active one, so a screen
79
+ reader announces «Nombre del cliente · contiene Globex» as the option it is. */
80
+ const listId = useId();
81
+ const optionId = (at) => `${listId}-${at}`;
77
82
  /**
78
83
  * Los campos por los que se puede preguntar.
79
84
  *
@@ -290,7 +295,9 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
290
295
  removeLabel: copy.search.dropOp,
291
296
  },
292
297
  ]),
293
- ] }), _jsx("input", { ref: input, "aria-label": copy.choice.search, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
298
+ ] }), _jsx("input", { ref: input, "aria-label": copy.choice.search, role: "combobox", "aria-autocomplete": "list", "aria-expanded": open, ...(open ? { "aria-controls": listId } : {}), ...(open && !asTags && suggestions[active] !== undefined
299
+ ? { "aria-activedescendant": optionId(active) }
300
+ : {}), value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
294
301
  /* Retroceso con el campo vacío saca la ÚLTIMA instancia: el gesto
295
302
  que ya existe en cualquier campo de fichas y el único que se
296
303
  descubre sin que nadie lo explique. La última y no la última de
@@ -392,7 +399,7 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
392
399
  /* UNA LISTA, no una tira. Se está ELIGIENDO: el ojo baja por una
393
400
  columna y el teclado también. Doce atributos doblados en tres
394
401
  renglones son doce cosas que barrer en zigzag para encontrar una. */
395
- _jsx("div", { className: "max-h-72 overflow-y-auto p-1", children: _jsx(Tags, { stack: true, surface: "canvas", label: step.field !== undefined ? copy.search.askOp : copy.search.askAttribute, items: suggestions.map((one, at) => ({
402
+ _jsx("div", { id: listId, className: "max-h-72 overflow-y-auto p-1", children: _jsx(Tags, { stack: true, surface: "canvas", label: step.field !== undefined ? copy.search.askOp : copy.search.askAttribute, items: suggestions.map((one, at) => ({
396
403
  id: one.id,
397
404
  label: one.label,
398
405
  ...(one.kind === "op"
@@ -402,7 +409,9 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
402
409
  : {}),
403
410
  active: at === active,
404
411
  onSelect: () => pick(one),
405
- })) }) })) : (_jsx("div", { className: "p-1", children: suggestions.map((one, at) => (_jsxs("button", { type: "button", onClick: () => pick(one), onMouseEnter: () => setActive(at), className: `flex w-full items-baseline gap-1.5 rounded-control px-2 py-1.5 text-left text-meta transition-colors duration-(--duration-instant) ${at === active ? "bg-primary/8" : "hover:bg-ink/4"}`, children: [_jsx("span", { className: "shrink-0 font-medium text-ink", children: one.label }), one.kind === "condition" || one.kind === "literal" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "shrink-0 text-muted", children: ":" }), _jsx("span", { className: "min-w-0 truncate font-mono text-micro text-ink", children: one.hint })] })) : one.kind === "field" && one.hint !== undefined ? (_jsx("span", { className: "inline-flex shrink-0 items-center rounded-pill border border-line-strong bg-ink/5 px-2 py-0.5 text-micro text-muted", children: one.hint })) : null] }, one.id))) }))] })) : null, panel && (columns.length > 0 || onOpenBuilder !== undefined) ? (_jsxs("div", { id: id, className: "absolute top-full right-0 z-30 mt-1 flex w-max max-w-full flex-wrap overflow-hidden rounded-panel border border-line-strong bg-surface shadow-float", children: [columns.map((c, i) => (_jsxs("div", { className: `flex min-w-48 flex-1 flex-col ${i === 0 ? "" : "border-t border-line sm:border-t-0 sm:border-l"}`, children: [_jsx("div", { className: "border-b border-line px-2.5 py-1.5 text-micro font-medium text-muted", children: c.title }), _jsx("div", { className: "flex flex-col gap-0.5 p-1", children: c.content ??
412
+ })) }) })) : (_jsx("div", { id: listId, role: "listbox", "aria-label": copy.search.suggestions, className: "p-1", children: suggestions.map((one, at) => (_jsxs("div", { id: optionId(at), role: "option", "aria-selected": at === active,
413
+ /* The input keeps the focus: pressing an option must not take it. */
414
+ onMouseDown: (event) => event.preventDefault(), onClick: () => pick(one), onMouseEnter: () => setActive(at), className: `flex w-full cursor-pointer items-baseline gap-1.5 rounded-control px-2 py-1.5 text-left text-meta transition-colors duration-(--duration-instant) ${at === active ? "bg-primary/8" : "hover:bg-ink/4"}`, children: [_jsx("span", { className: "shrink-0 font-medium text-ink", children: one.label }), one.kind === "condition" || one.kind === "literal" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "shrink-0 text-muted", children: ":" }), _jsx("span", { className: "min-w-0 truncate font-mono text-micro text-ink", children: one.hint })] })) : one.kind === "field" && one.hint !== undefined ? (_jsx("span", { className: "inline-flex shrink-0 items-center rounded-pill border border-line-strong bg-ink/5 px-2 py-0.5 text-micro text-muted", children: one.hint })) : null] }, one.id))) }))] })) : null, panel && (columns.length > 0 || onOpenBuilder !== undefined) ? (_jsxs("div", { id: id, className: "absolute top-full right-0 z-30 mt-1 flex w-max max-w-full flex-wrap overflow-hidden rounded-panel border border-line-strong bg-surface shadow-float", children: [columns.map((c, i) => (_jsxs("div", { className: `flex min-w-48 flex-1 flex-col ${i === 0 ? "" : "border-t border-line sm:border-t-0 sm:border-l"}`, children: [_jsx("div", { className: "border-b border-line px-2.5 py-1.5 text-micro font-medium text-muted", children: c.title }), _jsx("div", { className: "flex flex-col gap-0.5 p-1", children: c.content ??
406
415
  c.groups?.map((g, gi) => (_jsx(Group, { group: g, first: gi === 0, onSelect: c.onSelect }, g.label ?? gi))) })] }, c.title))), onOpenBuilder === undefined ? null : (_jsx("div", { className: "w-full border-t border-line p-1", children: _jsxs("button", { type: "button", onClick: () => {
407
416
  onOpenBuilder();
408
417
  setPanel(false);
@@ -1,5 +1,21 @@
1
- import type { BuiltCondition, BuiltFilter } from "./FilterBuilder.js";
2
1
  import type { GroupGrain, GroupLevel } from "../../foundations/grouping.js";
2
+ /** Una condición, tal como la declara el motor. `operator` sale del `ops` del campo, así que
3
+ * lo que el constructor ofrece es exactamente lo que el modelo acepta. */
4
+ export interface BuiltCondition {
5
+ field: string;
6
+ operator: string;
7
+ value: unknown;
8
+ }
9
+ /** Lo que el filtro puede ser en su forma real: una condición, un grupo `all`/`any` de más
10
+ * filtros —lo que el árbol arma, a cualquier profundidad—, o un `negate` que llegó de otro
11
+ * lado y que el árbol no fabrica, pero que este componente sabe mostrar y editar como JSON. */
12
+ export type BuiltFilter = BuiltCondition | {
13
+ all: BuiltFilter[];
14
+ } | {
15
+ any: BuiltFilter[];
16
+ } | {
17
+ negate: BuiltFilter;
18
+ };
3
19
  /** Cómo se juntan las instancias de un nodo. `not` niega lo que tiene adentro. */
4
20
  export type CriteriaJoin = "all" | "any" | "not";
5
21
  /**
@@ -1,5 +1,6 @@
1
+ export { type BuiltCondition, type BuiltFilter, } from "./criteria.js";
1
2
  export { type BuiltCriteria, type BuiltLevel, type BuiltSort, CriteriaBuilder, } from "./CriteriaBuilder.js";
2
- export { blankGroup, type BuiltCondition, type BuiltFilter, describeFilter, FilterBuilder, type TreeCondition, treeFromFilter, type TreeGroup, type TreeNode, treeToFilter, } from "./FilterBuilder.js";
3
+ export { blankGroup, describeFilter, FilterBuilder, type TreeCondition, treeFromFilter, type TreeGroup, type TreeNode, treeToFilter, } from "./FilterBuilder.js";
3
4
  export { formatJson, JsonEditor } from "./JsonEditor.js";
4
5
  export { SearchBar, type SearchBarProps, type SearchFacet, } from "./SearchBar.js";
5
6
  export { at, criteriaId, type CriteriaJoin, type CriteriaPath, filterCriteria, filtersOf, type GroupCriteria, groupCriteria, groupsOf, hasNamed, levelsOf, nextJoin, removeAt, replaceAt, type SearchCriteria, toggleNamed, whereOf, withLevels, } from "./criteria.js";
@@ -1,4 +1,5 @@
1
1
  /* Buscar con facetas, con los recortes metidos adentro del campo. */
2
+ export {} from "./criteria.js";
2
3
  export { CriteriaBuilder, } from "./CriteriaBuilder.js";
3
4
  export { blankGroup, describeFilter, FilterBuilder, treeFromFilter, treeToFilter, } from "./FilterBuilder.js";
4
5
  export { formatJson, JsonEditor } from "./JsonEditor.js";
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from "react";
2
+ declare const BASIS: {
3
+ readonly narrow: "@4xl:flex-2";
4
+ readonly wide: "@4xl:flex-3";
5
+ };
6
+ /** One column of the screen. `narrow` for the set you pick from, `wide` for the work. */
7
+ export declare function Pane({ children, size, label, }: {
8
+ children: ReactNode;
9
+ size?: keyof typeof BASIS;
10
+ /** What this pane is — «Extracto», «Por conciliar». Panes are regions a screen reader
11
+ * jumps between, so each one is named. */
12
+ label: string;
13
+ }): import("react").JSX.Element;
14
+ /** The panes, side by side on a desk and stacked when narrow. Fills the height it is given. */
15
+ export declare function Panes({ children }: {
16
+ children: ReactNode;
17
+ }): import("react").JSX.Element;
18
+ export {};
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { HeaderSlotContext } from "./headerSlot.js";
4
+ import { stickyReset } from "../../foundations/sticky.js";
5
+ /* ==================================================================== *
6
+ * PANES — a screen of N sets side by side, each one with its own search.
7
+ *
8
+ * Context: Odoo's bank reconciliation is the case — the statement lines on one side, the
9
+ * record being matched and the journal items that could match it on the other, each with
10
+ * its own search, and the person going back and forth between them. A list that fills the
11
+ * page carries its bar up to the screen's header (`useHeaderSlot`), and there is ONE
12
+ * header: with two or three lists they fought over it, and each bar stuck to the page
13
+ * instead of to its own column.
14
+ *
15
+ * A pane is the answer: on a desk each pane SCROLLS ON ITS OWN — a long list on the left
16
+ * does not drag the record on the right out of sight — so what sticks inside it (a list's
17
+ * bar, a table's header) sticks to the pane's edge, and nothing inside goes up to the
18
+ * screen's header. Narrow, the panes stack and the page scrolls: a scroll inside a scroll
19
+ * on a phone is a list nobody can leave.
20
+ *
21
+ * It decides by ITS OWN width (it is the `@container`), not the window's: a workspace panel
22
+ * among four is narrow on a wide screen, and a view mounted without a container around it
23
+ * would never go side by side.
24
+ * ==================================================================== */
25
+ const BASIS = { narrow: "@4xl:flex-2", wide: "@4xl:flex-3" };
26
+ /** One column of the screen. `narrow` for the set you pick from, `wide` for the work. */
27
+ export function Pane({ children, size = "wide", label, }) {
28
+ return (_jsx(HeaderSlotContext.Provider, { value: null, children: _jsx("section", { "aria-label": label, style: stickyReset(),
29
+ /* Stacked it keeps its own height (the page scrolls); side by side it shares the
30
+ height and scrolls inside. */
31
+ className: `flex min-h-0 min-w-0 shrink-0 flex-col @4xl:shrink @4xl:basis-0 @4xl:overflow-y-auto ${BASIS[size]}`, children: children }) }));
32
+ }
33
+ /** The panes, side by side on a desk and stacked when narrow. Fills the height it is given. */
34
+ export function Panes({ children }) {
35
+ return (_jsx("div", { className: "@container flex h-full min-h-0 min-w-0 flex-1 flex-col", children: _jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col divide-line @4xl:flex-row @4xl:divide-x max-@4xl:divide-y", children: children }) }));
36
+ }
@@ -1,2 +1,3 @@
1
1
  export { AppFrame } from "./AppFrame.js";
2
2
  export { type AppEntry, SystrayButton, TopBar, type TopMenu, } from "./TopBar.js";
3
+ export { Pane, Panes } from "./Panes.js";
@@ -1,3 +1,5 @@
1
1
  /* EL MARCO DE LA APLICACIÓN — lo que está arriba de toda vista. */
2
2
  export { AppFrame } from "./AppFrame.js";
3
3
  export { SystrayButton, TopBar, } from "./TopBar.js";
4
+ /* A screen of N sets side by side — the reconciliation: each pane scrolls on its own. */
5
+ export { Pane, Panes } from "./Panes.js";
@@ -1,4 +1,6 @@
1
- import type { GroupGrain } from "./grouping.js";
1
+ /** El grano de un nivel de fecha. Sin grano, un campo de fecha parte por
2
+ * instante y cada fila termina en su propio montón, que es no agrupar. */
3
+ export type GroupGrain = "day" | "week" | "month" | "quarter" | "year";
2
4
  /**
3
5
  * Una fecha recortada a la CLAVE de su grano.
4
6
  *
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from "react";
2
+ /** What changes, at any depth. A word is replaced; a section is merged. */
3
+ export type CopyOverride<C> = {
4
+ [K in keyof C]?: C[K] extends string ? string : C[K] extends readonly unknown[] ? C[K] : C[K] extends object ? CopyOverride<C[K]> : C[K];
5
+ };
6
+ /** `base` with `over` on top: sections merge, everything else is replaced. */
7
+ export declare function mergeCopyOver<C extends object>(base: C, over: CopyOverride<C> | undefined): C;
8
+ export type CopyCatalog<C extends object> = {
9
+ /** The factory words: what is there before anything is installed. */
10
+ english: C;
11
+ /** Sets the session's words — English with `over` on top — and returns them. */
12
+ install: (over?: CopyOverride<C>) => C;
13
+ /** The installed words: for what is not a component. */
14
+ current: () => C;
15
+ /** English with `over` on top, without installing it. */
16
+ merge: (over?: CopyOverride<C>) => C;
17
+ /** Changes the words for its branch, on top of what encloses it. */
18
+ Provider: (props: {
19
+ copy?: CopyOverride<C>;
20
+ children: ReactNode;
21
+ }) => ReactNode;
22
+ /** The words of this branch: the nearest `Provider`'s, or the installed ones. */
23
+ useCopy: () => C;
24
+ };
25
+ export declare function createCopy<C extends object>(english: C): CopyCatalog<C>;
26
+ /** `{name}` → value. A missing key stays visible in the template. */
27
+ export declare function fillCopy(template: string, params: Record<string, string | number>): string;
@@ -0,0 +1,48 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useMemo } from "react";
4
+ const isSection = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5
+ /** `base` with `over` on top: sections merge, everything else is replaced. */
6
+ export function mergeCopyOver(base, over) {
7
+ if (over === undefined)
8
+ return base;
9
+ const out = { ...base };
10
+ for (const [key, value] of Object.entries(over)) {
11
+ if (value === undefined)
12
+ continue;
13
+ const under = out[key];
14
+ out[key] = isSection(under) && isSection(value) ? mergeCopyOver(under, value) : value;
15
+ }
16
+ return out;
17
+ }
18
+ export function createCopy(english) {
19
+ let installed = english;
20
+ /* `null` and not English: a context keeps the value it was created with, so with the
21
+ factory words inside it a component without a provider would ignore `install`. */
22
+ const Context = createContext(null);
23
+ const current = () => installed;
24
+ const useCopy = () => useContext(Context) ?? installed;
25
+ function Provider({ copy, children }) {
26
+ const enclosing = useCopy();
27
+ const merged = useMemo(() => mergeCopyOver(enclosing, copy), [enclosing, copy]);
28
+ return _jsx(Context.Provider, { value: merged, children: children });
29
+ }
30
+ return {
31
+ english,
32
+ install: (over) => {
33
+ installed = mergeCopyOver(english, over);
34
+ return installed;
35
+ },
36
+ current,
37
+ merge: (over) => mergeCopyOver(english, over),
38
+ Provider,
39
+ useCopy,
40
+ };
41
+ }
42
+ /** `{name}` → value. A missing key stays visible in the template. */
43
+ export function fillCopy(template, params) {
44
+ return template.replace(/\{(\w+)\}/g, (whole, name) => {
45
+ const value = params[name];
46
+ return value === undefined ? whole : String(value);
47
+ });
48
+ }
@@ -1,18 +1,20 @@
1
- import { type ReactNode } from "react";
1
+ import { type CopyOverride, fillCopy } from "./catalog.js";
2
+ import type { ReactNode } from "react";
2
3
  /**
3
4
  * Chrome copy for the design system.
4
5
  *
5
6
  * The handbook rule: code is English; language is chosen at render. This
6
7
  * package must not bake product Spanish into buttons. English is the default
7
- * so a story still reads; the app overlays the session locale via
8
- * `UiCopyProvider`.
8
+ * so a story still reads; the app installs the session's language once
9
+ * (`installCopy(SPANISH_COPY)`), and `UiCopyProvider` changes a branch.
9
10
  *
10
11
  * Domain sentences (empty catalog, upload hint) stay out — those are the
11
12
  * product's catalogs, passed as props.
12
13
  *
13
- * `locale` is also a module store so `formatCount` — called from modules that
14
- * are not components — tracks the same BCP-47 the provider just set. A React
15
- * context cannot be read from `shared/format.ts`.
14
+ * The installed words are also what is not a component reads (`currentCopy`,
15
+ * `currentCopyLocale`): a sentence built in a function, a month named by the
16
+ * calendar. A provider does not change those — it is for a branch, not for the
17
+ * session (see `catalog.tsx`).
16
18
  */
17
19
  export type UiCopy = {
18
20
  /** BCP-47 for `toLocaleString` and `Intl`. */
@@ -68,8 +70,13 @@ export type UiCopy = {
68
70
  faces: string;
69
71
  /** The mark of a record with changes that are not saved yet. */
70
72
  unsaved: string;
73
+ /** The same mark when the record says how many fields changed. */
74
+ unsavedOne: string;
75
+ unsavedMany: string;
71
76
  save: string;
72
77
  saving: string;
78
+ /** Said, briefly, once what was saved is saved. */
79
+ saved: string;
73
80
  discard: string;
74
81
  /** Read aloud next to a stage that failed: the red fill alone says nothing to a
75
82
  * screen reader. */
@@ -113,6 +120,8 @@ export type UiCopy = {
113
120
  unset: string;
114
121
  /** A list of checks read with nothing chosen. */
115
122
  none: string;
123
+ /** Next to the label of a field whose value changed and is not saved yet. */
124
+ changed: string;
116
125
  };
117
126
  /** The choice controls: combo boxes, selects, suggestion lists. */
118
127
  choice: {
@@ -125,6 +134,8 @@ export type UiCopy = {
125
134
  };
126
135
  search: {
127
136
  tools: string;
137
+ /** The name of the list of what the bar offers for what was typed. */
138
+ suggestions: string;
128
139
  /** The name of the panel button once something is set: it shows the count, and what
129
140
  * the button SHOWS has to be inside its name (WCAG 2.5.3) — `{label}` is `tools`. */
130
141
  toolsSetOne: string;
@@ -226,6 +237,8 @@ export type UiCopy = {
226
237
  min: string;
227
238
  max: string;
228
239
  distinct: string;
240
+ /** Under a pile that holds its first page and counts them all: ask for the rest. */
241
+ pileMore: string;
229
242
  };
230
243
  menu: {
231
244
  empty: string;
@@ -255,6 +268,8 @@ export type UiCopy = {
255
268
  * record itself. */
256
269
  peek: string;
257
270
  open: string;
271
+ /** Asked again while the rows stay: the name of the discreet bar over them. */
272
+ refreshing: string;
258
273
  };
259
274
  /** La historia de un registro: eventos y mensajes. */
260
275
  /** The panel of everything around a record: its notices, its conversation and its
@@ -365,6 +380,8 @@ export type UiCopy = {
365
380
  weekOfYear: string;
366
381
  quarter: string;
367
382
  quarterOfYear: string;
383
+ /** The calendar's «go to today» button: its word and its name are the same. */
384
+ today: string;
368
385
  };
369
386
  /**
370
387
  * CÓMO SE LLAMA UN OPERADOR para quien lo lee.
@@ -503,6 +520,9 @@ export type UiCopy = {
503
520
  pilePageNote: string;
504
521
  limitPiles: string;
505
522
  /** El árbol de condiciones. */
523
+ /** The names of a rule's two choices — the field it asks about, and how it compares. */
524
+ ruleField: string;
525
+ ruleOperator: string;
506
526
  noValue: string;
507
527
  from: string;
508
528
  to: string;
@@ -741,63 +761,21 @@ export type UiCopy = {
741
761
  };
742
762
  };
743
763
  export declare const ENGLISH_COPY: UiCopy;
744
- /** The merged catalog the formatters read when they are not a component. */
745
- export declare function currentCopy(): UiCopy;
764
+ /** What changes of web-ui's words, at any depth: `{ search: { joins: { any: "o" } } }`. */
765
+ export type UiCopyOverride = CopyOverride<UiCopy>;
766
+ /** The installed words: for formatters and sentences that are not a component. */
767
+ export declare const currentCopy: () => UiCopy;
746
768
  /** BCP-47 the formatters read when they are not a component. */
747
769
  export declare function currentCopyLocale(): string;
748
- export type UiCopyOverride = {
749
- locale?: string;
750
- present?: Partial<UiCopy["present"]>;
751
- frame?: Partial<UiCopy["frame"]>;
752
- selection?: Partial<UiCopy["selection"]>;
753
- pager?: Partial<UiCopy["pager"]>;
754
- record?: Partial<UiCopy["record"]>;
755
- shell?: Partial<UiCopy["shell"]>;
756
- field?: Partial<UiCopy["field"]>;
757
- choice?: Partial<UiCopy["choice"]>;
758
- search?: Partial<UiCopy["search"]>;
759
- failed?: Partial<UiCopy["failed"]>;
760
- table?: Partial<UiCopy["table"]>;
761
- menu?: Partial<UiCopy["menu"]>;
762
- list?: Partial<UiCopy["list"]>;
763
- grouping?: Partial<UiCopy["grouping"]>;
764
- dates?: Partial<UiCopy["dates"]>;
765
- calendar?: Partial<UiCopy["calendar"]>;
766
- ops?: Record<string, string>;
767
- sorting?: Partial<UiCopy["sorting"]>;
768
- copyable?: Partial<UiCopy["copyable"]>;
769
- activity?: Partial<UiCopy["activity"]>;
770
- editable?: Partial<UiCopy["editable"]>;
771
- picker?: Partial<UiCopy["picker"]>;
772
- cell?: Partial<UiCopy["cell"]>;
773
- time?: Partial<UiCopy["time"]>;
774
- dialog?: Partial<UiCopy["dialog"]>;
775
- report?: Partial<UiCopy["report"]>;
776
- builder?: Partial<UiCopy["builder"]>;
777
- pivot?: Partial<UiCopy["pivot"]>;
778
- person?: Partial<UiCopy["person"]>;
779
- lines?: Partial<UiCopy["lines"]>;
780
- lineItems?: Partial<UiCopy["lineItems"]>;
781
- priority?: Partial<UiCopy["priority"]>;
782
- board?: Partial<UiCopy["board"]>;
783
- kanbanState?: Partial<UiCopy["kanbanState"]>;
784
- timeline?: Partial<UiCopy["timeline"]>;
785
- activityState?: Partial<UiCopy["activityState"]>;
786
- relation?: Partial<UiCopy["relation"]>;
787
- relationCard?: Partial<UiCopy["relationCard"]>;
788
- totals?: Partial<UiCopy["totals"]>;
789
- inlineEdit?: Partial<UiCopy["inlineEdit"]>;
790
- theme?: Partial<UiCopy["theme"]>;
791
- tree?: Partial<UiCopy["tree"]>;
792
- assist?: Partial<UiCopy["assist"]>;
793
- };
794
- export declare function mergeCopy(over?: UiCopyOverride): UiCopy;
795
- /** For tests and non-React callers that need a locale before a provider mounts. */
796
- export declare function installCopy(over?: UiCopyOverride): UiCopy;
797
- export declare function UiCopyProvider({ copy, children, }: {
770
+ /** English with `over` on top, without installing it. */
771
+ export declare const mergeCopy: (over?: UiCopyOverride) => UiCopy;
772
+ /** Sets the session's words: once, before the first draw — and the one global there is. */
773
+ export declare const installCopy: (over?: UiCopyOverride) => UiCopy;
774
+ /** Changes the words for its branch, on top of what encloses it. It never writes the
775
+ * global: a corner in another language does not change the rest of the screen. */
776
+ export declare const UiCopyProvider: (props: {
798
777
  copy?: UiCopyOverride;
799
778
  children: ReactNode;
800
- }): import("react").JSX.Element;
801
- export declare function useUiCopy(): UiCopy;
802
- /** `{name}` → value. A missing key stays visible in the template. */
803
- export declare function fillCopy(template: string, params: Record<string, string | number>): string;
779
+ }) => ReactNode;
780
+ export declare const useUiCopy: () => UiCopy;
781
+ export { fillCopy };
@@ -72,8 +72,11 @@ export const SPANISH_COPY = {
72
72
  status: "Estado",
73
73
  faces: "Caras",
74
74
  unsaved: "sin guardar",
75
+ unsavedOne: "1 cambio sin guardar",
76
+ unsavedMany: "{count} cambios sin guardar",
75
77
  save: "Guardar",
76
78
  saving: "Guardando…",
79
+ saved: "Guardado",
77
80
  discard: "Descartar",
78
81
  failed: "falló",
79
82
  notices: "Avisos sobre este registro",
@@ -106,6 +109,7 @@ export const SPANISH_COPY = {
106
109
  help: "Ayuda",
107
110
  unset: "sin definir",
108
111
  none: "Ninguna",
112
+ changed: "modificado",
109
113
  },
110
114
  choice: {
111
115
  noResults: "Sin resultados",
@@ -127,6 +131,7 @@ export const SPANISH_COPY = {
127
131
  },
128
132
  search: {
129
133
  tools: "Filtrar, agrupar y favoritos",
134
+ suggestions: "Sugerencias",
130
135
  toolsSetOne: "{label}, {count} puesto",
131
136
  toolsSetMany: "{label}, {count} puestos",
132
137
  editChip: "Clic para editar",
@@ -199,6 +204,7 @@ export const SPANISH_COPY = {
199
204
  min: "mín {value}",
200
205
  max: "máx {value}",
201
206
  distinct: "{value} distintos",
207
+ pileMore: "Ver {count} más",
202
208
  },
203
209
  menu: { empty: "Nada coincide." },
204
210
  list: {
@@ -208,6 +214,7 @@ export const SPANISH_COPY = {
208
214
  count: "{count} registros",
209
215
  peek: "Vista previa",
210
216
  open: "Abrir",
217
+ refreshing: "Actualizando la lista…",
211
218
  },
212
219
  activity: {
213
220
  bySystem: "el sistema",
@@ -264,6 +271,7 @@ export const SPANISH_COPY = {
264
271
  weekOfYear: "Semana {n} de {year}",
265
272
  quarter: "T{n}",
266
273
  quarterOfYear: "T{n} de {year}",
274
+ today: "Hoy",
267
275
  },
268
276
  grouping: {
269
277
  label: "Agrupar por",
@@ -346,6 +354,8 @@ export const SPANISH_COPY = {
346
354
  pilesNote: "Sobre lo que plegaron —los totales de arriba, y «count»— no sobre las filas: eso es el filtro de más arriba.",
347
355
  pilePageNote: "Sólo el primer nivel: pedida una página, los niveles más profundos sólo contestan por los montones que sobrevivieron.",
348
356
  limitPiles: "Limitar cuántos montones vuelven",
357
+ ruleField: "Campo",
358
+ ruleOperator: "Comparación",
349
359
  noValue: "sin valor",
350
360
  from: "desde…",
351
361
  to: "hasta…",