@sincpro/web-ui 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +3 -3
  2. package/dist/components/activity/ActivityBadge.d.ts +16 -0
  3. package/dist/components/activity/ActivityBadge.js +29 -0
  4. package/dist/components/activity/index.d.ts +1 -0
  5. package/dist/components/activity/index.js +1 -0
  6. package/dist/components/calendar/RecordCalendar.d.ts +48 -0
  7. package/dist/components/calendar/RecordCalendar.js +101 -0
  8. package/dist/components/calendar/index.d.ts +1 -0
  9. package/dist/components/calendar/index.js +1 -0
  10. package/dist/components/form/Sheet.js +5 -1
  11. package/dist/components/gantt/RecordGantt.d.ts +44 -0
  12. package/dist/components/gantt/RecordGantt.js +110 -0
  13. package/dist/components/gantt/index.d.ts +1 -0
  14. package/dist/components/gantt/index.js +1 -0
  15. package/dist/components/index.d.ts +3 -0
  16. package/dist/components/index.js +3 -0
  17. package/dist/components/lines/LineItems.d.ts +5 -2
  18. package/dist/components/lines/LineItems.js +3 -3
  19. package/dist/components/listing/BoardColumns.d.ts +39 -0
  20. package/dist/components/listing/BoardColumns.js +138 -0
  21. package/dist/components/listing/ListingBar.d.ts +5 -0
  22. package/dist/components/listing/ListingBar.js +5 -2
  23. package/dist/components/listing/PivotExplorer.d.ts +34 -0
  24. package/dist/components/listing/PivotExplorer.js +86 -0
  25. package/dist/components/listing/RecordCard.d.ts +6 -8
  26. package/dist/components/listing/RecordCard.js +13 -7
  27. package/dist/components/listing/Row.d.ts +21 -2
  28. package/dist/components/listing/Row.js +29 -13
  29. package/dist/components/listing/index.d.ts +2 -0
  30. package/dist/components/listing/index.js +2 -0
  31. package/dist/components/listing/set.d.ts +1 -1
  32. package/dist/components/navigation/index.d.ts +0 -2
  33. package/dist/components/navigation/index.js +2 -3
  34. package/dist/components/record/RecordAudit.d.ts +17 -0
  35. package/dist/components/record/RecordAudit.js +27 -0
  36. package/dist/components/record/RecordHeader.d.ts +7 -1
  37. package/dist/components/record/RecordHeader.js +30 -18
  38. package/dist/components/record/RecordRibbon.d.ts +13 -0
  39. package/dist/components/record/RecordRibbon.js +22 -0
  40. package/dist/components/record/index.d.ts +2 -0
  41. package/dist/components/record/index.js +2 -0
  42. package/dist/components/search/CriteriaChips.d.ts +6 -1
  43. package/dist/components/search/CriteriaChips.js +71 -20
  44. package/dist/components/search/SearchBar.d.ts +15 -1
  45. package/dist/components/search/SearchBar.js +34 -16
  46. package/dist/components/search/query.d.ts +14 -11
  47. package/dist/components/search/query.js +36 -3
  48. package/dist/components/timeline/RecordTimeline.d.ts +60 -0
  49. package/dist/components/timeline/RecordTimeline.js +214 -0
  50. package/dist/components/timeline/TimelineToolbar.d.ts +30 -0
  51. package/dist/components/timeline/TimelineToolbar.js +31 -0
  52. package/dist/components/timeline/index.d.ts +3 -0
  53. package/dist/components/timeline/index.js +3 -0
  54. package/dist/components/timeline/time.d.ts +43 -0
  55. package/dist/components/timeline/time.js +135 -0
  56. package/dist/foundations/copy.d.ts +110 -14
  57. package/dist/foundations/copy.es.js +85 -9
  58. package/dist/foundations/copy.js +85 -9
  59. package/dist/foundations/grouping.d.ts +3 -0
  60. package/dist/foundations/index.d.ts +1 -1
  61. package/dist/foundations/match.d.ts +23 -0
  62. package/dist/foundations/match.js +63 -0
  63. package/dist/foundations/slots.d.ts +1 -1
  64. package/dist/foundations/slots.js +8 -3
  65. package/dist/foundations/types.d.ts +7 -0
  66. package/dist/foundations/types.js +0 -36
  67. package/dist/primitives/block/index.d.ts +1 -1
  68. package/dist/primitives/block/index.js +1 -1
  69. package/dist/primitives/cell/CellStack.d.ts +7 -1
  70. package/dist/primitives/cell/CellStack.js +5 -2
  71. package/dist/primitives/choice/ComboBox.js +8 -4
  72. package/dist/primitives/choice/KanbanState.d.ts +14 -0
  73. package/dist/primitives/choice/KanbanState.js +37 -0
  74. package/dist/primitives/choice/Priority.d.ts +20 -0
  75. package/dist/primitives/choice/Priority.js +41 -0
  76. package/dist/primitives/choice/Slider.d.ts +6 -1
  77. package/dist/primitives/choice/Slider.js +2 -2
  78. package/dist/primitives/choice/index.d.ts +2 -0
  79. package/dist/primitives/choice/index.js +2 -0
  80. package/dist/primitives/dialog/Dialog.d.ts +9 -3
  81. package/dist/primitives/dialog/Dialog.js +41 -5
  82. package/dist/primitives/dialog/index.d.ts +0 -1
  83. package/dist/primitives/dialog/index.js +0 -2
  84. package/dist/primitives/notice/index.d.ts +1 -1
  85. package/dist/primitives/notice/index.js +1 -1
  86. package/dist/primitives/overlay/Preview.js +13 -4
  87. package/dist/primitives/picker/MatchedText.d.ts +10 -0
  88. package/dist/primitives/picker/MatchedText.js +12 -0
  89. package/dist/primitives/picker/Picker.d.ts +42 -7
  90. package/dist/primitives/picker/Picker.js +107 -29
  91. package/dist/primitives/table/EditableTable.d.ts +6 -1
  92. package/dist/primitives/table/EditableTable.js +70 -9
  93. package/dist/primitives/table/ReportTable.js +2 -1
  94. package/dist/primitives/table/RowActions.js +7 -2
  95. package/dist/primitives/table/Table.d.ts +5 -0
  96. package/dist/primitives/table/Table.js +17 -8
  97. package/dist/primitives/tag/Tags.d.ts +2 -0
  98. package/dist/primitives/tag/Tags.js +2 -1
  99. package/dist/primitives/tag/TagsField.d.ts +17 -0
  100. package/dist/primitives/tag/TagsField.js +46 -11
  101. package/dist/primitives/tag/choices.d.ts +6 -1
  102. package/dist/primitives/tag/choices.js +6 -2
  103. package/dist/primitives/value/Avatar.d.ts +16 -0
  104. package/dist/primitives/value/Avatar.js +91 -0
  105. package/dist/primitives/value/Person.d.ts +6 -51
  106. package/dist/primitives/value/Person.js +9 -148
  107. package/dist/primitives/value/Relation.d.ts +21 -9
  108. package/dist/primitives/value/Relation.js +35 -24
  109. package/dist/primitives/value/contact.d.ts +30 -0
  110. package/dist/primitives/value/contact.js +51 -0
  111. package/dist/primitives/value/index.d.ts +1 -1
  112. package/dist/primitives/value/index.js +1 -1
  113. package/dist/tokens.css +136 -1
  114. package/dist/views/list/List.d.ts +25 -2
  115. package/dist/views/list/List.js +48 -8
  116. package/dist/views/list/ListView.d.ts +1 -1
  117. package/dist/views/list/ListView.js +40 -9
  118. package/dist/views/list/columns.js +8 -1
  119. package/dist/views/list/presentations.d.ts +5 -2
  120. package/dist/views/list/presentations.js +32 -2
  121. package/dist/views/list/types.d.ts +44 -0
  122. package/dist/views/list/useNarrowList.d.ts +10 -0
  123. package/dist/views/list/useNarrowList.js +20 -6
  124. package/dist/views/record/Record.d.ts +14 -0
  125. package/dist/views/record/Record.js +22 -7
  126. package/package.json +11 -4
  127. package/dist/components/navigation/CommandPalette.d.ts +0 -40
  128. package/dist/components/navigation/CommandPalette.js +0 -114
  129. package/dist/components/navigation/commands.d.ts +0 -30
  130. package/dist/components/navigation/commands.js +0 -30
  131. package/dist/primitives/dialog/Drill.d.ts +0 -90
  132. package/dist/primitives/dialog/Drill.js +0 -172
@@ -1,3 +1,4 @@
1
+ import type { OpenTarget } from "../../foundations/types.js";
1
2
  import { type FormMode } from "../field/mode.js";
2
3
  import type { LucideIcon } from "lucide-react";
3
4
  import { type ReactNode } from "react";
@@ -21,6 +22,13 @@ export interface PickerItem {
21
22
  /** The header this item is listed under. Consecutive items with the same section share
22
23
  * one header; an item without it is listed loose, outside any section. */
23
24
  section?: string;
25
+ /** A person or a company: its face before the name — the photo, or its initials when
26
+ * `true`. Who is being chosen is recognised before it is read, in the list and in the field. */
27
+ avatar?: true | string;
28
+ /** What is read at a glance on the right, in tabular figures: the balance owed, the stock,
29
+ * a state badge, a priority — a widget, not text. Where `meta` describes, this COMPARES:
30
+ * lined up in one column, eight customers' debts are read top to bottom. */
31
+ aside?: ReactNode;
24
32
  }
25
33
  /**
26
34
  * What the list shows and how many are left out of it. Capped at 8 with the footer count,
@@ -39,13 +47,38 @@ export type PickerProps = {
39
47
  items: readonly PickerItem[];
40
48
  value?: string;
41
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;
42
63
  placeholder?: string;
43
- /** What to do with the ones that don't fit. Without this, the footer only
44
- * informs. */
45
- onBrowseAll?: () => void;
64
+ /** «Buscar más…»: the last option of the list, always there when this is — the record
65
+ * may not be among the eight, or two of them may need their columns to tell apart. It
66
+ * gets what was typed, so the list that chooses (`List onChoose`, in a modal of the
67
+ * workspace) opens on the same search. */
68
+ onBrowseAll?: (query: string) => void;
69
+ /** «Crear «…»»: a record made from the typed text alone (Odoo's `name_create`); it returns
70
+ * the new id — or a promise of it — and the field chooses it. Offered while something is
71
+ * typed that no option is called exactly. The consumer appends the new item to `items`. */
72
+ onCreate?: (name: string) => string | Promise<string>;
73
+ /** «Crear y editar…»: the new record needs more than a name — open its form (in a workspace,
74
+ * a modal) with the typed text as the name. Choosing it there is the consumer's. */
75
+ onCreateAndEdit?: (name: string) => void;
46
76
  /** Open the chosen record. Drawn as a trailing affordance, the way a many2one
47
77
  * offers "open" next to "pick". Absent when there is nothing chosen. */
48
- onOpen?: (id: string) => void;
78
+ onOpen?: (id: string, to: OpenTarget) => void;
79
+ /** Where the chosen record can open, the first being the button's (a modal, by default: the
80
+ * form stays behind); the rest go in «Abrir en…». `onOpen` is told which was picked. */
81
+ openTargets?: readonly OpenTarget[];
49
82
  emptyText?: string;
50
83
  /** Placeholder of the field while it is being typed into. It is NOT a second
51
84
  * box — the field itself is the search. */
@@ -56,7 +89,7 @@ export type PickerProps = {
56
89
  loadingText?: string;
57
90
  /** The list grows from the backend: called when its end scrolls into view. The consumer
58
91
  * appends the next page to `items`; with nothing more to fetch it does nothing. Turns off
59
- * the cap of 8, the «see all…» row and the footer count. */
92
+ * the cap of 8 and the footer count. */
60
93
  onLoadMore?: () => void;
61
94
  /** A page asked for by `onLoadMore` is on its way: a quiet row at the end says so. Unlike
62
95
  * `loading`, the rows already there stay. */
@@ -72,15 +105,17 @@ export type PickerProps = {
72
105
  };
73
106
  export declare function Picker({ mode, ...props }: PickerProps): import("react").JSX.Element;
74
107
  /**
75
- * The list of a `Picker`: its rows, the section headers, the «see all…» row and the loading
108
+ * The list of a `Picker`: its rows, the section headers, the «Buscar más…» row and the loading
76
109
  * row at the end. Internal — not exported from the family — and apart from the combobox so it
77
110
  * can be rendered on the server without a popover, which is how the tests see the rows.
78
111
  */
79
- export declare function PickerOptions({ items, marks, hints, remaining, emptyText, onLoadMore, loadingMore, "aria-label": ariaLabel, }: {
112
+ export declare function PickerOptions({ items, marks, hints, remaining, query, emptyText, onLoadMore, loadingMore, "aria-label": ariaLabel, }: {
80
113
  items: readonly PickerItem[];
81
114
  marks: boolean;
82
115
  hints: boolean;
83
116
  remaining: number;
117
+ /** What was typed: it is marked in every row, so each row says why it is there. */
118
+ query?: string;
84
119
  emptyText: string;
85
120
  onLoadMore?: () => void;
86
121
  loadingMore?: boolean;
@@ -1,19 +1,36 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { AriaLocale } from "../../foundations/aria.js";
4
4
  import { fillCopy, useUiCopy } from "../../foundations/copy.js";
5
+ import { matchedWord, matches } from "../../foundations/match.js";
5
6
  import { cn, tv } from "../../foundations/variants.js";
6
7
  import { IconButton } from "../button/Button.js";
7
8
  import { FieldButton, FieldGroup } from "../field/aria.js";
8
9
  import { useFieldIds } from "../field/context.js";
9
10
  import { ReadValue } from "../field/Field.js";
10
11
  import { useFormMode } from "../field/mode.js";
12
+ import { Menu } from "../menu/Menu.js";
11
13
  import { Popover } from "../overlay/Popover.js";
12
- import { Check, ChevronDown, SquareArrowOutUpRight } from "lucide-react";
14
+ import { MatchedText } from "./MatchedText.js";
15
+ import { Avatar } from "../value/Person.js";
16
+ import { AppWindow, Check, ChevronDown, Ellipsis, Eye, PanelRight, PictureInPicture2, SquareArrowOutUpRight, } from "lucide-react";
13
17
  import { useContext, useMemo, useRef, useState } from "react";
14
18
  import { Collection, ComboBox as AriaComboBox, ComboBoxStateContext, Header, Input, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, Text, useFilter, } from "react-aria-components";
15
- /** The id of the «see all…» row. Not a record id: it cannot collide with one. */
19
+ /** The icon of each place a record opens — the same in every field and list. */
20
+ const OPEN_ICON = {
21
+ modal: PictureInPicture2,
22
+ peek: Eye,
23
+ page: SquareArrowOutUpRight,
24
+ side: PanelRight,
25
+ window: AppWindow,
26
+ };
27
+ /** The id of the «Buscar más…» row. Not a record id: it cannot collide with one. */
16
28
  const BROWSE_ALL = "\u0000browse-all";
29
+ /** The rows that create from what was typed — quick, or with the full form. */
30
+ const CREATE = "\u0000create";
31
+ const CREATE_EDIT = "\u0000create-edit";
32
+ /** The rows that act instead of choosing: after the records, in this order. */
33
+ const ACTIONS = new Set([CREATE, CREATE_EDIT, BROWSE_ALL]);
17
34
  const CAP = 8;
18
35
  /**
19
36
  * What the list shows and how many are left out of it. Capped at 8 with the footer count,
@@ -29,7 +46,7 @@ export function shownRows(filtered, { loading, incremental }) {
29
46
  return { visible, remaining: filtered.length - visible.length };
30
47
  }
31
48
  /** Folds consecutive items of the same `section` into one run under a header. The ids of
32
- * the runs cannot collide with a record id, like the «see all…» one. */
49
+ * the runs cannot collide with a record id, like the «Buscar más…» one. */
33
50
  function sectioned(items) {
34
51
  const out = [];
35
52
  for (const item of items) {
@@ -74,13 +91,14 @@ const option = tv({
74
91
  });
75
92
  export function Picker({ mode, ...props }) {
76
93
  if (useFormMode(mode) === "read") {
77
- return _jsx(ReadValue, { value: props.items.find((i) => i.id === props.value)?.name ?? null });
94
+ const name = props.items.find((i) => i.id === props.value)?.name ?? props.valueLabel;
95
+ return _jsx(ReadValue, { value: props.value === undefined ? null : (name ?? null) });
78
96
  }
79
97
  /* The filter reads the locale from React Aria's context, so the box that provides it has
80
98
  to be ABOVE the component that asks for it. */
81
99
  return (_jsx(AriaLocale, { children: _jsx(EditablePicker, { ...props }) }));
82
100
  }
83
- function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrowseAll, onOpen, 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, }) {
84
102
  const { id, labelledBy, describedBy, invalid } = useFieldIds({
85
103
  id: ownId,
86
104
  describedBy: ownDescribedBy,
@@ -93,50 +111,86 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
93
111
  const shownSearch = searchPlaceholder ?? copy.picker.search;
94
112
  const shownLoading = loadingText ?? copy.picker.loading;
95
113
  const chosen = items.find((i) => i.id === value);
96
- const chosenName = chosen?.name ?? "";
114
+ const chosenName = chosen?.name ?? (value === undefined ? "" : (valueLabel ?? ""));
97
115
  /* Both the text and the choice are controlled, so React Aria never rewrites the field
98
116
  from its own collection — which is capped and filtered, and may not hold the chosen
99
117
  record at all. `query` is what filters: empty when the list opens (the whole list,
100
118
  not just the one already chosen), the typed text after that. */
101
119
  const [open, setOpen] = useState(false);
102
120
  const [text, setText] = useState(chosenName);
103
- const [query, setQuery] = useState("");
121
+ const [query, setQueryOnly] = useState("");
122
+ /* What the consumer last heard: a list that opens, closes or chooses starts the search over,
123
+ and telling it `""` three times would ask the server three times for the same page. */
124
+ const heard = useRef("");
125
+ const setQuery = (next) => {
126
+ setQueryOnly(next);
127
+ if (onSearch !== undefined && next !== heard.current) {
128
+ heard.current = next;
129
+ onSearch(next);
130
+ }
131
+ };
104
132
  const [shownFor, setShownFor] = useState(chosenName);
105
133
  if (!open && shownFor !== chosenName) {
106
134
  setShownFor(chosenName);
107
135
  setText(chosenName);
108
136
  }
137
+ const searched = onSearch !== undefined;
109
138
  const filtered = useMemo(() => {
110
139
  const q = query.trim();
111
- if (q === "")
140
+ if (q === "" || searched)
112
141
  return items;
113
142
  return items.filter((i) => contains(`${i.name} ${i.meta ?? ""} ${i.haystack ?? ""}`, q));
114
- }, [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. */
115
145
  const { visible, remaining } = shownRows(filtered, {
116
146
  loading,
117
- incremental: onLoadMore !== undefined,
147
+ incremental: onLoadMore !== undefined || searched,
118
148
  });
119
149
  const hasMarks = items.some((i) => i.icon !== undefined);
120
150
  const hasHints = visible.some((i) => i.meta !== undefined);
121
151
  /* «See all…» is an OPTION at the end of the list and not a button in a footer: the
122
152
  arrows reach it like any other row. As a footer button it was out of the keyboard's
123
153
  path — Tab leaves the field and closes the list before getting there. */
124
- const browsable = remaining > 0 && onBrowseAll !== undefined;
125
- const listed = browsable
126
- ? [...visible, { id: BROWSE_ALL, name: copy.picker.browseAll }]
127
- : visible;
154
+ const browsable = onBrowseAll !== undefined;
155
+ const typed = query.trim();
156
+ /* Exactly the name of an option already: creating it again would make a twin. */
157
+ const named = typed !== "" &&
158
+ items.some((i) => i.name.localeCompare(typed, copy.locale, { sensitivity: "base" }) === 0);
159
+ const actions = loading
160
+ ? []
161
+ : [
162
+ ...(onCreate !== undefined && typed !== "" && !named
163
+ ? [{ id: CREATE, name: fillCopy(copy.picker.create, { name: typed }) }]
164
+ : []),
165
+ ...(onCreateAndEdit === undefined
166
+ ? []
167
+ : [{ id: CREATE_EDIT, name: copy.picker.createAndEdit }]),
168
+ ...(browsable ? [{ id: BROWSE_ALL, name: copy.picker.browseAll }] : []),
169
+ ];
170
+ const listed = [...visible, ...actions];
128
171
  const closeList = useRef(null);
129
172
  const take = (key) => {
130
173
  if (key === null)
131
174
  return;
132
175
  const next = String(key);
133
- if (next === BROWSE_ALL) {
134
- /* The full list opens somewhere else (a dialog, a panel): this one closes and the
135
- field keeps the chosen name. */
176
+ if (ACTIONS.has(next)) {
177
+ /* The full list, or the new record's form, opens somewhere else (a dialog, a panel):
178
+ this one closes and the field keeps the chosen name. A quick create chooses the new
179
+ record once it exists. */
180
+ const asked = query;
136
181
  closeList.current?.();
137
182
  setText(chosenName);
138
183
  setQuery("");
139
- onBrowseAll?.();
184
+ if (next === BROWSE_ALL)
185
+ onBrowseAll?.(asked);
186
+ if (next === CREATE_EDIT)
187
+ onCreateAndEdit?.(asked.trim());
188
+ if (next === CREATE && onCreate !== undefined) {
189
+ /* The field keeps the new name from now on, not the old one until the record exists:
190
+ a controlled text that changes while the input has focus reopens the list. */
191
+ setText(asked.trim());
192
+ void Promise.resolve(onCreate(asked.trim())).then((created) => onChange?.(created));
193
+ }
140
194
  return;
141
195
  }
142
196
  setText(items.find((i) => i.id === next)?.name ?? "");
@@ -160,43 +214,67 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
160
214
  the options are the answer before the first letter. */
161
215
  menuTrigger: "focus",
162
216
  /* An empty list stays open to SAY it is empty (or loading) instead of vanishing. */
163
- allowsEmptyCollection: true, isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: [_jsx(CloseBridge, { into: closeList }), _jsxs(FieldGroup, { children: [_jsx(Input, { placeholder: loading ? shownLoading : open ? shownSearch : shownPlaceholder, title: shownSearch,
217
+ allowsEmptyCollection: true, isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: [_jsx(CloseBridge, { into: closeList }), _jsxs(FieldGroup, { children: [chosen?.avatar === undefined || open ? null : (_jsx("span", { className: "flex shrink-0 items-center pl-2", children: _jsx(Avatar, { name: chosen.name, size: "sm", ...(typeof chosen.avatar === "string" ? { src: chosen.avatar } : {}) }) })), _jsx(Input, { placeholder: loading ? shownLoading : open ? shownSearch : shownPlaceholder, title: shownSearch,
164
218
  /* Arriving selects the name, so the first letter typed replaces it. Only when
165
219
  ARRIVING: React Aria hands the focus back to the input while the list is open,
166
220
  and selecting again then would swallow every second letter. */
167
221
  onFocus: (event) => {
168
222
  if (!open)
169
223
  event.currentTarget.select();
170
- }, className: "h-full min-w-0 flex-1 bg-transparent px-2.5 text-meta text-ink outline-0 placeholder:text-muted disabled:cursor-not-allowed" }), value !== undefined && value !== "" && onOpen !== undefined ? (_jsx(IconButton, { icon: SquareArrowOutUpRight, label: copy.picker.open, size: "sm", disabled: disabled, onClick: () => onOpen(value) })) : null, _jsx(FieldButton, { className: "mr-1", children: _jsx(ChevronDown, { "aria-hidden": true, className: "size-(--size-icon-md)", strokeWidth: 2 }) })] }), _jsxs(Popover, { placement: "bottom start", className: "flex w-(--trigger-width) flex-col overflow-hidden", children: [_jsx(PickerOptions, { items: listed, marks: hasMarks, hints: hasHints, remaining: remaining, emptyText: loading ? shownLoading : shownEmpty,
224
+ }, className: "h-full min-w-0 flex-1 bg-transparent px-2.5 text-meta text-ink outline-0 placeholder:text-muted disabled:cursor-not-allowed" }), value !== undefined &&
225
+ value !== "" &&
226
+ onOpen !== undefined &&
227
+ openTargets[0] !== undefined ? (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: OPEN_ICON[openTargets[0]], label: copy.picker.openIn[openTargets[0]], size: "sm", disabled: disabled, onClick: () => onOpen(value, openTargets[0]) }), openTargets.length < 2 || disabled ? null : (_jsx(Menu, { label: copy.picker.openMore, icon: Ellipsis, iconOnly: true, chevron: false, variant: "text", align: "end", groups: [
228
+ {
229
+ options: openTargets.slice(1).map((to) => ({
230
+ id: to,
231
+ label: copy.picker.openIn[to],
232
+ icon: OPEN_ICON[to],
233
+ })),
234
+ },
235
+ ], onSelect: (to) => onOpen(value, to) }))] })) : null, _jsx(FieldButton, { className: "mr-1", children: _jsx(ChevronDown, { "aria-hidden": true, className: "size-(--size-icon-md)", strokeWidth: 2 }) })] }), _jsxs(Popover, { placement: "bottom start", className: "flex w-(--trigger-width) flex-col overflow-hidden", children: [_jsx(PickerOptions, { items: listed, marks: hasMarks, hints: hasHints, remaining: remaining, query: query, emptyText: loading ? shownLoading : shownEmpty,
171
236
  /* Not while the first page loads: the sentinel of an empty list is in view and
172
237
  would ask for page two before page one arrived. */
173
238
  onLoadMore: loading ? undefined : onLoadMore, loadingMore: loadingMore }), remaining > 0 && !browsable ? (_jsxs("div", { className: "flex shrink-0 items-center gap-2 border-t border-line bg-ground px-2.5 py-1.5", children: [_jsx("span", { className: "text-micro tabular-nums text-muted", children: fillCopy(copy.picker.more, { count: remaining.toLocaleString(copy.locale) }) }), _jsx("span", { className: "text-micro text-muted", children: copy.picker.refine })] })) : null] })] }));
174
239
  }
175
240
  /**
176
- * The list of a `Picker`: its rows, the section headers, the «see all…» row and the loading
241
+ * The list of a `Picker`: its rows, the section headers, the «Buscar más…» row and the loading
177
242
  * row at the end. Internal — not exported from the family — and apart from the combobox so it
178
243
  * can be rendered on the server without a popover, which is how the tests see the rows.
179
244
  */
180
- export function PickerOptions({ items, marks, hints, remaining, emptyText, onLoadMore, loadingMore = false, "aria-label": ariaLabel, }) {
245
+ export function PickerOptions({ items, marks, hints, remaining, query = "", emptyText, onLoadMore, loadingMore = false, "aria-label": ariaLabel, }) {
181
246
  const copy = useUiCopy();
182
247
  const entries = useMemo(() => sectioned(items), [items]);
183
- const renderItem = (item) => item.id === BROWSE_ALL ? (_jsx(ListBoxItem, { id: BROWSE_ALL, textValue: item.name, className: (state) => `${option({ ...state, hints: false })} mt-1 border-t border-line text-primary`, children: _jsxs("span", { className: "flex w-full items-center justify-between gap-2", children: [_jsx("span", { className: "font-medium", children: item.name }), _jsx("span", { className: "text-micro tabular-nums text-muted", children: fillCopy(copy.picker.more, {
248
+ /* The actions are one block under the records: one hairline above the first of them. */
249
+ const firstAction = items.find((i) => ACTIONS.has(i.id))?.id;
250
+ const renderItem = (item) => ACTIONS.has(item.id) ? (_jsx(ListBoxItem, { id: item.id, textValue: item.name, className: (state) => cn(option({ ...state, hints: false }), "text-primary", item.id === firstAction && items[0]?.id !== item.id
251
+ ? "mt-1 border-t border-line"
252
+ : ""), children: _jsxs("span", { className: "flex w-full items-center justify-between gap-2", children: [_jsx("span", { className: "min-w-0 truncate font-medium", children: item.name }), item.id !== BROWSE_ALL || remaining === 0 ? null : (_jsx("span", { className: "text-micro tabular-nums text-muted", children: fillCopy(copy.picker.more, {
184
253
  count: remaining.toLocaleString(copy.locale),
185
- }) })] }) })) : (_jsx(ListBoxItem, { id: item.id, textValue: item.name, isDisabled: item.disabled, className: (state) => option({ ...state, hints }), children: ({ isSelected }) => (_jsx(Row, { item: item, selected: isSelected, marks: marks, hints: hints })) }));
254
+ }) }))] }) })) : (_jsx(ListBoxItem, { id: item.id, textValue: item.name, isDisabled: item.disabled, className: (state) => option({ ...state, hints }), children: ({ isSelected }) => (_jsx(Row, { item: item, selected: isSelected, marks: marks, hints: hints, query: query, locale: copy.locale })) }));
186
255
  return (_jsxs(ListBox, { "aria-label": ariaLabel,
187
256
  /* Nothing matched YET while the next page is coming: «nothing matches» above «loading
188
257
  more…» says two contradicting things, so the empty list says only the second. */
189
258
  renderEmptyState: () => (_jsx("p", { className: "px-3 py-3 text-center text-meta text-muted", children: loadingMore ? copy.picker.loadingMore : emptyText })),
190
259
  /* Incremental, the list is not capped: it scrolls inside its own height and the
191
260
  sentinel at its end is what asks for more. */
192
- className: cn("min-h-0 flex-1 overflow-y-auto p-1 outline-0", onLoadMore === undefined ? "" : "max-h-(--size-dropdown)"), children: [_jsx(Collection, { items: entries, children: (entry) => "items" in entry ? (_jsxs(ListBoxSection, { id: entry.id, className: "not-first:mt-1 not-first:border-t not-first:border-line not-first:pt-1", children: [_jsxs(Header, { className: "flex items-center gap-2 px-2 pt-1 pb-0.5 text-micro font-medium text-muted", children: [marks ? (_jsx("span", { className: "size-(--size-icon-sm) shrink-0", "aria-hidden": true })) : null, entry.section] }), _jsx(Collection, { items: entry.items, children: renderItem })] })) : (renderItem(entry)) }), onLoadMore === undefined ? null : (_jsx(ListBoxLoadMoreItem, { onLoadMore: onLoadMore, isLoading: loadingMore && items.length > 0, className: "px-3 py-1.5 text-center text-micro text-muted", children: copy.picker.loadingMore }))] }));
261
+ className: cn("min-h-0 flex-1 overflow-y-auto p-1 outline-0", onLoadMore === undefined ? "" : "max-h-(--size-dropdown)"), children: [_jsx(Collection, { items: entries, dependencies: [query], children: (entry) => "items" in entry ? (_jsxs(ListBoxSection, { id: entry.id, className: "not-first:mt-1 not-first:border-t not-first:border-line not-first:pt-1", children: [_jsxs(Header, { className: "flex items-center gap-2 px-2 pt-1 pb-0.5 text-micro font-medium text-muted", children: [marks ? (_jsx("span", { className: "size-(--size-icon-sm) shrink-0", "aria-hidden": true })) : null, entry.section] }), _jsx(Collection, { items: entry.items, dependencies: [query], children: renderItem })] })) : (renderItem(entry)) }), onLoadMore === undefined ? null : (_jsx(ListBoxLoadMoreItem, { onLoadMore: onLoadMore, isLoading: loadingMore && items.length > 0, className: "px-3 py-1.5 text-center text-micro text-muted", children: copy.picker.loadingMore }))] }));
193
262
  }
194
- function Row({ item, selected, marks, hints, }) {
263
+ function Row({ item, selected, marks, hints, query, locale, }) {
195
264
  const Icon = item.icon;
196
- return (_jsxs(_Fragment, { children: [marks ? (_jsx("span", { className: cn(hints ? "mt-0.5" : "", "flex size-(--size-icon-sm) shrink-0 items-center justify-center"), children: selected ? (_jsx(Check, { "aria-hidden": true, className: "size-(--size-icon-sm) text-primary forced-colors:text-[HighlightText]", strokeWidth: 2.5 })) : Icon !== undefined ? (_jsx(Icon, { "aria-hidden": true, className: "size-(--size-icon-sm) text-muted", strokeWidth: 1.75 })) : null })) : null, _jsxs("span", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx(Text, { slot: "label", className: cn("min-w-0 truncate text-meta text-ink", selected ? "font-medium" : ""), children: item.name }), item.tags] }), item.meta === undefined ? null : (_jsx(Text, { slot: "description", className: "truncate text-micro tabular-nums text-muted", children: item.meta }))] }), !marks && selected ? (_jsx(Check, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0 text-primary forced-colors:text-[HighlightText]", strokeWidth: 2.5 })) : null] }));
265
+ const copy = useUiCopy();
266
+ /* Listed for a code that is not drawn — the barcode, the NIT —: say which, or the row reads
267
+ as a wrong match. */
268
+ const hidden = query.trim() === "" ||
269
+ matches(item.name, query, locale) ||
270
+ (item.meta !== undefined && matches(item.meta, query, locale)) ||
271
+ item.haystack === undefined
272
+ ? null
273
+ : matchedWord(item.haystack, query, locale);
274
+ return (_jsxs(_Fragment, { children: [marks ? (_jsx("span", { className: cn(hints ? "mt-0.5" : "", "flex size-(--size-icon-sm) shrink-0 items-center justify-center"), children: selected ? (_jsx(Check, { "aria-hidden": true, className: "size-(--size-icon-sm) text-primary forced-colors:text-[HighlightText]", strokeWidth: 2.5 })) : Icon !== undefined ? (_jsx(Icon, { "aria-hidden": true, className: "size-(--size-icon-sm) text-muted", strokeWidth: 1.75 })) : null })) : null, item.avatar === undefined ? null : (_jsx(Avatar, { name: item.name, size: "sm", ...(typeof item.avatar === "string" ? { src: item.avatar } : {}) })), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx(Text, { slot: "label", className: cn("min-w-0 truncate text-meta text-ink", selected ? "font-medium" : ""), children: _jsx(MatchedText, { text: item.name, query: query, locale: locale }) }), item.tags] }), item.meta === undefined ? null : (_jsx(Text, { slot: "description", className: "truncate text-micro tabular-nums text-muted", children: _jsx(MatchedText, { text: item.meta, query: query, locale: locale }) })), hidden === null ? null : (_jsxs("span", { className: "truncate text-micro text-muted", children: [copy.picker.matched, " ", _jsx("span", { className: "font-mono", children: _jsx(MatchedText, { text: hidden, query: query, locale: locale }) })] }))] }), item.aside === undefined ? null : (_jsx("span", { className: "flex shrink-0 items-center gap-1.5 self-center text-meta text-ink tabular-nums", children: item.aside })), !marks && selected ? (_jsx(Check, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0 text-primary forced-colors:text-[HighlightText]", strokeWidth: 2.5 })) : null] }));
197
275
  }
198
276
  /** Hands the combobox's `close` to the code outside it: React Aria owns the open state and
199
- * offers no controlled `isOpen`, and the «see all…» row has to close the list it lives in. */
277
+ * offers no controlled `isOpen`, and the «Buscar más…» row has to close the list it lives in. */
200
278
  function CloseBridge({ into }) {
201
279
  const state = useContext(ComboBoxStateContext);
202
280
  into.current = state === null ? null : () => state.close();
@@ -135,7 +135,7 @@ totals, empty, grouping,
135
135
  /** El total de una columna DENTRO de un montón, ya formateado. Lo calcula quien
136
136
  * compone, por el mismo motivo que `totals`: sumar significa cosas distintas
137
137
  * según el dominio, y un porcentaje se promedia, no se suma. */
138
- groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, }: {
138
+ groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, onReorder, }: {
139
139
  rows: readonly T[];
140
140
  columns: readonly EditColumn<T>[];
141
141
  getRowId: (row: T) => string;
@@ -188,4 +188,9 @@ groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, }: {
188
188
  detail?: ReactNode;
189
189
  owner?: string;
190
190
  }) | null;
191
+ /** The rows have an order of their own — the lines of an order, Odoo's `sequence` — and it
192
+ * is changed by dragging the grip at the start of a row, or with Alt + ↑ / ↓ on it. `before`
193
+ * is the row it lands in front of; `null`, the end. Off while grouped (the piles decide the
194
+ * order) and in the stacked layout. */
195
+ onReorder?: (id: string, before: string | null) => void;
191
196
  }): import("react").JSX.Element;
@@ -8,7 +8,8 @@ import { stickyTop, useScrollBox } from "../../foundations/sticky.js";
8
8
  import { IconButton } from "../button/Button.js";
9
9
  import { NoteCard } from "../field/note.js";
10
10
  import { Flag } from "../notice/Flag.js";
11
- import { Plus, Trash2 } from "lucide-react";
11
+ import { HEADER_TEXT } from "./Table.js";
12
+ import { GripVertical, Plus, Trash2 } from "lucide-react";
12
13
  import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
13
14
  import { useKeyboard } from "react-aria";
14
15
  /** The lines each section heads: the rows after it up to the next section. Notes do not
@@ -239,7 +240,7 @@ totals, empty, grouping,
239
240
  /** El total de una columna DENTRO de un montón, ya formateado. Lo calcula quien
240
241
  * compone, por el mismo motivo que `totals`: sumar significa cosas distintas
241
242
  * según el dominio, y un porcentaje se promedia, no se suma. */
242
- groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
243
+ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReorder, }) {
243
244
  const copy = useUiCopy();
244
245
  const bandOf = (row) => bands?.kind(row) ?? null;
245
246
  /* The row's mark, before its first value — and, in a record's margin, its card. */
@@ -338,6 +339,51 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
338
339
  /* The rows in the order they are drawn: what the arrows walk. */
339
340
  const order = drawn.flatMap((entry) => entry.row === undefined ? [] : [getRowId(entry.row)]);
340
341
  const stop = active !== null && order.includes(active) ? active : (order[0] ?? null);
342
+ /* REORDER — a grip at the start of each row. Dragged, the row shows where it would land (a
343
+ line over the row it goes in front of); Alt + ↑ / ↓ on the grip does the same by keyboard. */
344
+ const reorderable = onReorder !== undefined && grouping === undefined && !stacked;
345
+ /* A ref and not state: `dragover` can arrive before React re-renders after `dragstart`. */
346
+ const dragged = useRef(null);
347
+ const [landing, setLanding] = useState(undefined);
348
+ const after = (id) => order[order.indexOf(id) + 1] ?? null;
349
+ const land = (id, before) => {
350
+ if (before === id || before === after(id))
351
+ return;
352
+ onReorder?.(id, before);
353
+ };
354
+ const overRow = (event) => {
355
+ if (dragged.current === null)
356
+ return;
357
+ const tr = event.target.closest("tr[data-row-id]");
358
+ if (tr === null)
359
+ return;
360
+ event.preventDefault();
361
+ const id = tr.dataset["rowId"] ?? "";
362
+ const box = tr.getBoundingClientRect();
363
+ setLanding(event.clientY < box.top + box.height / 2 ? id : after(id));
364
+ };
365
+ const lineOver = (id) => (landing === id ? "border-t-2 border-t-primary" : "");
366
+ const grip = (id, mark) => (_jsx("td", { className: `w-0 py-1 pr-0 pl-1 align-middle ${mark} ${lineOver(id)}`, children: _jsx("button", { type: "button", draggable: true, "aria-label": copy.editable.reorder, title: copy.editable.reorderHint, "aria-keyshortcuts": "Alt+ArrowUp Alt+ArrowDown", ...(editable && stop !== id ? { tabIndex: -1 } : {}), onClick: (event) => event.stopPropagation(), onKeyDown: (event) => {
367
+ if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown"))
368
+ return;
369
+ event.preventDefault();
370
+ event.stopPropagation();
371
+ const at = order.indexOf(id);
372
+ if (event.key === "ArrowUp" && at > 0)
373
+ land(id, order[at - 1] ?? null);
374
+ if (event.key === "ArrowDown" && at < order.length - 1)
375
+ land(id, order[at + 2] ?? null);
376
+ }, onDragStart: (event) => {
377
+ event.dataTransfer.setData("text/plain", id);
378
+ event.dataTransfer.effectAllowed = "move";
379
+ const tr = event.currentTarget.closest("tr");
380
+ if (tr !== null)
381
+ event.dataTransfer.setDragImage(tr, 16, 16);
382
+ dragged.current = id;
383
+ }, onDragEnd: () => {
384
+ dragged.current = null;
385
+ setLanding(undefined);
386
+ }, className: "flex cursor-grab items-center rounded-control p-0.5 text-muted opacity-40 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 hover:text-ink focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-primary active:cursor-grabbing", children: _jsx(GripVertical, { "aria-hidden": true, className: "size-(--size-icon-sm)", strokeWidth: 2 }) }) }));
341
387
  useEffect(() => {
342
388
  const target = focusOnOpen.current;
343
389
  if (target === null || target.id !== editing)
@@ -556,7 +602,20 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
556
602
  return null;
557
603
  return (_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-meta", children: [_jsx("dt", { className: "text-muted", children: c.header }), _jsx("dd", { className: "font-semibold text-ink tabular-nums", children: total })] }, c.id));
558
604
  }) }))] }));
559
- return (_jsxs("div", { ref: frame, className: "min-w-0", children: [_jsx("div", { ref: box.ref, className: `min-w-0 ${box.className}`, style: box.style, children: stacked ? (list) : (_jsxs("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: [_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsxs("tr", { children: [columns.map((c) => (_jsx("th", { scope: "col", style: c.width === undefined ? undefined : { width: c.width }, className: `border-b border-line-strong text-meta font-medium whitespace-nowrap text-muted ${cell} ${c.numeric === true ? "text-right" : "text-left"}`, children: c.header }, c.id))), onRemove === undefined ? null : (_jsx("th", { className: "w-0 border-b border-line-strong" }))] }) }), _jsx("tbody", { children: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columns.length + (onRemove === undefined ? 0 : 1), className: `text-meta text-muted italic ${cell}`, children: emptyText }) })) : (drawn.map((entry) => {
605
+ return (_jsxs("div", { ref: frame, className: "min-w-0", children: [_jsx("div", { ref: box.ref, className: `min-w-0 ${box.className}`, style: box.style, children: stacked ? (list) : (_jsxs("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: [_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsxs("tr", { children: [reorderable ? _jsx("th", { className: "w-0 border-b border-line-strong" }) : null, columns.map((c) => (_jsx("th", { scope: "col", style: c.width === undefined ? undefined : { width: c.width }, className: `border-b border-line-strong ${HEADER_TEXT} ${cell} ${c.numeric === true ? "text-right" : "text-left"}`, children: c.header }, c.id))), onRemove === undefined ? null : (_jsx("th", { className: "w-0 border-b border-line-strong" }))] }) }), _jsx("tbody", { ...(reorderable
606
+ ? {
607
+ onDragOver: overRow,
608
+ onDrop: (event) => {
609
+ event.preventDefault();
610
+ if (dragged.current !== null && landing !== undefined)
611
+ land(dragged.current, landing);
612
+ dragged.current = null;
613
+ setLanding(undefined);
614
+ },
615
+ }
616
+ : {}), children: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columns.length +
617
+ (onRemove === undefined ? 0 : 1) +
618
+ (reorderable ? 1 : 0), className: `text-meta text-muted italic ${cell}`, children: emptyText }) })) : (drawn.map((entry) => {
560
619
  if (entry.pile !== undefined) {
561
620
  const pile = entry.pile;
562
621
  const inside = pile.piles.length === 0 ? pile.items : pileItems(pile.piles);
@@ -565,7 +624,7 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
565
624
  columna y cada total bajo la suya. Un total en un rincón
566
625
  obliga a buscar de qué columna es; alineado no hay nada que
567
626
  buscar. */
568
- _jsxs("tr", { children: [columns.map((c, ci) => (_jsx("td", { className: `bg-ground text-meta ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, style: ci === 0 && pile.depth > 0
627
+ _jsxs("tr", { children: [reorderable ? _jsx("td", { className: "w-0 bg-ground" }) : null, columns.map((c, ci) => (_jsx("td", { className: `bg-ground text-meta ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, style: ci === 0 && pile.depth > 0
569
628
  ? {
570
629
  paddingLeft: `calc(var(--spacing-indent) * ${pile.depth} + 0.625rem)`,
571
630
  }
@@ -586,7 +645,7 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
586
645
  : "group-hover/row:bg-ink/4";
587
646
  const bandMark = `border-b border-line first:rounded-l-control last:rounded-r-control ${bandWash}`;
588
647
  const control = isEditing && editable && bands?.edit !== undefined;
589
- return (_jsxs(EditRow, { as: "tr", ...rowWiring(id, row), className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [_jsx("td", { colSpan: span, ...(control ? { "data-edit": "" } : {}), className: `text-meta ${bandMark} ${cell} ${band === "section"
648
+ return (_jsxs(EditRow, { as: "tr", ...rowWiring(id, row), className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [reorderable ? grip(id, bandMark) : null, _jsx("td", { colSpan: span, ...(control ? { "data-edit": "" } : {}), className: `text-meta ${bandMark} ${lineOver(id)} ${cell} ${band === "section"
590
649
  ? "font-semibold text-ink"
591
650
  : "text-muted italic"}`, children: control ? bands?.edit?.(row, setter(id)) : bands?.render(row) }), columns.slice(span).map((c, i) => (_jsx("td", { className: `text-meta text-ink ${bandMark} ${cell} ${c.numeric === true
592
651
  ? "text-right font-semibold tabular-nums"
@@ -600,12 +659,14 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
600
659
  return (_jsxs(EditRow, { as: "tr", ...rowWiring(id, row),
601
660
  /* The focus ring is drawn on the row, inset, so it is not cut by
602
661
  the box and does not move the cells. */
603
- className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [columns.map((c, ci) => {
662
+ className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [reorderable ? grip(id, mark) : null, columns.map((c, ci) => {
604
663
  const control = isEditing && c.edit !== undefined && editable;
605
- const mark = ci === 0 ? flagOf(row, true) : null;
606
- return (_jsx("td", { ...(control ? { "data-edit": "" } : {}), className: `text-meta text-ink ${mark} ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: mark === null ? (control ? (c.edit?.(row, set)) : (c.render(row))) : (_jsxs("span", { className: "flex min-w-0 items-start gap-1.5", children: [mark, _jsx("span", { className: "min-w-0 flex-1", children: control ? c.edit?.(row, set) : c.render(row) })] })) }, c.id));
664
+ /* Not `mark`: that name is the row's border and wash, above. Shadowed,
665
+ the cells were drawn with the class «null» — no hairline, no hover. */
666
+ const flagged = ci === 0 ? flagOf(row, true) : null;
667
+ return (_jsx("td", { ...(control ? { "data-edit": "" } : {}), className: `text-meta text-ink ${mark} ${lineOver(id)} ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: flagged === null ? (control ? (c.edit?.(row, set)) : (c.render(row))) : (_jsxs("span", { className: "flex min-w-0 items-start gap-1.5", children: [flagged, _jsx("span", { className: "min-w-0 flex-1", children: control ? c.edit?.(row, set) : c.render(row) })] })) }, c.id));
607
668
  }), onRemove === undefined ? null : (_jsx("td", { className: `w-0 py-1 pr-2 pl-1 align-middle ${mark}`, children: _jsx("span", { className: "flex opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 group-focus-visible/row:opacity-100 focus-within:opacity-100", onClick: (e) => e.stopPropagation(), children: _jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", ...(editable && stop !== id ? { tabIndex: -1 } : {}), onClick: () => onRemove(id) }) }) }))] }, id));
608
- })) }), totals === undefined ? null : (_jsx("tfoot", { children: _jsxs("tr", { children: [columns.map((c, i) => (_jsx("td", { className: `border-t border-line-strong text-meta font-semibold text-ink ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: totals[i] ?? null }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "border-t border-line-strong" }))] }) }))] })) }), onAdd === undefined && (adds === undefined || adds.length === 0) ? null : (
669
+ })) }), totals === undefined ? null : (_jsx("tfoot", { children: _jsxs("tr", { children: [reorderable ? _jsx("td", { className: "border-t border-line-strong" }) : null, columns.map((c, i) => (_jsx("td", { className: `border-t border-line-strong text-meta font-semibold text-ink ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: totals[i] ?? null }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "border-t border-line-strong" }))] }) }))] })) }), onAdd === undefined && (adds === undefined || adds.length === 0) ? null : (
609
670
  /* The «add» goes under the grid, as text, not as a button away from it:
610
671
  what is about to happen is that one more row shows up right here.
611
672
  The hover is the same inset mark as a row: the shell lines the label
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { currentCopyLocale, fillCopy, useUiCopy } from "../../foundations/copy.js";
4
4
  import { byDensity, useDensity } from "../../foundations/density.js";
5
5
  import { stickyTop, useScrollBox } from "../../foundations/sticky.js";
6
+ import { HEADER_TEXT } from "./Table.js";
6
7
  import { ChevronRight, Info } from "lucide-react";
7
8
  import { useState } from "react";
8
9
  /* `useGrouping: "always"` and not the default: without it, four-digit numbers
@@ -102,5 +103,5 @@ defaultOpen, caption, height, }) {
102
103
  desplazamiento de la barra y quedaba empujado 68 px hacia adentro de su
103
104
  propia caja — banda en blanco arriba y títulos de columna cayendo entre
104
105
  las filas. */
105
- _jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsxs("table", { className: "w-full border-collapse px-1", children: [caption === undefined ? null : (_jsx("caption", { className: `text-left text-meta text-muted ${byDensity(d, "px-3 pb-2", "px-2 pb-1.5")}`, children: caption })), _jsx("thead", { className: "sticky z-20 bg-ground", style: stickyTop, children: _jsxs("tr", { className: "border-b border-line-strong", children: [_jsx("th", { className: `sticky left-0 z-10 bg-ground text-left ${compact ? "px-2 py-1.5" : "px-3 py-2"}`, children: _jsx("span", { className: "sr-only", children: copy.report.concept }) }), columns.map((c) => (_jsx("th", { scope: "col", className: `text-right text-meta font-medium whitespace-nowrap text-muted ${compact ? "px-2 py-1.5" : "px-3 py-2"}`, children: c.header }, c.id))), _jsx("th", { className: "w-0" })] }) }), _jsx("tbody", { children: rows.map((r) => (_jsx(ReportLine, { row: r, columns: columns, open: open, onToggle: toggle, compact: compact }, r.id))) })] }) }));
106
+ _jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsxs("table", { className: "w-full border-collapse px-1", children: [caption === undefined ? null : (_jsx("caption", { className: `text-left text-meta text-muted ${byDensity(d, "px-3 pb-2", "px-2 pb-1.5")}`, children: caption })), _jsx("thead", { className: "sticky z-20 bg-ground", style: stickyTop, children: _jsxs("tr", { className: "border-b border-line-strong", children: [_jsx("th", { className: `sticky left-0 z-10 bg-ground text-left ${compact ? "px-2 py-1.5" : "px-3 py-2"}`, children: _jsx("span", { className: "sr-only", children: copy.report.concept }) }), columns.map((c) => (_jsx("th", { scope: "col", className: `text-right ${HEADER_TEXT} ${compact ? "px-2 py-1.5" : "px-3 py-2"}`, children: c.header }, c.id))), _jsx("th", { className: "w-0" })] }) }), _jsx("tbody", { children: rows.map((r) => (_jsx(ReportLine, { row: r, columns: columns, open: open, onToggle: toggle, compact: compact }, r.id))) })] }) }));
106
107
  }
@@ -3,19 +3,24 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useUiCopy } from "../../foundations/copy.js";
4
4
  import { IconButton } from "../button/Button.js";
5
5
  import { Menu } from "../menu/Menu.js";
6
+ import { useRowWash } from "./Table.js";
6
7
  import { MoreHorizontal } from "lucide-react";
7
8
  export function RowActions({ actions,
8
9
  /** How many stay visible before the rest fall into the `⋯`. */
9
10
  inline = 2, }) {
10
11
  const copy = useUiCopy();
12
+ const wash = useRowWash();
11
13
  if (actions.length === 0)
12
- return _jsx("td", { className: "w-0" });
14
+ return _jsx("td", { className: `w-0 border-b border-line ${wash}` });
13
15
  /* With only one left over there is no `⋯`: a menu with a single item is one
14
16
  extra click to reach the same button. */
15
17
  const inlineActions = actions.length <= inline + 1 ? actions : actions.slice(0, inline);
16
18
  const overflow = actions.slice(inlineActions.length);
17
19
  const byId = new Map(actions.map((a) => [a.id, a]));
18
- return (_jsx("td", { className: "w-0 py-1 pr-2 pl-1 align-middle whitespace-nowrap", children: _jsxs("div", { onClick: (e) => e.stopPropagation(), className: "flex items-center justify-end gap-0.5 opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100", children: [inlineActions.map((a) => (_jsx(IconButton, { icon: a.icon, label: a.label, size: "sm", ...(a.tone === undefined ? {} : { tone: a.tone }), ...(a.disabled === undefined ? {} : { disabled: a.disabled }), onClick: a.onSelect }, a.id))), overflow.length === 0 ? null : (_jsx(Menu, { label: copy.selection.more, icon: MoreHorizontal, iconOnly: true, chevron: false, align: "end", groups: [
20
+ return (
21
+ /* The row's hairline runs under its actions too: without it the separators stopped one
22
+ column short and the actions read as outside the table. */
23
+ _jsx("td", { className: `w-0 border-b border-line py-1 pr-2 pl-1 align-middle whitespace-nowrap last:rounded-r-control ${wash}`, children: _jsxs("div", { onClick: (e) => e.stopPropagation(), className: "flex items-center justify-end gap-0.5 opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100", children: [inlineActions.map((a) => (_jsx(IconButton, { icon: a.icon, label: a.label, size: "sm", ...(a.tone === undefined ? {} : { tone: a.tone }), ...(a.disabled === undefined ? {} : { disabled: a.disabled }), onClick: a.onSelect }, a.id))), overflow.length === 0 ? null : (_jsx(Menu, { label: copy.selection.more, icon: MoreHorizontal, iconOnly: true, chevron: false, align: "end", groups: [
19
24
  {
20
25
  options: overflow.map((a) => ({
21
26
  id: a.id,
@@ -14,6 +14,9 @@ height, }: {
14
14
  * system scale instead of pinning a size in pixels. */
15
15
  height?: number | string;
16
16
  }): import("react").JSX.Element;
17
+ /** The text of a column header, shared by every table of the family. Ink and semibold, not
18
+ * muted: in grey at the cells' weight the names of the columns read as one more row. */
19
+ export declare const HEADER_TEXT = "text-meta font-semibold whitespace-nowrap text-ink";
17
20
  export declare function THead({ children }: {
18
21
  children: ReactNode;
19
22
  }): import("react").JSX.Element;
@@ -71,6 +74,8 @@ export declare function TrDetail({ children, detail, open, onToggle, columns, la
71
74
  * a hovered row and a painted cell are two signals, and stacking them reads
72
75
  * as a third color. */
73
76
  export declare const CELL_TONE: Record<Tone, string>;
77
+ /** The wash of the row a cell sits in: for cells drawn outside `Td`, like the row's actions. */
78
+ export declare function useRowWash(): string;
74
79
  export declare function Td({ children, numeric, align,
75
80
  /** A CODE — an id, a hash, a key — set in the monospaced face. Off by default: a
76
81
  * number does not need it (a numeric cell gets tabular figures on its own), and a