@sincpro/web-ui 1.0.0 → 1.1.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.
@@ -1,4 +1,5 @@
1
1
  import { type CriteriaJoin, type CriteriaPath, type SearchCriteria } from "./criteria.js";
2
+ import type { BuiltCondition, BuiltFilter } from "./FilterBuilder.js";
2
3
  import type { ModelDefinition } from "./model.js";
3
4
  import type { Surface } from "../../foundations/types.js";
4
5
  import type { MenuGroup } from "../../primitives/menu/Menu.js";
@@ -70,6 +71,16 @@ export type SearchBarProps = {
70
71
  * obligar a buscar la que uno venía mirando entre doce.
71
72
  */
72
73
  onOpenBuilder?: (path?: CriteriaPath) => void;
74
+ /**
75
+ * The set's own search — what a free text means when it is not a question about one field:
76
+ * every field the set searches, at once (sincpro_framework's `LiteralSearch`, Odoo's «Search
77
+ * for: …»). Given the typed text, it returns what the chip asks; `null` offers nothing.
78
+ *
79
+ * With it, «Buscar «Globex»» is offered for every text. It goes FIRST — the one Enter takes —
80
+ * unless the model reads the text as a question of its own (an operator, a field's name, an
81
+ * enum's value, a number a numeric field answers): then that goes first and this after it.
82
+ */
83
+ literal?: (text: string) => BuiltCondition | BuiltFilter | null;
73
84
  /** Lo que va pegado al campo, a su derecha. */
74
85
  trailing?: ReactNode;
75
86
  /** How many facets the bar shows before folding the rest into «+N»; the grouping chain
@@ -78,4 +89,4 @@ export type SearchBarProps = {
78
89
  /** Por default la hereda de la superficie que la envuelve. */
79
90
  surface?: Surface;
80
91
  };
81
- export declare function SearchBar({ facets, model, criteria, onCriteriaChange, placeholder, filters, grouping, onOpenGroup, favorites, onFilterSelect, onFavoriteSelect, onOpenBuilder, trailing, collapseAfter, surface, }: SearchBarProps): import("react").JSX.Element;
92
+ export declare function SearchBar({ facets, model, criteria, onCriteriaChange, placeholder, filters, grouping, onOpenGroup, favorites, onFilterSelect, onFavoriteSelect, onOpenBuilder, literal, trailing, collapseAfter, surface, }: SearchBarProps): import("react").JSX.Element;
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { at, criteriaId, filterCriteria, removeAt, replaceAt, } from "./criteria.js";
4
4
  import { CriteriaChips } from "./CriteriaChips.js";
5
5
  import { describeCondition, opLabel, queryFields, readTyped, suggest, withoutValue, } from "./query.js";
6
- import { useUiCopy } from "../../foundations/copy.js";
6
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
7
7
  import { bySurface, useSurface } from "../../foundations/surface.js";
8
8
  import { Tags } from "../../primitives/tag/Tags.js";
9
9
  import { ChevronRight, CornerDownRight, ListFilter, Search, SlidersHorizontal, X, } from "lucide-react";
@@ -28,7 +28,7 @@ function Group({ group, first, onSelect, }) {
28
28
  ? "flex flex-col gap-0.5 pl-(--spacing-indent)"
29
29
  : "flex flex-col gap-0.5", children: options }))] }));
30
30
  }
31
- export function SearchBar({ facets, model, criteria, onCriteriaChange, placeholder, filters, grouping, onOpenGroup, favorites, onFilterSelect, onFavoriteSelect, onOpenBuilder, trailing, collapseAfter, surface, }) {
31
+ export function SearchBar({ facets, model, criteria, onCriteriaChange, placeholder, filters, grouping, onOpenGroup, favorites, onFilterSelect, onFavoriteSelect, onOpenBuilder, literal, trailing, collapseAfter, surface, }) {
32
32
  const copy = useUiCopy();
33
33
  const sup = useSurface(surface);
34
34
  const [text, setText] = useState("");
@@ -102,7 +102,7 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
102
102
  ...(step.field === undefined ? {} : { field: step.field }),
103
103
  ...(step.op === undefined ? {} : { op: step.op }),
104
104
  text,
105
- }, fields);
105
+ }, fields, { literal: literal !== undefined });
106
106
  /* Lo escrito, ya leído: hace falta acá para saber si se está eligiendo un
107
107
  ATRIBUTO —lo que empieza con `#`— y dibujar fichas en vez de renglones. */
108
108
  const asked = readTyped(text);
@@ -155,12 +155,8 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
155
155
  * la lista si no. Es lo único que la barra decide sobre dónde van las cosas:
156
156
  * el resto lo decide quien tiene la lista.
157
157
  */
158
- function commit(field, op, value) {
159
- const one = filterCriteria(
160
- /* El que no lleva valor viaja con `true`, que es lo que el motor espera de
161
- «está vacío»: mandarle la cadena vacía sería preguntar por el campo que
162
- vale "". */
163
- { field: field.field, operator: op, value: withoutValue(op) ? true : value }, describeCondition(field, op, value));
158
+ /** A finished instance, where the next one goes: the end, or the group left open. */
159
+ function put(one) {
164
160
  onCriteriaChange(openGroup === null
165
161
  ? [...criteria, one]
166
162
  : replaceAt(criteria, openGroup, addTo(at(criteria, openGroup), one)));
@@ -168,6 +164,14 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
168
164
  setStep({});
169
165
  input.current?.focus();
170
166
  }
167
+ function commit(field, op, value) {
168
+ const one = filterCriteria(
169
+ /* El que no lleva valor viaja con `true`, que es lo que el motor espera de
170
+ «está vacío»: mandarle la cadena vacía sería preguntar por el campo que
171
+ vale "". */
172
+ { field: field.field, operator: op, value: withoutValue(op) ? true : value }, describeCondition(field, op, value));
173
+ put(one);
174
+ }
171
175
  /** Lo elegido en el desplegable, según de qué clase sea. */
172
176
  function pick(suggestion) {
173
177
  if (suggestion.kind === "field") {
@@ -199,6 +203,12 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
199
203
  input.current?.focus();
200
204
  return;
201
205
  }
206
+ if (suggestion.kind === "literal") {
207
+ const where = literal?.(suggestion.text) ?? null;
208
+ if (where !== null)
209
+ put(filterCriteria(where, fillCopy(copy.search.literalChip, { text: suggestion.text })));
210
+ return;
211
+ }
202
212
  commit(suggestion.field, suggestion.op, suggestion.value);
203
213
  }
204
214
  /** El nodo con una instancia más adentro. */
@@ -387,7 +397,7 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
387
397
  : {}),
388
398
  active: at === active,
389
399
  onSelect: () => pick(one),
390
- })) }) })) : (_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" ? (_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 ??
400
+ })) }) })) : (_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 ??
391
401
  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: () => {
392
402
  onOpenBuilder();
393
403
  setPanel(false);
@@ -122,6 +122,15 @@ export type QuerySuggestion =
122
122
  field: QueryField;
123
123
  op: string;
124
124
  value: string;
125
+ }
126
+ /** The set's own search over the text — every field it searches, at once. What it asks is
127
+ * the consumer's (`SearchBar literal`): the bar only offers it and puts the chip. */
128
+ | {
129
+ kind: "literal";
130
+ id: string;
131
+ label: string;
132
+ hint: string;
133
+ text: string;
125
134
  };
126
135
  /** Cómo se llama un operador para quien lo lee. Sale de la copia, así que un
127
136
  * operador que el servidor agregue aparece con su símbolo hasta que alguien le
@@ -139,17 +148,11 @@ export declare function withoutValue(op: string): boolean;
139
148
  * preguntan en el constructor, que tiene los dos campos.
140
149
  */
141
150
  export declare function withTwoValues(op: string): boolean;
142
- /**
143
- * Qué se ofrece ahora mismo.
144
- *
145
- * Es una sola lista y no tres pantallas: en el primer paso conviven los campos
146
- * que se llaman como lo tipeado —«nombr» → «nombre»— con las preguntas enteras
147
- * que lo tipeado ya permite armar —«test» en el nombre—. Quien escribe el nombre
148
- * de un campo quiere seguir por ahí; quien escribe un valor quiere preguntarlo
149
- * ya. Las dos intenciones se escriben igual, así que se ofrecen las dos, con los
150
- * campos primero: es el paso que todavía no decidió nada.
151
- */
152
- export declare function suggest(draft: QueryDraft, fields: readonly QueryField[]): QuerySuggestion[];
151
+ export declare function suggest(draft: QueryDraft, fields: readonly QueryField[],
152
+ /** The set has a literal search (`SearchBar literal`): offer it for free text. */
153
+ options?: {
154
+ literal?: boolean;
155
+ }): QuerySuggestion[];
153
156
  /**
154
157
  * CÓMO SE LEE UNA CONDICIÓN, en un solo lugar.
155
158
  *
@@ -97,6 +97,7 @@ export function readTyped(text) {
97
97
  export function globToLike(value) {
98
98
  return value.replace(/\*/g, "%");
99
99
  }
100
+ const NUMERIC = new Set(["integer", "number", "float", "decimal", "monetary", "money"]);
100
101
  /** Qué clase de valor es un campo, leída del tipo que publica el modelo. */
101
102
  function kindOf(definition) {
102
103
  if (definition.kind === "relation")
@@ -104,7 +105,9 @@ function kindOf(definition) {
104
105
  if (definition.choices !== undefined && definition.choices.length > 0)
105
106
  return "choice";
106
107
  const type = definition.type.toLowerCase();
107
- if (type === "integer" || type === "number" || type === "float")
108
+ /* An amount is a number: «decimal» and «monetary» read as text made «total <= 1000»
109
+ impossible to ask of an invoice. */
110
+ if (NUMERIC.has(type))
108
111
  return "number";
109
112
  if (type === "date" || type === "datetime")
110
113
  return "date";
@@ -265,7 +268,24 @@ function matches(field, text) {
265
268
  * ya. Las dos intenciones se escriben igual, así que se ofrecen las dos, con los
266
269
  * campos primero: es el paso que todavía no decidió nada.
267
270
  */
268
- export function suggest(draft, fields) {
271
+ /**
272
+ * Whether the MODEL reads what was typed as a question of its own: an operator written in
273
+ * front («> 100»), the name of a field («estado»), a value of an enum («pagada»), or a number or
274
+ * a date that a field with order can answer. Plain text («Globex») is none of those — every
275
+ * text field «contains» it, which says nothing — so the literal search goes first for it.
276
+ */
277
+ function readByModel(typed, byName, fields) {
278
+ if (typed.op !== undefined || byName.length > 0)
279
+ return true;
280
+ const value = typed.value.toLowerCase();
281
+ if (fields.some((field) => field.choices?.some((one) => one.toLowerCase() === value)))
282
+ return true;
283
+ return ((typed.shape === "number" || typed.shape === "date") &&
284
+ fields.some((field) => ORDERED.has(field.kind) && answers(field, typed)));
285
+ }
286
+ export function suggest(draft, fields,
287
+ /** The set has a literal search (`SearchBar literal`): offer it for free text. */
288
+ options = {}) {
269
289
  const typed = readTyped(draft.text);
270
290
  /* Paso 3 — el campo y el operador ya están: falta el valor. */
271
291
  if (draft.field !== undefined && draft.op !== undefined) {
@@ -409,10 +429,23 @@ export function suggest(draft, fields) {
409
429
  hecha — salvo que lo tipeado sea también un valor razonable, que es
410
430
  justamente el caso de «test». */
411
431
  const named = new Set(byName.map((one) => (one.kind === "field" ? one.field.field : "")));
412
- return [
432
+ const byModel = [
413
433
  ...byName,
414
434
  ...ready.filter((one) => one.kind !== "condition" || !named.has(one.field.field)),
415
435
  ];
436
+ if (options.literal !== true)
437
+ return byModel;
438
+ /* The literal search is always offered for text: what the model reads goes first, and
439
+ when it reads nothing, the literal search is what Enter takes. */
440
+ const text = draft.text.trim();
441
+ const literal = {
442
+ kind: "literal",
443
+ id: "l:literal",
444
+ label: currentCopy().search.literal,
445
+ hint: text,
446
+ text,
447
+ };
448
+ return readByModel(typed, byName, fields) ? [...byModel, literal] : [literal, ...byModel];
416
449
  }
417
450
  /**
418
451
  * CÓMO SE LEE UNA CONDICIÓN, en un solo lugar.
@@ -182,6 +182,10 @@ export type UiCopy = {
182
182
  askField: string;
183
183
  /** Cuando se escribió `#`: lo que sigue es el nombre de un campo. */
184
184
  askAttribute: string;
185
+ /** The set's own search over what was typed — every field it searches, at once — as it
186
+ * is offered («Buscar») and as the chip reads once put («Buscar «Globex»»). */
187
+ literal: string;
188
+ literalChip: string;
185
189
  askOp: string;
186
190
  /** El encabezado de un nodo del árbol: cómo se juntan las reglas de abajo. */
187
191
  askJoin: string;
@@ -155,6 +155,8 @@ export const SPANISH_COPY = {
155
155
  openBuilderHere: "Abrir esta condición en el constructor",
156
156
  askField: "en qué campo",
157
157
  askAttribute: "elegí un atributo",
158
+ literal: "Buscar",
159
+ literalChip: "Buscar «{text}»",
158
160
  askOp: "comparado cómo",
159
161
  askJoin: "tienen que coincidir",
160
162
  askValue: "contra qué",
@@ -115,6 +115,8 @@ export const ENGLISH_COPY = {
115
115
  openBuilderHere: "Open this condition in the builder",
116
116
  askField: "ask about",
117
117
  askAttribute: "pick an attribute",
118
+ literal: "Search",
119
+ literalChip: "Search “{text}”",
118
120
  askOp: "compared how",
119
121
  askJoin: "these rules match",
120
122
  unreadable: "We cannot read this filter as a tree — here it is as it stands. It can still be edited as JSON below.",
@@ -97,6 +97,9 @@ export type Pile<T> = {
97
97
  * montones los cuenta quien guarda los datos, este número viene de allá.
98
98
  */
99
99
  count: number;
100
+ /** What the pile ADDS UP TO, already written — «Total Bs 1.204.933» — summed by whoever
101
+ * keeps the data over EVERY row in it, like the count. Drawn in the pile's header. */
102
+ detail?: string;
100
103
  /** Las filas, en el nivel MÁS PROFUNDO. Vacío en los de arriba: un montón que
101
104
  * tiene montones adentro no tiene filas propias. */
102
105
  items: readonly T[];
@@ -47,6 +47,19 @@ export type PickerProps = {
47
47
  items: readonly PickerItem[];
48
48
  value?: string;
49
49
  onChange?: (id: string) => void;
50
+ /**
51
+ * The options are SEARCHED by whoever gives them — the server's literal search — and this
52
+ * hears what is typed (and `""` when the list starts over). With it the picker filters
53
+ * nothing itself and caps nothing: `items` is the answer, all of it drawn, with the typed
54
+ * text still marked inside it. Without it, the picker filters what it holds.
55
+ *
56
+ * Context: a picker over ten thousand customers holds the first ten. Filtering THOSE finds
57
+ * nothing past the tenth — the customer typed by its exact name was «not there».
58
+ */
59
+ onSearch?: (query: string) => void;
60
+ /** The chosen record's name when it is not among `items` — a search narrowed them, or it
61
+ * lies past the first page. Without it, a chosen record that is not listed reads empty. */
62
+ valueLabel?: string;
50
63
  placeholder?: string;
51
64
  /** «Buscar más…»: the last option of the list, always there when this is — the record
52
65
  * may not be among the eight, or two of them may need their columns to tell apart. It
@@ -91,13 +91,14 @@ const option = tv({
91
91
  });
92
92
  export function Picker({ mode, ...props }) {
93
93
  if (useFormMode(mode) === "read") {
94
- return _jsx(ReadValue, { value: props.items.find((i) => i.id === props.value)?.name ?? null });
94
+ const name = props.items.find((i) => i.id === props.value)?.name ?? props.valueLabel;
95
+ return _jsx(ReadValue, { value: props.value === undefined ? null : (name ?? null) });
95
96
  }
96
97
  /* The filter reads the locale from React Aria's context, so the box that provides it has
97
98
  to be ABOVE the component that asks for it. */
98
99
  return (_jsx(AriaLocale, { children: _jsx(EditablePicker, { ...props }) }));
99
100
  }
100
- function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrowseAll, onCreate, onCreateAndEdit, onOpen, openTargets = ["modal", "peek", "side", "page"], emptyText, searchPlaceholder, loading = false, loadingText, onLoadMore, loadingMore = false, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, "aria-label": ariaLabel, }) {
101
+ function EditablePicker({ id: ownId, items, value, onChange, onSearch, valueLabel, placeholder, onBrowseAll, onCreate, onCreateAndEdit, onOpen, openTargets = ["modal", "peek", "side", "page"], emptyText, searchPlaceholder, loading = false, loadingText, onLoadMore, loadingMore = false, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, "aria-label": ariaLabel, }) {
101
102
  const { id, labelledBy, describedBy, invalid } = useFieldIds({
102
103
  id: ownId,
103
104
  describedBy: ownDescribedBy,
@@ -110,28 +111,40 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
110
111
  const shownSearch = searchPlaceholder ?? copy.picker.search;
111
112
  const shownLoading = loadingText ?? copy.picker.loading;
112
113
  const chosen = items.find((i) => i.id === value);
113
- const chosenName = chosen?.name ?? "";
114
+ const chosenName = chosen?.name ?? (value === undefined ? "" : (valueLabel ?? ""));
114
115
  /* Both the text and the choice are controlled, so React Aria never rewrites the field
115
116
  from its own collection — which is capped and filtered, and may not hold the chosen
116
117
  record at all. `query` is what filters: empty when the list opens (the whole list,
117
118
  not just the one already chosen), the typed text after that. */
118
119
  const [open, setOpen] = useState(false);
119
120
  const [text, setText] = useState(chosenName);
120
- const [query, setQuery] = useState("");
121
+ const [query, setQueryOnly] = useState("");
122
+ /* What the consumer last heard: a list that opens, closes or chooses starts the search over,
123
+ and telling it `""` three times would ask the server three times for the same page. */
124
+ const heard = useRef("");
125
+ const setQuery = (next) => {
126
+ setQueryOnly(next);
127
+ if (onSearch !== undefined && next !== heard.current) {
128
+ heard.current = next;
129
+ onSearch(next);
130
+ }
131
+ };
121
132
  const [shownFor, setShownFor] = useState(chosenName);
122
133
  if (!open && shownFor !== chosenName) {
123
134
  setShownFor(chosenName);
124
135
  setText(chosenName);
125
136
  }
137
+ const searched = onSearch !== undefined;
126
138
  const filtered = useMemo(() => {
127
139
  const q = query.trim();
128
- if (q === "")
140
+ if (q === "" || searched)
129
141
  return items;
130
142
  return items.filter((i) => contains(`${i.name} ${i.meta ?? ""} ${i.haystack ?? ""}`, q));
131
- }, [items, query, contains]);
143
+ }, [items, query, contains, searched]);
144
+ /* Searched by the consumer, the items ARE the page it chose to show: all of them. */
132
145
  const { visible, remaining } = shownRows(filtered, {
133
146
  loading,
134
- incremental: onLoadMore !== undefined,
147
+ incremental: onLoadMore !== undefined || searched,
135
148
  });
136
149
  const hasMarks = items.some((i) => i.icon !== undefined);
137
150
  const hasHints = visible.some((i) => i.meta !== undefined);
@@ -15,6 +15,13 @@ export type TagsFieldProps = {
15
15
  * backend (Odoo's `name_create`); the consumer also appends the new option to `options`.
16
16
  */
17
17
  onCreate?: (label: string) => string | Promise<string>;
18
+ /**
19
+ * The options are SEARCHED by whoever gives them — the server's literal search over
20
+ * thousands of products or people — and this hears what is typed (`""` when it starts over).
21
+ * With it the field filters nothing itself: `options` is the answer, plus the chosen ones for
22
+ * their labels; the chosen still never come back as options.
23
+ */
24
+ onSearch?: (query: string) => void;
18
25
  /** «Buscar más…» at the end of the list: the tags to add chosen from a table
19
26
  * (the list that chooses, `List onChoose` with `selection`, in a modal). It gets what was typed. */
20
27
  onBrowseAll?: (query: string) => void;
@@ -84,7 +84,7 @@ export function TagsField({ mode, ...props }) {
84
84
  to be ABOVE the component that asks for it. */
85
85
  return (_jsx(AriaLocale, { children: _jsx(EditableTagsField, { ...props }) }));
86
86
  }
87
- function EditableTagsField({ value, onChange, options, onCreate, onBrowseAll, onOpen, openTarget, placeholder, disabled = false, id: ownId, describedBy: ownDescribedBy, invalid: ownInvalid, "aria-label": ariaLabel, }) {
87
+ function EditableTagsField({ value, onChange, options, onCreate, onSearch, onBrowseAll, onOpen, openTarget, placeholder, disabled = false, id: ownId, describedBy: ownDescribedBy, invalid: ownInvalid, "aria-label": ariaLabel, }) {
88
88
  const { id, labelledBy, describedBy, invalid } = useFieldIds({
89
89
  id: ownId,
90
90
  describedBy: ownDescribedBy,
@@ -95,7 +95,17 @@ function EditableTagsField({ value, onChange, options, onCreate, onBrowseAll, on
95
95
  const { contains } = useFilter({ sensitivity: "base" });
96
96
  const { locale } = useLocale();
97
97
  const collator = useMemo(() => new Intl.Collator(locale, { sensitivity: "base", usage: "search" }), [locale]);
98
- const [text, setText] = useState("");
98
+ const [text, setTextOnly] = useState("");
99
+ /* What the consumer last heard: an add or a reopen starts the search over, and telling it
100
+ `""` twice would ask the server twice for the same page. */
101
+ const heard = useRef("");
102
+ const setText = (next) => {
103
+ setTextOnly(next);
104
+ if (onSearch !== undefined && next !== heard.current) {
105
+ heard.current = next;
106
+ onSearch(next);
107
+ }
108
+ };
99
109
  const boxRef = useRef(null);
100
110
  const [width, setWidth] = useState(null);
101
111
  const inputRef = useRef(null);
@@ -103,7 +113,7 @@ function EditableTagsField({ value, onChange, options, onCreate, onBrowseAll, on
103
113
  creating: a tag removed meanwhile must not come back. */
104
114
  const latest = useRef(value);
105
115
  latest.current = value;
106
- const { available, create } = tagChoices(options, value, text, { contains, same: (a, b) => collator.compare(a.trim(), b) === 0 }, onCreate !== undefined && !disabled);
116
+ const { available, create } = tagChoices(options, value, text, { contains, same: (a, b) => collator.compare(a.trim(), b) === 0 }, onCreate !== undefined && !disabled, onSearch !== undefined);
107
117
  const listed = [
108
118
  ...available,
109
119
  ...(create === null
@@ -30,7 +30,10 @@ export type TagMatch = {
30
30
  * existing tag, chosen or not: creating «Mayorista» next to «mayorista» is how a catalog
31
31
  * ends up with both.
32
32
  */
33
- export declare function tagChoices(options: readonly TagOption[], value: readonly string[], text: string, match: TagMatch, creatable: boolean): {
33
+ export declare function tagChoices(options: readonly TagOption[], value: readonly string[], text: string, match: TagMatch, creatable: boolean,
34
+ /** The options are already the answer to `text` (searched by the consumer, on a server):
35
+ * they are not filtered again — the server may have matched a field no label shows. */
36
+ searched?: boolean): {
34
37
  available: TagOption[];
35
38
  create: string | null;
36
39
  };
@@ -7,10 +7,14 @@
7
7
  * existing tag, chosen or not: creating «Mayorista» next to «mayorista» is how a catalog
8
8
  * ends up with both.
9
9
  */
10
- export function tagChoices(options, value, text, match, creatable) {
10
+ export function tagChoices(options, value, text, match, creatable,
11
+ /** The options are already the answer to `text` (searched by the consumer, on a server):
12
+ * they are not filtered again — the server may have matched a field no label shows. */
13
+ searched = false) {
11
14
  const chosen = new Set(value);
12
15
  const query = text.trim();
13
- const available = options.filter((option) => !chosen.has(option.value) && (query === "" || match.contains(option.label, query)));
16
+ const available = options.filter((option) => !chosen.has(option.value) &&
17
+ (searched || query === "" || match.contains(option.label, query)));
14
18
  const create = creatable && query !== "" && !options.some((option) => match.same(option.label, query))
15
19
  ? query
16
20
  : null;
@@ -238,7 +238,7 @@ export function ListView({ items, keyOf, identity, crumbs, embedded = false, sur
238
238
  return list.map((pile) => {
239
239
  const key = pathKey(pile.path);
240
240
  const open = isOpen(pile);
241
- return (_jsxs(Fragment, { children: [_jsx(GroupHeader, { field: pile.field, label: pile.label, count: pile.count, depth: pile.depth, open: open, ...(group?.onToggle === undefined
241
+ return (_jsxs(Fragment, { children: [_jsx(GroupHeader, { field: pile.field, label: pile.label, count: pile.count, ...(pile.detail === undefined ? {} : { detail: pile.detail }), depth: pile.depth, open: open, ...(group?.onToggle === undefined
242
242
  ? {}
243
243
  : { onToggle: () => group.onToggle?.(key) }) }), !open
244
244
  ? null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sincpro/web-ui",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "An enterprise web design system for business software — ERPs, back offices, management tools — in React and Tailwind: tokens, primitives, components and the two views every management screen is made of.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",