@sincpro/web-ui 1.0.0 → 1.2.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/dist/components/search/SearchBar.d.ts +12 -1
- package/dist/components/search/SearchBar.js +26 -11
- package/dist/components/search/query.d.ts +14 -11
- package/dist/components/search/query.js +36 -3
- package/dist/foundations/copy.d.ts +8 -0
- package/dist/foundations/copy.es.js +4 -0
- package/dist/foundations/copy.js +4 -0
- package/dist/foundations/grouping.d.ts +3 -0
- package/dist/primitives/picker/Picker.d.ts +13 -0
- package/dist/primitives/picker/Picker.js +20 -7
- package/dist/primitives/table/DataTable.js +42 -16
- package/dist/primitives/table/Table.d.ts +12 -3
- package/dist/primitives/table/Table.js +6 -6
- package/dist/primitives/table/rowWindow.d.ts +39 -0
- package/dist/primitives/table/rowWindow.js +177 -0
- package/dist/primitives/tag/TagsField.d.ts +7 -0
- package/dist/primitives/tag/TagsField.js +13 -3
- package/dist/primitives/tag/choices.d.ts +4 -1
- package/dist/primitives/tag/choices.js +6 -2
- package/dist/views/list/ListView.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
159
|
-
|
|
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. */
|
|
@@ -371,7 +381,12 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
|
|
|
371
381
|
onCriteriaChange([]);
|
|
372
382
|
setOpenGroup(null);
|
|
373
383
|
input.current?.focus();
|
|
374
|
-
}, "aria-label": copy.search.clearAll, title: copy.search.clearAll, className: `my-0.5 inline-flex h-(--size-control-sm) shrink-0 items-center rounded-control px-1.5 transition-colors duration-(--duration-instant) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${bySurface(sup, "text-muted hover:bg-ink/5 hover:text-ink", "text-chrome-muted hover:bg-chrome-ink/10 hover:text-chrome-ink")}`, children: _jsx(X, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true }) })), columns.length === 0 && onOpenBuilder === undefined ? null : (_jsxs("button", { type: "button", onClick: () => setPanel((v) => !v), "aria-expanded": panel, "aria-controls": panel ? id : undefined,
|
|
384
|
+
}, "aria-label": copy.search.clearAll, title: copy.search.clearAll, className: `my-0.5 inline-flex h-(--size-control-sm) shrink-0 items-center rounded-control px-1.5 transition-colors duration-(--duration-instant) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${bySurface(sup, "text-muted hover:bg-ink/5 hover:text-ink", "text-chrome-muted hover:bg-chrome-ink/10 hover:text-chrome-ink")}`, children: _jsx(X, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true }) })), columns.length === 0 && onOpenBuilder === undefined ? null : (_jsxs("button", { type: "button", onClick: () => setPanel((v) => !v), "aria-expanded": panel, "aria-controls": panel ? id : undefined, "aria-label": puestos === 0
|
|
385
|
+
? copy.search.tools
|
|
386
|
+
: fillCopy(puestos === 1 ? copy.search.toolsSetOne : copy.search.toolsSetMany, {
|
|
387
|
+
label: copy.search.tools,
|
|
388
|
+
count: puestos.toLocaleString(copy.locale),
|
|
389
|
+
}), title: copy.search.tools, className: `my-0.5 mr-0.5 inline-flex h-(--size-control-sm) shrink-0 items-center gap-1 rounded-control border px-2 text-micro transition-colors duration-(--duration-instant) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${puestos > 0 || panel
|
|
375
390
|
? bySurface(sup, "border-accent/40 bg-accent/18 text-ink", "border-chrome-link/35 bg-chrome-link/15 text-chrome-link")
|
|
376
391
|
: bySurface(sup, "border-transparent text-muted hover:bg-ink/5 hover:text-ink", "border-transparent text-chrome-muted hover:bg-chrome-ink/10 hover:text-chrome-ink")}`, children: [_jsx(ListFilter, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true }), puestos > 0 ? _jsx("span", { className: "text-micro tabular-nums", children: puestos }) : null] }))] }), trailing, open ? (_jsxs("div", { className: `absolute top-full z-30 mt-1 overflow-hidden rounded-panel border border-line-strong bg-surface shadow-float ${asTags ? "left-0 w-max max-w-full" : "right-0 left-0"}`, children: [openJoin === undefined ? null : (_jsxs("div", { className: "flex items-center gap-1.5 border-line border-b px-3 py-1.5 text-micro text-muted", children: [_jsx(CornerDownRight, { className: "size-3 shrink-0", strokeWidth: 2, "aria-hidden": true }), _jsx("span", { className: "min-w-0 truncate", children: copy.search.landsHere }), _jsx("span", { className: "shrink-0 rounded-pill border border-accent/40 bg-accent/18 px-1.5 text-ink", children: copy.search.joins[openJoin === "not" ? "none" : openJoin] })] })), asTags ? (
|
|
377
392
|
/* UNA LISTA, no una tira. Se está ELIGIENDO: el ojo baja por una
|
|
@@ -387,7 +402,7 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
|
|
|
387
402
|
: {}),
|
|
388
403
|
active: at === active,
|
|
389
404
|
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 ??
|
|
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 ??
|
|
391
406
|
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
407
|
onOpenBuilder();
|
|
393
408
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -125,6 +125,10 @@ export type UiCopy = {
|
|
|
125
125
|
};
|
|
126
126
|
search: {
|
|
127
127
|
tools: string;
|
|
128
|
+
/** The name of the panel button once something is set: it shows the count, and what
|
|
129
|
+
* the button SHOWS has to be inside its name (WCAG 2.5.3) — `{label}` is `tools`. */
|
|
130
|
+
toolsSetOne: string;
|
|
131
|
+
toolsSetMany: string;
|
|
128
132
|
editChip: string;
|
|
129
133
|
editNamed: string;
|
|
130
134
|
removeChip: string;
|
|
@@ -182,6 +186,10 @@ export type UiCopy = {
|
|
|
182
186
|
askField: string;
|
|
183
187
|
/** Cuando se escribió `#`: lo que sigue es el nombre de un campo. */
|
|
184
188
|
askAttribute: string;
|
|
189
|
+
/** The set's own search over what was typed — every field it searches, at once — as it
|
|
190
|
+
* is offered («Buscar») and as the chip reads once put («Buscar «Globex»»). */
|
|
191
|
+
literal: string;
|
|
192
|
+
literalChip: string;
|
|
185
193
|
askOp: string;
|
|
186
194
|
/** El encabezado de un nodo del árbol: cómo se juntan las reglas de abajo. */
|
|
187
195
|
askJoin: string;
|
|
@@ -127,6 +127,8 @@ export const SPANISH_COPY = {
|
|
|
127
127
|
},
|
|
128
128
|
search: {
|
|
129
129
|
tools: "Filtrar, agrupar y favoritos",
|
|
130
|
+
toolsSetOne: "{label}, {count} puesto",
|
|
131
|
+
toolsSetMany: "{label}, {count} puestos",
|
|
130
132
|
editChip: "Clic para editar",
|
|
131
133
|
editNamed: "Editar {label}",
|
|
132
134
|
removeChip: "Quitar {label}: {value}",
|
|
@@ -155,6 +157,8 @@ export const SPANISH_COPY = {
|
|
|
155
157
|
openBuilderHere: "Abrir esta condición en el constructor",
|
|
156
158
|
askField: "en qué campo",
|
|
157
159
|
askAttribute: "elegí un atributo",
|
|
160
|
+
literal: "Buscar",
|
|
161
|
+
literalChip: "Buscar «{text}»",
|
|
158
162
|
askOp: "comparado cómo",
|
|
159
163
|
askJoin: "tienen que coincidir",
|
|
160
164
|
askValue: "contra qué",
|
package/dist/foundations/copy.js
CHANGED
|
@@ -90,6 +90,8 @@ export const ENGLISH_COPY = {
|
|
|
90
90
|
},
|
|
91
91
|
search: {
|
|
92
92
|
tools: "Filter, group and favorites",
|
|
93
|
+
toolsSetOne: "{label}, {count} applied",
|
|
94
|
+
toolsSetMany: "{label}, {count} applied",
|
|
93
95
|
editChip: "Click to edit",
|
|
94
96
|
editNamed: "Edit {label}",
|
|
95
97
|
removeChip: "Remove {label}: {value}",
|
|
@@ -115,6 +117,8 @@ export const ENGLISH_COPY = {
|
|
|
115
117
|
openBuilderHere: "Open this condition in the builder",
|
|
116
118
|
askField: "ask about",
|
|
117
119
|
askAttribute: "pick an attribute",
|
|
120
|
+
literal: "Search",
|
|
121
|
+
literalChip: "Search “{text}”",
|
|
118
122
|
askOp: "compared how",
|
|
119
123
|
askJoin: "these rules match",
|
|
120
124
|
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
|
-
|
|
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,
|
|
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);
|
|
@@ -6,6 +6,7 @@ import { CellCopy } from "./CellCopy.js";
|
|
|
6
6
|
import { CellEditor } from "./CellEditor.js";
|
|
7
7
|
import { EditRow, firstControl } from "./EditableTable.js";
|
|
8
8
|
import { RowActions } from "./RowActions.js";
|
|
9
|
+
import { useRowWindow } from "./rowWindow.js";
|
|
9
10
|
import { SelectBox, Table, Td, Th, THead, Tr, } from "./Table.js";
|
|
10
11
|
import { aggregationFns, columnGroupingFeature, createColumnHelper, createExpandedRowModel, createGroupedRowModel, createSortedRowModel, rowAggregationFeature, rowExpandingFeature, rowSortingFeature, sortFns, tableFeatures, useTable, } from "@tanstack/react-table";
|
|
11
12
|
import { ChevronRight, Plus } from "lucide-react";
|
|
@@ -258,6 +259,14 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
|
|
|
258
259
|
return true;
|
|
259
260
|
});
|
|
260
261
|
const selecting = onSelectedChange !== undefined;
|
|
262
|
+
const width = columns.length + (selecting ? 1 : 0) + (rowActions === undefined ? 0 : 1);
|
|
263
|
+
/* Past a few hundred rows only the ones in view are drawn — see `rowWindow.tsx`. It
|
|
264
|
+
windows what is DRAWN, group rows included and folded rows left out: that is the
|
|
265
|
+
sequence a person scrolls through. */
|
|
266
|
+
const view = useRowWindow({
|
|
267
|
+
count: drawn.length,
|
|
268
|
+
getKey: (i) => drawn[i]?.id ?? String(i),
|
|
269
|
+
});
|
|
261
270
|
const leaves = useMemo(() => rows.filter((r) => r.subRows.length === 0), [rows]);
|
|
262
271
|
/* The aggregates are computed by the component and not by TanStack: its
|
|
263
272
|
`aggregationFn` lives in the GROUP model, and a footer exists precisely when
|
|
@@ -273,8 +282,20 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
|
|
|
273
282
|
n.delete(id);
|
|
274
283
|
onSelectedChange?.(n);
|
|
275
284
|
}
|
|
276
|
-
const grid = (_jsxs(Table, { height: height,
|
|
285
|
+
const grid = (_jsxs(Table, { height: height, ...(view.on
|
|
286
|
+
? {
|
|
287
|
+
rowCount: 1 + drawn.length + (draft === null ? 0 : 1) + (totals ? 1 : 0),
|
|
288
|
+
}
|
|
289
|
+
: {}), children: [_jsxs(THead, { ...(view.on ? { rowIndex: 1 } : {}), children: [selecting ? (_jsx(Th, { width: 36, children: _jsx(SelectBox, { label: copy.selection.all, checked: all, indeterminate: some, onChange: (v) => onSelectedChange?.(v ? new Set(leaves.map((r) => r.id)) : new Set()) }) })) : null, columns.map((c) => (_jsx(Th, { sortKey: c.sortable === false ? undefined : c.id, sort: sort, onSort: onSortChange, numeric: c.numeric, width: c.width, children: c.header }, c.id))), rowActions === undefined ? null : _jsx(Th, { width: 1, children: "" })] }), _jsxs("tbody", { ref: view.body.ref, children: [view.rows.flatMap((slot, at) => {
|
|
290
|
+
const row = drawn[slot.index];
|
|
291
|
+
if (row === undefined)
|
|
292
|
+
return [];
|
|
277
293
|
const isGroup = row.subRows.length > 0;
|
|
294
|
+
const windowed = view.windowed(slot.index);
|
|
295
|
+
/* What is not drawn between the previous drawn row and this one: the rows
|
|
296
|
+
above the view before the first, and the gap before a row kept only because
|
|
297
|
+
it has the focus. */
|
|
298
|
+
const gap = view.pad(at === 0 ? 0 : view.rows[at - 1].end, slot.start, width, `\u0000pad${slot.index}`);
|
|
278
299
|
if (isGroup) {
|
|
279
300
|
const path = pathOf(row);
|
|
280
301
|
const key = pathKey(path);
|
|
@@ -287,22 +308,27 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
|
|
|
287
308
|
const open = collapsed === undefined
|
|
288
309
|
? row.getIsExpanded()
|
|
289
310
|
: collapsed.includes(key) !== true;
|
|
290
|
-
return
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
311
|
+
return [
|
|
312
|
+
gap,
|
|
313
|
+
/* The group row: it spans the whole width, says which value it
|
|
314
|
+
groups by and how many are inside. The counter is what makes it
|
|
315
|
+
useful without opening it. */
|
|
316
|
+
_jsx("tr", { ...windowed, className: "border-b border-line bg-ground", children: _jsx("td", { colSpan: width, className: "px-3 py-1.5", style: depth === 0
|
|
317
|
+
? undefined
|
|
318
|
+
: { paddingLeft: `calc(var(--spacing-indent) * ${depth} + 0.75rem)` }, children: _jsxs("button", { type: "button", onClick: () => onGroupToggle === undefined ? row.toggleExpanded() : onGroupToggle(key), "aria-expanded": open, className: "inline-flex items-center gap-1.5 rounded-control text-meta font-medium text-ink transition-colors duration-(--duration-instant) hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: [_jsx(ChevronRight, { className: `size-(--size-icon-sm) text-muted transition-transform duration-(--duration-instant) ${open ? "rotate-90" : ""}`, strokeWidth: 2, "aria-hidden": true }), level === undefined ? null : (_jsx("span", { className: "text-micro font-normal text-muted/70", children: level.label })), _jsx("span", { children: level === undefined ? value : level.labelOf(value) }), _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 tabular-nums text-muted", children: row.getLeafRows().length.toLocaleString(copy.locale) })] }) }) }, row.id),
|
|
319
|
+
];
|
|
297
320
|
}
|
|
298
321
|
const original = row.original;
|
|
299
|
-
return
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
322
|
+
return [
|
|
323
|
+
gap,
|
|
324
|
+
_jsxs(Tr, { ...(windowed === undefined ? {} : { windowed }), selected: selected?.has(row.id) === true, onClick: onRowClick === undefined ? undefined : () => onRowClick(original), children: [selecting ? (_jsx(Td, { children: _jsx(SelectBox, { label: copy.selection.row, checked: selected?.has(row.id) === true, onChange: (v) => toggle(row.id, v) }) })) : null, columns.map((c) => {
|
|
325
|
+
const t = c.tone?.(original);
|
|
326
|
+
const n = c.note?.(original);
|
|
327
|
+
const edit = c.edit?.when?.(original) === false ? undefined : c.edit;
|
|
328
|
+
return (_jsx(Td, { numeric: c.numeric, mono: c.mono === true, ...(t === undefined ? {} : { tone: t }), ...(n === undefined ? {} : { title: n }), children: edit !== undefined ? (_jsx(CellEditor, { label: fillCopy(copy.inlineEdit.editCell, { column: c.header }), numeric: c.numeric === true, editor: (done) => edit.render(original, done), children: drawCell(copy, c, original) })) : copyable && c.copy !== false ? (_jsx(CellCopy, { header: c.header, numeric: c.numeric === true, ...(c.copy === undefined ? {} : { value: c.copy(original) }), children: drawCell(copy, c, original) })) : (drawCell(copy, c, original)) }, c.id));
|
|
329
|
+
}), rowActions === undefined ? null : (_jsx(RowActions, { actions: rowActions(original) }))] }, row.id),
|
|
330
|
+
];
|
|
331
|
+
}), view.tail(width), create === undefined || draft === null ? null : (_jsxs(EditRow, { as: "tr", rowId: "\\u0000draft", rowRef: (node) => {
|
|
306
332
|
draftRow.current = node;
|
|
307
333
|
}, editable: true, editing: true, tabbable: false, className: "group/row", onStart: () => { }, onCommit: saveDraft, onCancel: () => {
|
|
308
334
|
backToAdd.current = true;
|
|
@@ -318,7 +344,7 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
|
|
|
318
344
|
}, children: [selecting ? _jsx("td", { className: DRAFT_CELL }) : null, columns.map((c) => (_jsx("td", { ...(c.input === undefined ? {} : { "data-edit": "" }), className: `${DRAFT_CELL} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: c.input?.(draft, (next) => {
|
|
319
345
|
touched.current = true;
|
|
320
346
|
create.onChange(next);
|
|
321
|
-
}) ?? null }, c.id))), rowActions === undefined ? null : _jsx("td", { className: DRAFT_CELL })] }))] }), footer === null ? null : (_jsx("tfoot", { className: "sticky bottom-0 z-10 bg-ground", children: _jsxs("tr", { className: "border-t border-line-strong", children: [selecting ? _jsx("td", {}) : null, columns.map((c, i) => {
|
|
347
|
+
}) ?? null }, c.id))), rowActions === undefined ? null : _jsx("td", { className: DRAFT_CELL })] }))] }), footer === null ? null : (_jsx("tfoot", { className: "sticky bottom-0 z-10 bg-ground", children: _jsxs("tr", { className: "border-t border-line-strong", "aria-rowindex": view.on ? 2 + drawn.length + (draft === null ? 0 : 1) : undefined, children: [selecting ? _jsx("td", {}) : null, columns.map((c, i) => {
|
|
322
348
|
const value = footer[i] ?? null;
|
|
323
349
|
return (_jsx("td", { className: `px-3 py-1.5 text-meta font-semibold text-ink ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: i === 0 && value === null ? (_jsxs("span", { className: "text-muted", children: [data.length.toLocaleString(copy.locale), " ", data.length === 1 ? copy.table.row : copy.table.rows] })) : value === null ? null : (aggregateLabel(copy, c, value)) }, c.id));
|
|
324
350
|
}), rowActions === undefined ? null : _jsx("td", {})] }) }))] }));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Tone } from "../../foundations/types.js";
|
|
2
|
+
import type { WindowedRow } from "./rowWindow.js";
|
|
2
3
|
import { type ReactNode } from "react";
|
|
3
4
|
export type SortDirection = "asc" | "desc";
|
|
4
5
|
export interface SortState {
|
|
@@ -8,17 +9,23 @@ export interface SortState {
|
|
|
8
9
|
export declare function Table({ children,
|
|
9
10
|
/** With a height, the table scrolls on its own and the header sticks.
|
|
10
11
|
* Without a height, the page scrolls and the header goes away. */
|
|
11
|
-
height, }: {
|
|
12
|
+
height, rowCount, }: {
|
|
12
13
|
children: ReactNode;
|
|
13
14
|
/** A number means pixels; a string goes through as is, so `26rem` follows the
|
|
14
15
|
* system scale instead of pinning a size in pixels. */
|
|
15
16
|
height?: number | string;
|
|
17
|
+
/** How many rows the table REALLY has, header and footer included, when it draws only
|
|
18
|
+
* some of them (`useRowWindow`): a screen reader says «row 412 of 3 000» and not
|
|
19
|
+
* «row 12 of 40». Leave it out when every row is drawn. */
|
|
20
|
+
rowCount?: number;
|
|
16
21
|
}): import("react").JSX.Element;
|
|
17
22
|
/** The text of a column header, shared by every table of the family. Ink and semibold, not
|
|
18
23
|
* muted: in grey at the cells' weight the names of the columns read as one more row. */
|
|
19
24
|
export declare const HEADER_TEXT = "text-meta font-semibold whitespace-nowrap text-ink";
|
|
20
|
-
export declare function THead({ children }: {
|
|
25
|
+
export declare function THead({ children, rowIndex, }: {
|
|
21
26
|
children: ReactNode;
|
|
27
|
+
/** Its `aria-rowindex`, set only when the table says its `rowCount`. */
|
|
28
|
+
rowIndex?: number;
|
|
22
29
|
}): import("react").JSX.Element;
|
|
23
30
|
/** A column. If it gets a `sortKey`, it can be sorted.
|
|
24
31
|
*
|
|
@@ -42,10 +49,12 @@ export declare function Th({ children, sortKey, sort, onSort, numeric, align, wi
|
|
|
42
49
|
* system scale instead of pinning a size in pixels. */
|
|
43
50
|
width?: number | string;
|
|
44
51
|
}): import("react").JSX.Element;
|
|
45
|
-
export declare function Tr({ children, onClick, selected, }: {
|
|
52
|
+
export declare function Tr({ children, onClick, selected, windowed, }: {
|
|
46
53
|
children: ReactNode;
|
|
47
54
|
onClick?: () => void;
|
|
48
55
|
selected?: boolean;
|
|
56
|
+
/** Set by a table that draws only the rows in view (`useRowWindow`). */
|
|
57
|
+
windowed?: WindowedRow;
|
|
49
58
|
}): import("react").JSX.Element;
|
|
50
59
|
/**
|
|
51
60
|
* A row that opens its own detail underneath.
|
|
@@ -6,25 +6,25 @@ import { createContext, useContext, useId } from "react";
|
|
|
6
6
|
export function Table({ children,
|
|
7
7
|
/** With a height, the table scrolls on its own and the header sticks.
|
|
8
8
|
* Without a height, the page scrolls and the header goes away. */
|
|
9
|
-
height, }) {
|
|
9
|
+
height, rowCount, }) {
|
|
10
10
|
const caja = useScrollBox(height === undefined ? {} : { height });
|
|
11
11
|
return (
|
|
12
12
|
/* Cuándo esta caja es un contenedor de scroll y cuándo no lo decide
|
|
13
13
|
`useScrollBox`: ahí vive el dilema entero y ahí vive su explicación. Las
|
|
14
14
|
tres tablas del sistema lo resolvían con tres copias de la misma
|
|
15
15
|
medición. */
|
|
16
|
-
_jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsx("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: children }) }));
|
|
16
|
+
_jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsx("table", { "aria-rowcount": rowCount, className: "w-full border-separate border-spacing-0 px-1 text-left", children: children }) }));
|
|
17
17
|
}
|
|
18
18
|
/** The text of a column header, shared by every table of the family. Ink and semibold, not
|
|
19
19
|
* muted: in grey at the cells' weight the names of the columns read as one more row. */
|
|
20
20
|
export const HEADER_TEXT = "text-meta font-semibold whitespace-nowrap text-ink";
|
|
21
|
-
export function THead({ children }) {
|
|
21
|
+
export function THead({ children, rowIndex, }) {
|
|
22
22
|
return (
|
|
23
23
|
/* Se pega DEBAJO de lo que ya esté pegado —la barra de la aplicación y la
|
|
24
24
|
de la lista— y no en `top: 0`, que lo metía atrás de las dos. Sin alto en
|
|
25
25
|
`Table` el que scrollea es la página, y ahí la diferencia es justamente
|
|
26
26
|
la que hacía desaparecer los nombres de columna. Ver `system/sticky.tsx`. */
|
|
27
|
-
_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsx("tr", { children: children }) }));
|
|
27
|
+
_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsx("tr", { "aria-rowindex": rowIndex, children: children }) }));
|
|
28
28
|
}
|
|
29
29
|
/** A column. If it gets a `sortKey`, it can be sorted.
|
|
30
30
|
*
|
|
@@ -59,8 +59,8 @@ const ROW_ON = "bg-primary/8";
|
|
|
59
59
|
function rowWash({ selected, interactive }) {
|
|
60
60
|
return selected ? ROW_ON : interactive ? ROW_WASH : "";
|
|
61
61
|
}
|
|
62
|
-
export function Tr({ children, onClick, selected = false, }) {
|
|
63
|
-
return (_jsx(RowMark.Provider, { value: { selected, interactive: onClick !== undefined }, children: _jsx("tr", { onClick: onClick, className: `group/row ${onClick === undefined ? "" : "cursor-pointer"}`, children: children }) }));
|
|
62
|
+
export function Tr({ children, onClick, selected = false, windowed, }) {
|
|
63
|
+
return (_jsx(RowMark.Provider, { value: { selected, interactive: onClick !== undefined }, children: _jsx("tr", { ...windowed, onClick: onClick, className: `group/row ${onClick === undefined ? "" : "cursor-pointer"}`, children: children }) }));
|
|
64
64
|
}
|
|
65
65
|
/**
|
|
66
66
|
* A row that opens its own detail underneath.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type ReactNode, type RefObject } from "react";
|
|
2
|
+
/** From how many rows the table draws only the ones in view. */
|
|
3
|
+
export declare const WINDOW_FROM = 200;
|
|
4
|
+
/** What a drawn row has to carry: spread on its `<tr>`. */
|
|
5
|
+
export type WindowedRow = {
|
|
6
|
+
ref: (node: HTMLTableRowElement | null) => void;
|
|
7
|
+
"data-index": number;
|
|
8
|
+
"aria-rowindex": number;
|
|
9
|
+
onFocus: () => void;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Which rows of `count` to draw. Spread `body` on the `<tbody>`, draw `rows` in the
|
|
13
|
+
* order given — `windowed(i)` on each one's `<tr>` — with `pad(...)` between them, and
|
|
14
|
+
* pass `rowCount` to the table.
|
|
15
|
+
*
|
|
16
|
+
* `rowsBefore` is how many rows the table has above the body (the header), so the
|
|
17
|
+
* `aria-rowindex` of the rows counts from there.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useRowWindow({ count, rowsBefore, getKey, }: {
|
|
20
|
+
count: number;
|
|
21
|
+
rowsBefore?: number;
|
|
22
|
+
getKey: (index: number) => string;
|
|
23
|
+
}): {
|
|
24
|
+
on: boolean;
|
|
25
|
+
body: {
|
|
26
|
+
ref: RefObject<HTMLTableSectionElement | null>;
|
|
27
|
+
};
|
|
28
|
+
/** The indices to draw, in order. All of them when the window is off. */
|
|
29
|
+
rows: readonly {
|
|
30
|
+
index: number;
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
}[];
|
|
34
|
+
windowed: (index: number) => WindowedRow | undefined;
|
|
35
|
+
/** The spacer that stands for the rows not drawn before `start`. */
|
|
36
|
+
pad: (from: number, to: number, columns: number, key: string) => ReactNode;
|
|
37
|
+
/** The spacer after the last drawn row. */
|
|
38
|
+
tail: (columns: number) => ReactNode;
|
|
39
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useVirtualizer, useWindowVirtualizer } from "@tanstack/react-virtual";
|
|
4
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
5
|
+
/* ==================================================================== *
|
|
6
|
+
* ROW WINDOW — the rows of a `<table>` that are worth drawing.
|
|
7
|
+
*
|
|
8
|
+
* Context: `DataTable` drew every row it got. A page of eighty is nothing; a
|
|
9
|
+
* «load more» that reached three thousand was three thousand rows of cells,
|
|
10
|
+
* copy buttons and editors, and the tab froze. web-framework put a ceiling on
|
|
11
|
+
* «load more» and kept the pages BECAUSE of it. This is what lifts the ceiling.
|
|
12
|
+
*
|
|
13
|
+
* ── It stays a `<table>` ────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* `SheetTable` positions `<div>` rows absolutely. A list cannot: its columns
|
|
16
|
+
* size themselves from the content, its header sticks with `position: sticky`
|
|
17
|
+
* and its rows carry the row semantics a screen reader walks. So the rows that
|
|
18
|
+
* are NOT drawn become one spacer row above and one below — real height, no
|
|
19
|
+
* content, `aria-hidden` —, and the table says how many rows it really has
|
|
20
|
+
* (`aria-rowcount`) and where each drawn one sits (`aria-rowindex`).
|
|
21
|
+
*
|
|
22
|
+
* ── The scroller is found, not declared ─────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* A list inside `AppFrame` scrolls with the PAGE — that is what keeps the
|
|
25
|
+
* application bar, the list bar and the header stuck one under the other. The
|
|
26
|
+
* same list inside a composer panel or a `Peek` scrolls inside THAT box, and a
|
|
27
|
+
* `DataTable` with `height` scrolls inside its own. The nearest ancestor that
|
|
28
|
+
* really scrolls vertically is the one; none means the page. Giving the table a
|
|
29
|
+
* scroll box of its own instead would break the sticky stack — the trap
|
|
30
|
+
* AGENTS.md writes down for `overflow-hidden`.
|
|
31
|
+
*
|
|
32
|
+
* ── Only past a size ────────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* Under `WINDOW_FROM` rows everything is drawn, as before: the browser's find,
|
|
35
|
+
* printing and a row that is always there are worth more than the few
|
|
36
|
+
* milliseconds saved on a page of eighty. Over it, the find of the browser only
|
|
37
|
+
* sees what is drawn — the search bar is how a set that large is searched.
|
|
38
|
+
*
|
|
39
|
+
* ── What has focus stays drawn ──────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* A cell being edited, a copy button just pressed: if its row scrolled out and
|
|
42
|
+
* got unmounted, the focus would fall to `<body>` and the popover with it. The
|
|
43
|
+
* last row that took focus is always kept in the window, wherever it is.
|
|
44
|
+
* Tabbing forward works without help: the next row is in the overscan, the
|
|
45
|
+
* browser scrolls it into view, and the window follows.
|
|
46
|
+
* ==================================================================== */
|
|
47
|
+
/** From how many rows the table draws only the ones in view. */
|
|
48
|
+
export const WINDOW_FROM = 200;
|
|
49
|
+
/** A first guess of a row's height; each drawn row is measured after. */
|
|
50
|
+
const ESTIMATE = 36;
|
|
51
|
+
/** Rows drawn beyond each edge of the view. */
|
|
52
|
+
const OVERSCAN = 10;
|
|
53
|
+
/** Rows drawn before the scroller is known — the first frame only. */
|
|
54
|
+
const FIRST_FRAME = 40;
|
|
55
|
+
/** The nearest ancestor that scrolls vertically, or the page. */
|
|
56
|
+
function scrollerOf(node) {
|
|
57
|
+
for (let el = node.parentElement; el !== null; el = el.parentElement) {
|
|
58
|
+
if (el === document.body || el === document.documentElement)
|
|
59
|
+
break;
|
|
60
|
+
const { overflowY } = getComputedStyle(el);
|
|
61
|
+
const scrolls = overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay";
|
|
62
|
+
/* A box that is `overflow-auto` only because the table is WIDER than it grows with
|
|
63
|
+
its content and never scrolls down: it is not the one. */
|
|
64
|
+
if (scrolls && el.scrollHeight > el.clientHeight + 1)
|
|
65
|
+
return el;
|
|
66
|
+
}
|
|
67
|
+
return "page";
|
|
68
|
+
}
|
|
69
|
+
/** Where the body starts inside the scroller's content — what the window counts from. */
|
|
70
|
+
function offsetIn(body, scroller) {
|
|
71
|
+
const top = body.getBoundingClientRect().top;
|
|
72
|
+
if (scroller === "page")
|
|
73
|
+
return top + window.scrollY;
|
|
74
|
+
return top - scroller.getBoundingClientRect().top + scroller.scrollTop;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Which rows of `count` to draw. Spread `body` on the `<tbody>`, draw `rows` in the
|
|
78
|
+
* order given — `windowed(i)` on each one's `<tr>` — with `pad(...)` between them, and
|
|
79
|
+
* pass `rowCount` to the table.
|
|
80
|
+
*
|
|
81
|
+
* `rowsBefore` is how many rows the table has above the body (the header), so the
|
|
82
|
+
* `aria-rowindex` of the rows counts from there.
|
|
83
|
+
*/
|
|
84
|
+
export function useRowWindow({ count, rowsBefore = 1, getKey, }) {
|
|
85
|
+
const on = count >= WINDOW_FROM;
|
|
86
|
+
const body = useRef(null);
|
|
87
|
+
const [scroller, setScroller] = useState(null);
|
|
88
|
+
const [margin, setMargin] = useState(0);
|
|
89
|
+
const pinned = useRef(null);
|
|
90
|
+
/* Every render, before paint: which box scrolls and where the body starts in it. The
|
|
91
|
+
window re-renders on scroll, so what moves above the table — metrics that loaded,
|
|
92
|
+
chips that wrapped — is caught on the next one. Set only on a real change: each set
|
|
93
|
+
is one more render. No dependency list on purpose: what it measures is the layout,
|
|
94
|
+
which no prop of this hook describes; it settles because it sets only on a change. */
|
|
95
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
const node = body.current;
|
|
98
|
+
if (!on || node === null)
|
|
99
|
+
return;
|
|
100
|
+
const found = scrollerOf(node);
|
|
101
|
+
if (found !== scroller)
|
|
102
|
+
setScroller(found);
|
|
103
|
+
const at = offsetIn(node, found);
|
|
104
|
+
if (Math.abs(at - margin) > 1)
|
|
105
|
+
setMargin(at);
|
|
106
|
+
});
|
|
107
|
+
const options = {
|
|
108
|
+
count,
|
|
109
|
+
estimateSize: () => ESTIMATE,
|
|
110
|
+
overscan: OVERSCAN,
|
|
111
|
+
scrollMargin: margin,
|
|
112
|
+
getItemKey: getKey,
|
|
113
|
+
rangeExtractor: (range) => {
|
|
114
|
+
const first = Math.max(0, range.startIndex - OVERSCAN);
|
|
115
|
+
const last = Math.min(count - 1, range.endIndex + OVERSCAN);
|
|
116
|
+
const out = [];
|
|
117
|
+
for (let i = first; i <= last; i += 1)
|
|
118
|
+
out.push(i);
|
|
119
|
+
const keep = pinned.current;
|
|
120
|
+
if (keep !== null && keep < count && (keep < first || keep > last)) {
|
|
121
|
+
out.push(keep);
|
|
122
|
+
out.sort((a, b) => a - b);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
const inBox = useVirtualizer({
|
|
128
|
+
...options,
|
|
129
|
+
enabled: on && scroller !== null && scroller !== "page",
|
|
130
|
+
getScrollElement: () => (scroller === null || scroller === "page" ? null : scroller),
|
|
131
|
+
});
|
|
132
|
+
const inPage = useWindowVirtualizer({
|
|
133
|
+
...options,
|
|
134
|
+
enabled: on && scroller === "page",
|
|
135
|
+
});
|
|
136
|
+
const v = scroller === "page" ? inPage : inBox;
|
|
137
|
+
const all = () => Array.from({ length: count }, (_, index) => ({
|
|
138
|
+
index,
|
|
139
|
+
start: index * ESTIMATE,
|
|
140
|
+
end: (index + 1) * ESTIMATE,
|
|
141
|
+
}));
|
|
142
|
+
/* The positions come back in the scroller's coordinates; the spacers live inside the
|
|
143
|
+
body, so they are counted from where it starts. */
|
|
144
|
+
const rows = !on
|
|
145
|
+
? all()
|
|
146
|
+
: scroller === null
|
|
147
|
+
? all().slice(0, FIRST_FRAME)
|
|
148
|
+
: v.getVirtualItems().map((item) => ({
|
|
149
|
+
index: item.index,
|
|
150
|
+
start: item.start - margin,
|
|
151
|
+
end: item.end - margin,
|
|
152
|
+
}));
|
|
153
|
+
const total = !on ? 0 : scroller === null ? count * ESTIMATE : v.getTotalSize();
|
|
154
|
+
const spacer = (height, columns, key) => height <= 0 ? null : (_jsx("tr", { "aria-hidden": true, style: { height }, children: _jsx("td", { colSpan: columns, className: "p-0" }) }, key));
|
|
155
|
+
return {
|
|
156
|
+
on,
|
|
157
|
+
body: { ref: body },
|
|
158
|
+
rows,
|
|
159
|
+
windowed: (index) => !on
|
|
160
|
+
? undefined
|
|
161
|
+
: {
|
|
162
|
+
ref: scroller === null ? () => { } : v.measureElement,
|
|
163
|
+
"data-index": index,
|
|
164
|
+
"aria-rowindex": index + rowsBefore + 1,
|
|
165
|
+
onFocus: () => {
|
|
166
|
+
pinned.current = index;
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
pad: (from, to, columns, key) => (on ? spacer(to - from, columns, key) : null),
|
|
170
|
+
tail: (columns) => {
|
|
171
|
+
if (!on)
|
|
172
|
+
return null;
|
|
173
|
+
const end = rows.length === 0 ? 0 : rows[rows.length - 1].end;
|
|
174
|
+
return spacer(total - end, columns, "\u0000tail");
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -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,
|
|
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) &&
|
|
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.
|
|
3
|
+
"version": "1.2.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",
|