@sincpro/web-ui 1.6.0 → 1.7.1

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 (108) hide show
  1. package/dist/components/attachments/Attachments.d.ts +56 -0
  2. package/dist/components/attachments/Attachments.js +93 -0
  3. package/dist/components/attachments/files.d.ts +28 -0
  4. package/dist/components/attachments/files.js +100 -0
  5. package/dist/components/attachments/index.d.ts +2 -0
  6. package/dist/components/attachments/index.js +3 -0
  7. package/dist/components/flow/StepFlow.d.ts +37 -0
  8. package/dist/components/flow/StepFlow.js +74 -0
  9. package/dist/components/flow/index.d.ts +1 -0
  10. package/dist/components/flow/index.js +2 -0
  11. package/dist/components/index.d.ts +2 -0
  12. package/dist/components/index.js +2 -0
  13. package/dist/components/lines/LineItems.js +55 -45
  14. package/dist/components/listing/SortMenu.d.ts +24 -20
  15. package/dist/components/listing/SortMenu.js +51 -91
  16. package/dist/components/listing/index.d.ts +2 -1
  17. package/dist/components/listing/index.js +2 -1
  18. package/dist/components/listing/modelCells.d.ts +7 -0
  19. package/dist/components/listing/modelCells.js +56 -0
  20. package/dist/components/listing/modelColumns.d.ts +9 -0
  21. package/dist/components/listing/modelColumns.js +59 -14
  22. package/dist/components/search/index.d.ts +1 -1
  23. package/dist/components/search/index.js +1 -1
  24. package/dist/components/search/model.d.ts +1 -0
  25. package/dist/components/search/model.js +7 -0
  26. package/dist/components/search/query.js +2 -3
  27. package/dist/foundations/copy.d.ts +131 -0
  28. package/dist/foundations/copy.es.js +86 -0
  29. package/dist/foundations/copy.js +86 -0
  30. package/dist/foundations/form.d.ts +25 -0
  31. package/dist/foundations/form.js +27 -0
  32. package/dist/foundations/index.d.ts +1 -1
  33. package/dist/foundations/index.js +1 -1
  34. package/dist/primitives/button/Button.js +3 -1
  35. package/dist/primitives/choice/ComboBox.js +2 -2
  36. package/dist/primitives/choice/SearchField.js +2 -5
  37. package/dist/primitives/choice/SegmentedControl.js +2 -2
  38. package/dist/primitives/choice/Slider.js +2 -2
  39. package/dist/primitives/date/DatePicker.js +4 -4
  40. package/dist/primitives/date/DateTime.js +3 -3
  41. package/dist/primitives/field/CardNumberInput.d.ts +41 -0
  42. package/dist/primitives/field/CardNumberInput.js +117 -0
  43. package/dist/primitives/field/CodeInput.d.ts +18 -0
  44. package/dist/primitives/field/CodeInput.js +37 -0
  45. package/dist/primitives/field/ColorInput.d.ts +21 -0
  46. package/dist/primitives/field/ColorInput.js +42 -0
  47. package/dist/primitives/field/ContactInputs.d.ts +18 -0
  48. package/dist/primitives/field/ContactInputs.js +70 -0
  49. package/dist/primitives/field/DurationInput.d.ts +20 -0
  50. package/dist/primitives/field/DurationInput.js +54 -0
  51. package/dist/primitives/field/Field.d.ts +26 -5
  52. package/dist/primitives/field/Field.js +19 -6
  53. package/dist/primitives/field/ImageInput.d.ts +51 -0
  54. package/dist/primitives/field/ImageInput.js +267 -0
  55. package/dist/primitives/field/PasswordInput.d.ts +29 -0
  56. package/dist/primitives/field/PasswordInput.js +44 -0
  57. package/dist/primitives/field/PatternInput.d.ts +23 -0
  58. package/dist/primitives/field/PatternInput.js +48 -0
  59. package/dist/primitives/field/RichTextEditor.d.ts +18 -0
  60. package/dist/primitives/field/RichTextEditor.js +224 -0
  61. package/dist/primitives/field/RichTextInput.d.ts +30 -0
  62. package/dist/primitives/field/RichTextInput.js +38 -0
  63. package/dist/primitives/field/SignatureInput.d.ts +34 -0
  64. package/dist/primitives/field/SignatureInput.js +215 -0
  65. package/dist/primitives/field/Suggest.js +2 -2
  66. package/dist/primitives/field/card.d.ts +33 -0
  67. package/dist/primitives/field/card.js +173 -0
  68. package/dist/primitives/field/clipboard.js +3 -2
  69. package/dist/primitives/field/contact.d.ts +17 -0
  70. package/dist/primitives/field/contact.js +65 -0
  71. package/dist/primitives/field/duration.d.ts +5 -0
  72. package/dist/primitives/field/duration.js +48 -0
  73. package/dist/primitives/field/index.d.ts +14 -0
  74. package/dist/primitives/field/index.js +20 -0
  75. package/dist/primitives/field/mask.d.ts +33 -0
  76. package/dist/primitives/field/mask.js +56 -0
  77. package/dist/primitives/field/media.d.ts +99 -0
  78. package/dist/primitives/field/media.js +136 -0
  79. package/dist/primitives/field/pattern.d.ts +10 -0
  80. package/dist/primitives/field/pattern.js +68 -0
  81. package/dist/primitives/menu/Menu.d.ts +16 -0
  82. package/dist/primitives/menu/Menu.js +26 -8
  83. package/dist/primitives/menu/index.d.ts +1 -1
  84. package/dist/primitives/menu/index.js +1 -1
  85. package/dist/primitives/picker/Picker.js +2 -2
  86. package/dist/primitives/picker/TagsField.js +2 -2
  87. package/dist/primitives/table/DataTable.d.ts +6 -4
  88. package/dist/primitives/table/DataTable.js +12 -8
  89. package/dist/primitives/table/EditableTable.js +102 -99
  90. package/dist/primitives/table/Table.d.ts +5 -8
  91. package/dist/primitives/table/Table.js +63 -15
  92. package/dist/primitives/table/index.d.ts +2 -1
  93. package/dist/primitives/table/index.js +2 -1
  94. package/dist/primitives/table/sorting.d.ts +42 -0
  95. package/dist/primitives/table/sorting.js +110 -0
  96. package/dist/primitives/value/RichText.d.ts +10 -0
  97. package/dist/primitives/value/RichText.js +130 -0
  98. package/dist/primitives/value/Value.d.ts +20 -2
  99. package/dist/primitives/value/Value.js +26 -7
  100. package/dist/primitives/value/index.d.ts +3 -1
  101. package/dist/primitives/value/index.js +4 -1
  102. package/dist/primitives/value/rounding.d.ts +9 -0
  103. package/dist/primitives/value/rounding.js +48 -0
  104. package/dist/tokens.css +103 -0
  105. package/dist/views/list/List.d.ts +5 -4
  106. package/dist/views/list/ListView.js +2 -2
  107. package/dist/views/list/types.d.ts +6 -6
  108. package/package.json +9 -1
@@ -1,25 +1,30 @@
1
1
  import type { Surface } from "../../foundations/types.js";
2
- import type { SortState } from "../../primitives/table/Table.js";
2
+ import { type SortState } from "../../primitives/table/sorting.js";
3
3
  /**
4
- * ORDENAR, cuando no hay encabezados donde hacer clic.
4
+ * ORDENAR — por uno o por varios campos, desde la barra.
5
5
  *
6
6
  * Context: ordenar era una capacidad de `DataTable` y de nadie más. Se hacía
7
7
  * clic en el encabezado de la columna, que es la forma correcta **cuando hay
8
8
  * encabezados**. En filas, en tablero y en la planilla no los hay, así que
9
9
  * cambiar de presentación no perdía sólo el orden puesto: perdía la posibilidad
10
- * de ordenar. Es exactamente el mismo drift que tenía la agrupación.
10
+ * de ordenar.
11
11
  *
12
12
  * El orden es del CONJUNTO y no de la presentación, igual que los niveles y que
13
13
  * lo plegado. Por eso el control vive en la barra y no adentro de la tabla, y
14
14
  * por eso la tabla sigue teniendo además sus encabezados: son dos puertas a lo
15
15
  * mismo, y la de los encabezados es más rápida cuando existe.
16
16
  *
17
- * ── Por qué es un `Menu` y el agrupador no ──────────────────────────
17
+ * ── Varios criterios, y por eso un panel y no un menú ───────────────
18
18
  *
19
- * Porque ordenar SÍ es elegir una opción: un campo y una dirección. No hay orden
20
- * ni jerarquía que expresar —ordenar por dos campos a la vez es un desempate,
21
- * que es otra pregunta y la hace el motor— así que una lista de tildes alcanza y
22
- * no hace falta un editor.
19
+ * Se decía que ordenar por dos campos era «un desempate que hace el motor» y
20
+ * que alcanzaba con elegir uno. La gente pidió lo contrario en la pantalla: por
21
+ * fecha, y en la misma fecha por cliente, y después por producto. El motor ya
22
+ * lo aceptaba (`order: [...]`); faltaba poder decirlo. Un orden de N campos
23
+ * tiene ORDEN entre ellos —el segundo sólo decide entre las filas que el
24
+ * primero dejó iguales—, y eso un menú de tildes no lo expresa: hace falta una
25
+ * lista que se reordena. Es la de React Aria (`GridList` + `useDragAndDrop`):
26
+ * se arrastra con el mouse, y con el teclado desde el asa (Enter, flechas,
27
+ * Enter), anunciado.
23
28
  */
24
29
  export type SortField<T = unknown> = {
25
30
  field: string;
@@ -33,7 +38,7 @@ export type SortField<T = unknown> = {
33
38
  * falso— así que ahí el orden viaja en la lectura y esto sólo lo refleja.
34
39
  */
35
40
  value?: (item: T) => string | number | null | undefined;
36
- /** Qué quiere decir «ascendente» para este campo. Sin esto, «A → Z».
41
+ /** Qué quiere decir «ascendente» para este campo. Sin esto, «ascendente».
37
42
  * Una fecha asciende de vieja a nueva y un importe de chico a grande, y
38
43
  * decirlo evita el clic de prueba. */
39
44
  ascLabel?: string;
@@ -42,21 +47,20 @@ export type SortField<T = unknown> = {
42
47
  export type SortMenuProps = {
43
48
  /** Por qué campos se puede ordenar. Sin ninguno, el control no se dibuja. */
44
49
  fields: readonly SortField<never>[];
45
- sort: SortState | null;
46
- onSortChange: (next: SortState | null) => void;
50
+ /** El orden, de afuera hacia adentro. Vacío es «como llegaron». */
51
+ sort: readonly SortState[];
52
+ onSortChange: (next: SortState[]) => void;
47
53
  surface?: Surface;
48
54
  };
49
55
  /**
50
- * Las filas, ordenadas acá.
56
+ * Las filas, ordenadas acá, por cada criterio en su orden.
51
57
  *
52
- * Sólo cuando el campo activo declara `value`; si no, se devuelven tal cual —
53
- * ordenar una página de un conjunto que se pagina afuera produce un orden que es
54
- * mentira, porque ordena las doce que llegaron y no las ciento ochenta y siete.
58
+ * Sólo por los campos que declaran `value`; los demás se saltean —ordenar una
59
+ * página de un conjunto que se pagina afuera produce un orden que es mentira,
60
+ * porque ordena las doce que llegaron y no las ciento ochenta y siete. Sin
61
+ * ningún criterio legible, se devuelven tal cual.
55
62
  *
56
- * Los faltantes van SIEMPRE al final, suba o baje el orden. Es lo que hace
57
- * cualquier planilla, y la razón es que un vacío no es «el más chico»: es la
58
- * ausencia de respuesta, y ponerlo primero al invertir el orden llena la primera
59
- * pantalla con las filas que menos dicen.
63
+ * Los faltantes van SIEMPRE al final, suba o baje el orden (ver `compareBy`).
60
64
  */
61
- export declare function sortItems<T>(items: readonly T[], fields: readonly SortField<T>[], sort: SortState | null | undefined): readonly T[];
65
+ export declare function sortItems<T>(items: readonly T[], fields: readonly SortField<T>[], sort: readonly SortState[] | undefined): readonly T[];
62
66
  export declare function SortMenu({ fields, sort, onSortChange, surface }: SortMenuProps): import("react").JSX.Element | null;
@@ -1,105 +1,65 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useUiCopy } from "../../foundations/copy.js";
4
- import { Menu } from "../../primitives/menu/Menu.js";
5
- import { ArrowDownUp, ArrowDownWideNarrow, ArrowUpNarrowWide } from "lucide-react";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
4
+ import { Menu, menuTriggerClasses } from "../../primitives/menu/Menu.js";
5
+ import { Popover } from "../../primitives/overlay/Popover.js";
6
+ import { compareBy, flipSort, moveSort, } from "../../primitives/table/sorting.js";
7
+ import { ArrowDownUp, ArrowDownWideNarrow, ArrowUpNarrowWide, ChevronDown, GripVertical, Plus, X, } from "lucide-react";
8
+ import { Button as AriaButton, Dialog, DialogTrigger, GridList, GridListItem, useDragAndDrop, } from "react-aria-components";
6
9
  /**
7
- * Las filas, ordenadas acá.
10
+ * Las filas, ordenadas acá, por cada criterio en su orden.
8
11
  *
9
- * Sólo cuando el campo activo declara `value`; si no, se devuelven tal cual —
10
- * ordenar una página de un conjunto que se pagina afuera produce un orden que es
11
- * mentira, porque ordena las doce que llegaron y no las ciento ochenta y siete.
12
+ * Sólo por los campos que declaran `value`; los demás se saltean —ordenar una
13
+ * página de un conjunto que se pagina afuera produce un orden que es mentira,
14
+ * porque ordena las doce que llegaron y no las ciento ochenta y siete. Sin
15
+ * ningún criterio legible, se devuelven tal cual.
12
16
  *
13
- * Los faltantes van SIEMPRE al final, suba o baje el orden. Es lo que hace
14
- * cualquier planilla, y la razón es que un vacío no es «el más chico»: es la
15
- * ausencia de respuesta, y ponerlo primero al invertir el orden llena la primera
16
- * pantalla con las filas que menos dicen.
17
+ * Los faltantes van SIEMPRE al final, suba o baje el orden (ver `compareBy`).
17
18
  */
18
19
  export function sortItems(items, fields, sort) {
19
- if (sort === null || sort === undefined)
20
+ if (sort === undefined || sort.length === 0)
20
21
  return items;
21
- const field = fields.find((one) => one.field === sort.key);
22
- if (field?.value === undefined)
22
+ const read = (key) => fields.find((one) => one.field === key)?.value;
23
+ if (!sort.some((one) => read(one.key) !== undefined))
23
24
  return items;
24
- const read = field.value;
25
- const sign = sort.direction === "asc" ? 1 : -1;
26
- return [...items].sort((a, b) => {
27
- const left = read(a);
28
- const right = read(b);
29
- const leftMissing = left === null || left === undefined || left === "";
30
- const rightMissing = right === null || right === undefined || right === "";
31
- if (leftMissing && rightMissing)
32
- return 0;
33
- if (leftMissing)
34
- return 1;
35
- if (rightMissing)
36
- return -1;
37
- if (typeof left === "number" && typeof right === "number")
38
- return (left - right) * sign;
39
- return String(left).localeCompare(String(right)) * sign;
40
- });
25
+ return [...items].sort(compareBy(sort, read));
41
26
  }
42
27
  export function SortMenu({ fields, sort, onSortChange, surface }) {
43
28
  const copy = useUiCopy();
29
+ const fieldOf = (key) => fields.find((one) => one.field === key);
30
+ const labelOf = (key) => fieldOf(key)?.label ?? key;
31
+ const { dragAndDropHooks } = useDragAndDrop({
32
+ getItems: (keys) => [...keys].map((key) => ({ "text/plain": labelOf(String(key)) })),
33
+ onReorder: (event) => onSortChange(moveSort(sort, new Set([...event.keys].map(String)), String(event.target.key), event.target.dropPosition === "before" ? "before" : "after")),
34
+ });
44
35
  if (fields.length === 0)
45
36
  return null;
46
- const current = fields.find((one) => one.field === sort?.key);
47
- const groups = [
48
- {
49
- label: copy.sorting.by,
50
- options: fields.map((one) => ({
51
- id: one.field,
52
- label: one.label,
53
- checked: one.field === sort?.key,
54
- })),
55
- },
56
- /* La dirección sólo cuando hay algo ordenado: «descendente» sin campo no
57
- ordena nada, y una opción que no hace nada es una opción que se prueba. */
58
- ...(current === undefined
59
- ? []
60
- : [
61
- {
62
- options: [
63
- {
64
- id: "__asc__",
65
- label: current.ascLabel ?? copy.sorting.asc,
66
- icon: ArrowUpNarrowWide,
67
- checked: sort?.direction === "asc",
68
- },
69
- {
70
- id: "__desc__",
71
- label: current.descLabel ?? copy.sorting.desc,
72
- icon: ArrowDownWideNarrow,
73
- checked: sort?.direction === "desc",
74
- },
75
- ],
76
- },
77
- { options: [{ id: "__none__", label: copy.sorting.clear }] },
78
- ]),
79
- ];
80
- return (_jsx(Menu, { label: current === undefined ? copy.sorting.label : current.label, icon: sort === null || sort === undefined
81
- ? ArrowDownUp
82
- : sort.direction === "asc"
83
- ? ArrowUpNarrowWide
84
- : ArrowDownWideNarrow, groups: groups, active: sort !== null && sort !== undefined, size: "sm", width: "sm", closeOnSelect: false, ...(surface === undefined ? {} : { surface }), onSelect: (id) => {
85
- if (id === "__none__")
86
- return onSortChange(null);
87
- if (id === "__asc__") {
88
- return onSortChange(sort === null ? null : { ...sort, direction: "asc" });
89
- }
90
- if (id === "__desc__") {
91
- return onSortChange(sort === null ? null : { ...sort, direction: "desc" });
92
- }
93
- /* Volver a elegir el campo que ya está puesto DA VUELTA la dirección, que
94
- es lo que hace el clic en un encabezado. Sin eso, invertir el orden son
95
- dos gestos en un menú y uno en la tabla — la misma capacidad costando
96
- distinto según la presentación. */
97
- if (sort?.key === id) {
98
- return onSortChange({
99
- key: id,
100
- direction: sort.direction === "asc" ? "desc" : "asc",
101
- });
102
- }
103
- onSortChange({ key: id, direction: "asc" });
104
- } }));
37
+ const first = sort[0];
38
+ const label = first === undefined
39
+ ? copy.sorting.label
40
+ : sort.length === 1
41
+ ? labelOf(first.key)
42
+ : fillCopy(copy.sorting.more, { label: labelOf(first.key), count: sort.length - 1 });
43
+ const TriggerIcon = first === undefined
44
+ ? ArrowDownUp
45
+ : first.direction === "asc"
46
+ ? ArrowUpNarrowWide
47
+ : ArrowDownWideNarrow;
48
+ const left = fields.filter((one) => !sort.some((s) => s.key === one.field));
49
+ return (_jsxs(DialogTrigger, { children: [_jsxs(AriaButton, { className: ({ isPressed, isFocusVisible }) => menuTriggerClasses({
50
+ ...(surface === undefined ? {} : { surface }),
51
+ active: sort.length > 0,
52
+ open: isPressed,
53
+ focusVisible: isFocusVisible,
54
+ }), children: [_jsx(TriggerIcon, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true }), label, _jsx(ChevronDown, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true })] }), _jsx(Popover, { placement: "bottom start", className: "w-80", children: _jsxs(Dialog, { "aria-label": copy.sorting.label, className: "flex flex-col gap-1 p-2 outline-0", children: [sort.length === 0 ? (_jsx("p", { className: "px-1 py-1 text-meta text-muted", children: copy.sorting.none })) : (_jsx(GridList, { "aria-label": copy.sorting.label, items: sort.map((one, index) => ({ ...one, id: one.key, index })), dragAndDropHooks: dragAndDropHooks, className: "flex flex-col outline-0", children: (item) => {
55
+ const field = fieldOf(item.key);
56
+ const name = labelOf(item.key);
57
+ const direction = item.direction === "asc"
58
+ ? (field?.ascLabel ?? copy.sorting.asc)
59
+ : (field?.descLabel ?? copy.sorting.desc);
60
+ const DirectionIcon = item.direction === "asc" ? ArrowUpNarrowWide : ArrowDownWideNarrow;
61
+ return (_jsxs(GridListItem, { id: item.id, textValue: `${item.index + 1}. ${name}, ${direction}`, className: "flex min-w-0 items-center gap-1.5 rounded-control px-1 py-1 outline-0 transition-colors duration-(--duration-instant) data-dragging:opacity-50 data-drop-target:bg-primary/8 data-focus-visible:outline-2 data-focus-visible:-outline-offset-2 data-focus-visible:outline-primary data-hovered:bg-ink/4", children: [_jsx(AriaButton, { slot: "drag", "aria-label": fillCopy(copy.sorting.move, { field: name }), className: "inline-flex shrink-0 cursor-grab items-center rounded-mark p-0.5 text-muted outline-0 hover:text-ink data-focus-visible:outline-2 data-focus-visible:outline-primary", children: _jsx(GripVertical, { className: "size-(--size-icon-sm)", strokeWidth: 2 }) }), _jsx("span", { className: "w-16 shrink-0 text-micro text-muted", children: item.index === 0 ? copy.sorting.by : copy.sorting.then }), _jsx("span", { className: "min-w-0 flex-1 truncate text-meta text-ink", children: name }), _jsxs(AriaButton, { "aria-label": `${fillCopy(copy.sorting.flip, { field: name })}: ${direction}`, onPress: () => onSortChange(flipSort(sort, item.index)), className: "inline-flex shrink-0 items-center gap-1 rounded-mark px-1 py-0.5 text-micro text-primary outline-0 hover:bg-primary/8 data-focus-visible:outline-2 data-focus-visible:outline-primary", children: [_jsx(DirectionIcon, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true }), direction] }), _jsx(AriaButton, { "aria-label": fillCopy(copy.sorting.remove, { field: name }), onPress: () => onSortChange(sort.filter((_, i) => i !== item.index)), className: "inline-flex shrink-0 items-center rounded-mark p-0.5 text-muted outline-0 hover:bg-ink/6 hover:text-ink data-focus-visible:outline-2 data-focus-visible:outline-primary", children: _jsx(X, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true }) })] }));
62
+ } })), _jsxs("div", { className: "flex items-center justify-between gap-2 border-t border-line pt-2", children: [left.length === 0 ? (_jsx("span", {})) : (_jsx(Menu, { label: copy.sorting.add, icon: Plus, variant: "text", chevron: false, width: "sm", groups: [
63
+ { options: left.map((one) => ({ id: one.field, label: one.label })) },
64
+ ], onSelect: (id) => onSortChange([...sort, { key: id, direction: "asc" }]) })), sort.length === 0 ? null : (_jsx(AriaButton, { onPress: () => onSortChange([]), className: "rounded-mark px-1.5 py-0.5 text-micro text-muted outline-0 hover:bg-ink/6 hover:text-ink data-focus-visible:outline-2 data-focus-visible:outline-primary", children: copy.sorting.clear }))] })] }) })] }));
105
65
  }
@@ -2,7 +2,8 @@ export { type ColumnControl, type ColumnSpec, defaultVisibleIds, reconcileVisibl
2
2
  export { ColumnsMenu, specsFromColumns, } from "./ColumnsMenu.js";
3
3
  export { Listing } from "./Listing.js";
4
4
  export { type ListIdentity, ListingBar } from "./ListingBar.js";
5
- export { type ClaimedField, claimedFields, columnsFromModel, readModelValue, } from "./modelColumns.js";
5
+ export { type ModelCellKind } from "./modelCells.js";
6
+ export { type ClaimedField, claimedFields, columnsFromModel, modelCellKind, readModelValue, } from "./modelColumns.js";
6
7
  export { type CursorSource, DEFAULT_PAGE_SIZE, PAGE_SIZES, type Pages, type PaginationBridge, type ServerPage, useCursorPagination, usePagination, } from "./pages.js";
7
8
  export { Pagination } from "./Pagination.js";
8
9
  export { RecordLink } from "./RecordLink.js";
@@ -4,7 +4,8 @@ export { defaultVisibleIds, reconcileVisible, toggleColumn, useColumnControl, vi
4
4
  export { ColumnsMenu, specsFromColumns, } from "./ColumnsMenu.js";
5
5
  export { Listing } from "./Listing.js";
6
6
  export { ListingBar } from "./ListingBar.js";
7
- export { claimedFields, columnsFromModel, readModelValue, } from "./modelColumns.js";
7
+ export {} from "./modelCells.js";
8
+ export { claimedFields, columnsFromModel, modelCellKind, readModelValue, } from "./modelColumns.js";
8
9
  export { DEFAULT_PAGE_SIZE, PAGE_SIZES, useCursorPagination, usePagination, } from "./pages.js";
9
10
  export { Pagination } from "./Pagination.js";
10
11
  export { RecordLink } from "./RecordLink.js";
@@ -0,0 +1,7 @@
1
+ /** How a model field is drawn. `text` is today's behaviour, for any type not known here. */
2
+ export type ModelCellKind = "text" | "number" | "email" | "url" | "phone" | "duration" | "boolean" | "date" | "datetime";
3
+ /** One cell of a model column. `value` is what `readModelValue` read — never `null` here. */
4
+ export declare function ModelCell({ kind, value, }: {
5
+ kind: ModelCellKind;
6
+ value: string | number | boolean;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useUiCopy } from "../../foundations/copy.js";
4
+ import { emailHref, telHref, urlHref, urlLabel, } from "../../primitives/field/contact.js";
5
+ import { formatDuration } from "../../primitives/field/duration.js";
6
+ import { DateValue } from "../../primitives/value/DateValue.js";
7
+ import { Numeric } from "../../primitives/value/Value.js";
8
+ import { Check } from "lucide-react";
9
+ const LINK = "block truncate rounded-mark text-primary underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary";
10
+ function Plain({ text }) {
11
+ return (_jsx("span", { className: "block truncate text-muted", title: text, children: text }));
12
+ }
13
+ /** A link in a cell, or the plain text when the value does not make one. */
14
+ function Link({ text, href, label, external = false, }) {
15
+ const copy = useUiCopy();
16
+ if (href === null)
17
+ return _jsx(Plain, { text: text });
18
+ return (_jsxs("a", { href: href, title: text, className: LINK,
19
+ /* The row opens its record on a click; writing to its email must not ALSO open it. */
20
+ onClick: (event) => event.stopPropagation(), ...(external ? { target: "_blank", rel: "noopener noreferrer" } : {}), children: [label ?? text, external ? _jsxs("span", { className: "sr-only", children: [" (", copy.field.newTab, ")"] }) : null] }));
21
+ }
22
+ /** A yes/no in words: a check alone is not read by everyone the same way, and «no» needs
23
+ * no icon — an empty mark next to every false row would only add noise to the column. */
24
+ function YesNo({ value }) {
25
+ const copy = useUiCopy();
26
+ if (!value)
27
+ return _jsx("span", { className: "text-muted", children: copy.table.no });
28
+ return (_jsxs("span", { className: "inline-flex items-center gap-1 text-ink", children: [_jsx(Check, { "aria-hidden": true, className: "size-(--size-icon-sm) text-good", strokeWidth: 2.5 }), copy.table.yes] }));
29
+ }
30
+ /** One cell of a model column. `value` is what `readModelValue` read — never `null` here. */
31
+ export function ModelCell({ kind, value, }) {
32
+ const text = String(value);
33
+ switch (kind) {
34
+ case "number":
35
+ /* A figure in the locale's digits and right aligned, so a column of amounts lines up
36
+ — never «1234.5» as text. */
37
+ return typeof value === "number" ? (_jsx(Numeric, { value: value, format: "decimal" })) : (_jsx(Plain, { text: text }));
38
+ case "email":
39
+ return _jsx(Link, { text: text, href: emailHref(text) });
40
+ case "phone":
41
+ return _jsx(Link, { text: text, href: telHref(text) });
42
+ case "url":
43
+ return _jsx(Link, { text: text, href: urlHref(text), label: urlLabel(text), external: true });
44
+ case "duration":
45
+ return typeof value === "number" ? (
46
+ /* Right aligned like `Numeric`: a column of hours lines up by the colon. */
47
+ _jsx("span", { className: "block text-right text-body tabular-nums whitespace-nowrap text-ink", children: formatDuration(value) })) : (_jsx(Plain, { text: text }));
48
+ case "boolean":
49
+ return typeof value === "boolean" ? _jsx(YesNo, { value: value }) : _jsx(Plain, { text: text });
50
+ case "date":
51
+ case "datetime":
52
+ return typeof value === "boolean" ? (_jsx(Plain, { text: text })) : (_jsx(DateValue, { when: value, format: kind === "date" ? "date" : "relative" }));
53
+ default:
54
+ return _jsx(Plain, { text: text });
55
+ }
56
+ }
@@ -1,3 +1,4 @@
1
+ import { type ModelCellKind } from "./modelCells.js";
1
2
  import { type ModelDefinition } from "../search/model.js";
2
3
  import type { DataColumn } from "../../primitives/table/DataTable.js";
3
4
  /** Qué campo del modelo muestra una columna declarada a mano.
@@ -28,6 +29,14 @@ export declare function claimedFields(columns: readonly ClaimedField[]): Set<str
28
29
  * Las fechas se dejan como vienen —ISO— porque en ISO el orden alfabético ES
29
30
  * el cronológico, y así la columna ordena bien sin parsear nada. */
30
31
  export declare function readModelValue(raw: unknown): string | number | boolean | null;
32
+ /** How a field of the model is drawn, by its `type` and in any case — «Email» and `email` are
33
+ * the same field. A type not known here stays `text`, exactly as before.
34
+ *
35
+ * in "monetary" out "number"
36
+ * in "float_time" out "duration" «8:30», not «8.5»
37
+ * in "jsonb" out "text"
38
+ */
39
+ export declare function modelCellKind(type: string): ModelCellKind;
31
40
  /**
32
41
  * Las columnas declaradas, más una por cada campo del modelo que ninguna
33
42
  * contestaba.
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { fieldLabel } from "../search/model.js";
2
+ import { ModelCell } from "./modelCells.js";
3
+ import { fieldLabel, isNumericType, } from "../search/model.js";
4
+ import { formatDuration } from "../../primitives/field/duration.js";
3
5
  /** Los campos que las columnas declaradas ya contestan.
4
6
  *
5
7
  * in [{id:"rows", field:"row_count"}, {id:"name"}]
@@ -38,14 +40,45 @@ export function readModelValue(raw) {
38
40
  return raw === "" ? null : raw;
39
41
  return JSON.stringify(raw);
40
42
  }
43
+ /** The names an engine publishes for each kind it knows how to draw — Odoo's (`char` with a
44
+ * widget aside, `float_time`, `boolean`, `date`, `datetime`) and the plain ones a schema
45
+ * uses (`email`, `url`, `phone`, `duration`, `bool`, `timestamp`). */
46
+ const KIND_OF_TYPE = {
47
+ email: "email",
48
+ mail: "email",
49
+ url: "url",
50
+ uri: "url",
51
+ website: "url",
52
+ phone: "phone",
53
+ tel: "phone",
54
+ float_time: "duration",
55
+ duration: "duration",
56
+ boolean: "boolean",
57
+ bool: "boolean",
58
+ date: "date",
59
+ datetime: "datetime",
60
+ timestamp: "datetime",
61
+ };
62
+ /** How a field of the model is drawn, by its `type` and in any case — «Email» and `email` are
63
+ * the same field. A type not known here stays `text`, exactly as before.
64
+ *
65
+ * in "monetary" out "number"
66
+ * in "float_time" out "duration" «8:30», not «8.5»
67
+ * in "jsonb" out "text"
68
+ */
69
+ export function modelCellKind(type) {
70
+ if (isNumericType(type))
71
+ return "number";
72
+ return KIND_OF_TYPE[type.toLowerCase()] ?? "text";
73
+ }
41
74
  /** Cuánto ancho merece un campo, por su tipo. Una huella son 64 caracteres y
42
75
  * una cuenta son seis: darles el mismo ancho arruina las dos. */
43
- function widthFor(type) {
44
- if (type === "integer" || type === "number")
76
+ function widthFor(kind) {
77
+ if (kind === "number" || kind === "duration")
45
78
  return 120;
46
- if (type === "datetime" || type === "date")
79
+ if (kind === "datetime" || kind === "date")
47
80
  return 190;
48
- if (type === "boolean")
81
+ if (kind === "boolean")
49
82
  return 90;
50
83
  return 260;
51
84
  }
@@ -76,27 +109,39 @@ export function columnsFromModel(declared, model, locale = "default") {
76
109
  for (const [field, definition] of Object.entries(model.fields)) {
77
110
  if (claimed.has(field))
78
111
  continue;
79
- const numeric = definition.type === "integer" || definition.type === "number";
112
+ const kind = modelCellKind(definition.type);
113
+ /* A duration is a number of hours: it sorts, sums and aligns as one. */
114
+ const numeric = kind === "number" || kind === "duration";
115
+ const read = (row) => readModelValue(row[field]);
80
116
  extra.push({
81
117
  id: field,
82
118
  field,
83
119
  header: fieldLabel(model, field, locale),
84
- value: (row) => readModelValue(row[field]),
85
- /* El valor entero en el `title`: una huella de 64 caracteres no entra en
86
- ninguna celda, y cortada no sirve para nada — salvo para pasar el mouse
87
- por encima y leerla. */
120
+ value: read,
121
+ /* Drawn by what the model says the field IS (`modelCells.tsx`); a plain text keeps
122
+ its whole value in the `title` — a 64 character fingerprint fits no cell. */
88
123
  cell: (row) => {
89
- const value = readModelValue(row[field]);
124
+ const value = read(row);
90
125
  if (value === null)
91
126
  return _jsx("span", { className: "text-subtle", children: "\u2014" });
92
- const text = String(value);
93
- return (_jsx("span", { className: "block truncate text-muted", title: text, children: text }));
127
+ return _jsx(ModelCell, { kind: kind, value: value });
94
128
  },
129
+ /* Where a node cannot be drawn — the sheet, the footer's total — a duration still
130
+ reads «42:30» and never «42.5». */
131
+ ...(kind === "duration"
132
+ ? {
133
+ format: formatDuration,
134
+ text: (row) => {
135
+ const value = read(row);
136
+ return typeof value === "number" ? formatDuration(value) : String(value ?? "");
137
+ },
138
+ }
139
+ : {}),
95
140
  ...(numeric ? { numeric: true } : {}),
96
141
  sortable: definition.sortable,
97
142
  optional: true,
98
143
  defaultHidden: true,
99
- width: widthFor(definition.type),
144
+ width: widthFor(kind),
100
145
  });
101
146
  }
102
147
  return [...declared, ...extra];
@@ -5,6 +5,6 @@ export { formatJson, JsonEditor } from "./JsonEditor.js";
5
5
  export { SearchBar, type SearchBarProps, type SearchFacet, } from "./SearchBar.js";
6
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";
7
7
  export { CriteriaChips, type CriteriaChipsProps, } from "./CriteriaChips.js";
8
- export { type FieldDefinition, fieldLabel, filterableNames, isFilterable, type ModelDefinition, type ModelTranslations, } from "./model.js";
8
+ export { type FieldDefinition, fieldLabel, filterableNames, isFilterable, isNumericType, type ModelDefinition, type ModelTranslations, } from "./model.js";
9
9
  export { answers, defaultOp, describeCondition, globToLike, opLabel, type QueryDraft, type QueryField, queryFields, type QueryKind, type QuerySuggestion, readTyped, suggest, type TypedValue, valueFor, type ValueShape, withoutValue, } from "./query.js";
10
10
  export { type DatePeriod, datePeriodGroup, datePeriodOptions, type DateRange, periodId, readPeriodId, recentMonths, recentYears, resolvePeriods, } from "./datePeriods.js";
@@ -12,7 +12,7 @@ export { CriteriaChips, } from "./CriteriaChips.js";
12
12
  /* EL VOCABULARIO DEL MODELO — lo que el servidor publica sobre sus campos. Lo
13
13
  hablan el constructor de filtros, el de criterias y la barra de búsqueda, así
14
14
  que vive aparte de los tres. */
15
- export { fieldLabel, filterableNames, isFilterable, } from "./model.js";
15
+ export { fieldLabel, filterableNames, isFilterable, isNumericType, } from "./model.js";
16
16
  /* LA GRAMÁTICA DE LA BARRA: qué se puede preguntar sobre un modelo, y con qué.
17
17
  Tres pasos —campo, operador, valor— que se pueden escribir en uno. */
18
18
  export { answers, defaultOp, describeCondition, globToLike, opLabel, queryFields, readTyped, suggest, valueFor, withoutValue, } from "./query.js";
@@ -23,6 +23,7 @@ export interface FieldDefinition {
23
23
  /** Los valores que un campo de enum puede tomar — de ahí sale un `<select>`. */
24
24
  choices?: readonly unknown[];
25
25
  }
26
+ export declare function isNumericType(type: string): boolean;
26
27
  /** Si este campo se puede usar en un `where`, un `order` o un `grouping.by` directamente.
27
28
  * `ops.length === 0` alcanza como señal: la publican tanto `embedded` como los tres
28
29
  * relacionales, así que no hace falta mirar `kind` aparte. */
@@ -13,6 +13,13 @@
13
13
  * para saber cómo se llama un operador. Un vocabulario compartido no puede
14
14
  * colgar de uno de los que lo hablan.
15
15
  * ==================================================================== */
16
+ /** The types an engine publishes for an AMOUNT — integer, float, decimal, money. A search
17
+ * compares them as numbers and a listing aligns and formats them as numbers: «monetary»
18
+ * read as text made «total <= 1000» impossible and drew «1234.5» flush left. */
19
+ const NUMERIC_TYPES = new Set(["integer", "number", "float", "decimal", "monetary", "money"]);
20
+ export function isNumericType(type) {
21
+ return NUMERIC_TYPES.has(type.toLowerCase());
22
+ }
16
23
  /** Si este campo se puede usar en un `where`, un `order` o un `grouping.by` directamente.
17
24
  * `ops.length === 0` alcanza como señal: la publican tanto `embedded` como los tres
18
25
  * relacionales, así que no hace falta mirar `kind` aparte. */
@@ -1,4 +1,4 @@
1
- import { fieldLabel, isFilterable, } from "./model.js";
1
+ import { fieldLabel, isFilterable, isNumericType, } from "./model.js";
2
2
  import { currentCopy, fillCopy } from "../../foundations/copy.js";
3
3
  /**
4
4
  * Las palabras que, después del numeral, abren un GRUPO en vez de nombrar un
@@ -97,7 +97,6 @@ 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"]);
101
100
  /** Qué clase de valor es un campo, leída del tipo que publica el modelo. */
102
101
  function kindOf(definition) {
103
102
  if (definition.kind === "relation")
@@ -107,7 +106,7 @@ function kindOf(definition) {
107
106
  const type = definition.type.toLowerCase();
108
107
  /* An amount is a number: «decimal» and «monetary» read as text made «total <= 1000»
109
108
  impossible to ask of an invoice. */
110
- if (NUMERIC.has(type))
109
+ if (isNumericType(type))
111
110
  return "number";
112
111
  if (type === "date" || type === "datetime")
113
112
  return "date";