@sincpro/web-ui 0.1.0 → 1.0.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 (198) hide show
  1. package/README.md +40 -13
  2. package/dist/components/activity/Activity.d.ts +69 -0
  3. package/dist/components/activity/Activity.js +279 -0
  4. package/dist/components/activity/ActivityBadge.d.ts +16 -0
  5. package/dist/components/activity/ActivityBadge.js +29 -0
  6. package/dist/components/{chatter → activity}/Messages.d.ts +2 -2
  7. package/dist/components/{chatter → activity}/Messages.js +9 -6
  8. package/dist/components/activity/index.d.ts +4 -0
  9. package/dist/components/activity/index.js +5 -0
  10. package/dist/components/assist/AssistMark.d.ts +3 -3
  11. package/dist/components/assist/AssistMark.js +1 -1
  12. package/dist/components/assist/assist.d.ts +1 -1
  13. package/dist/components/assist/assist.js +1 -1
  14. package/dist/components/calendar/RecordCalendar.d.ts +48 -0
  15. package/dist/components/calendar/RecordCalendar.js +101 -0
  16. package/dist/components/calendar/index.d.ts +1 -0
  17. package/dist/components/calendar/index.js +1 -0
  18. package/dist/components/form/Sheet.d.ts +4 -0
  19. package/dist/components/form/Sheet.js +16 -5
  20. package/dist/components/gantt/RecordGantt.d.ts +44 -0
  21. package/dist/components/gantt/RecordGantt.js +110 -0
  22. package/dist/components/gantt/index.d.ts +1 -0
  23. package/dist/components/gantt/index.js +1 -0
  24. package/dist/components/index.d.ts +4 -1
  25. package/dist/components/index.js +4 -1
  26. package/dist/components/lines/LineItems.d.ts +45 -5
  27. package/dist/components/lines/LineItems.js +106 -18
  28. package/dist/components/listing/BoardColumns.d.ts +39 -0
  29. package/dist/components/listing/BoardColumns.js +138 -0
  30. package/dist/components/listing/Listing.js +1 -1
  31. package/dist/components/listing/ListingBar.d.ts +46 -2
  32. package/dist/components/listing/ListingBar.js +47 -5
  33. package/dist/components/listing/PivotExplorer.d.ts +34 -0
  34. package/dist/components/listing/PivotExplorer.js +86 -0
  35. package/dist/components/listing/RecordCard.d.ts +6 -8
  36. package/dist/components/listing/RecordCard.js +13 -7
  37. package/dist/components/listing/Row.d.ts +25 -2
  38. package/dist/components/listing/Row.js +30 -13
  39. package/dist/components/listing/index.d.ts +2 -0
  40. package/dist/components/listing/index.js +2 -0
  41. package/dist/components/listing/set.d.ts +1 -1
  42. package/dist/components/navigation/index.d.ts +0 -2
  43. package/dist/components/navigation/index.js +2 -3
  44. package/dist/components/record/RecordAudit.d.ts +17 -0
  45. package/dist/components/record/RecordAudit.js +27 -0
  46. package/dist/components/record/RecordHeader.d.ts +27 -17
  47. package/dist/components/record/RecordHeader.js +64 -20
  48. package/dist/components/record/RecordNotices.d.ts +37 -0
  49. package/dist/components/record/RecordNotices.js +75 -0
  50. package/dist/components/record/RecordRibbon.d.ts +13 -0
  51. package/dist/components/record/RecordRibbon.js +22 -0
  52. package/dist/components/record/ThumbBar.js +1 -1
  53. package/dist/components/record/index.d.ts +2 -0
  54. package/dist/components/record/index.js +2 -0
  55. package/dist/components/search/CriteriaChips.d.ts +6 -1
  56. package/dist/components/search/CriteriaChips.js +71 -20
  57. package/dist/components/search/SearchBar.d.ts +4 -1
  58. package/dist/components/search/SearchBar.js +15 -7
  59. package/dist/components/timeline/RecordTimeline.d.ts +60 -0
  60. package/dist/components/timeline/RecordTimeline.js +214 -0
  61. package/dist/components/timeline/TimelineToolbar.d.ts +30 -0
  62. package/dist/components/timeline/TimelineToolbar.js +31 -0
  63. package/dist/components/timeline/index.d.ts +3 -0
  64. package/dist/components/timeline/index.js +3 -0
  65. package/dist/components/timeline/time.d.ts +43 -0
  66. package/dist/components/timeline/time.js +135 -0
  67. package/dist/foundations/copy.d.ts +194 -18
  68. package/dist/foundations/copy.es.js +142 -15
  69. package/dist/foundations/copy.js +133 -11
  70. package/dist/foundations/index.d.ts +1 -1
  71. package/dist/foundations/match.d.ts +23 -0
  72. package/dist/foundations/match.js +63 -0
  73. package/dist/foundations/slots.d.ts +1 -1
  74. package/dist/foundations/slots.js +8 -3
  75. package/dist/foundations/types.d.ts +7 -0
  76. package/dist/foundations/types.js +0 -36
  77. package/dist/primitives/block/index.d.ts +1 -1
  78. package/dist/primitives/block/index.js +1 -1
  79. package/dist/primitives/cell/CellStack.d.ts +7 -1
  80. package/dist/primitives/cell/CellStack.js +5 -2
  81. package/dist/primitives/choice/ComboBox.d.ts +3 -0
  82. package/dist/primitives/choice/ComboBox.js +10 -6
  83. package/dist/primitives/choice/KanbanState.d.ts +14 -0
  84. package/dist/primitives/choice/KanbanState.js +37 -0
  85. package/dist/primitives/choice/Priority.d.ts +20 -0
  86. package/dist/primitives/choice/Priority.js +41 -0
  87. package/dist/primitives/choice/SearchField.d.ts +10 -1
  88. package/dist/primitives/choice/SearchField.js +21 -2
  89. package/dist/primitives/choice/Slider.d.ts +6 -1
  90. package/dist/primitives/choice/Slider.js +2 -2
  91. package/dist/primitives/choice/index.d.ts +2 -0
  92. package/dist/primitives/choice/index.js +2 -0
  93. package/dist/primitives/date/Calendar.js +30 -6
  94. package/dist/primitives/date/DatePicker.d.ts +11 -3
  95. package/dist/primitives/date/DatePicker.js +12 -8
  96. package/dist/primitives/date/DateTime.d.ts +35 -0
  97. package/dist/primitives/date/DateTime.js +100 -0
  98. package/dist/primitives/date/index.d.ts +1 -0
  99. package/dist/primitives/date/index.js +4 -2
  100. package/dist/primitives/dialog/Dialog.d.ts +9 -3
  101. package/dist/primitives/dialog/Dialog.js +41 -5
  102. package/dist/primitives/field/DropZone.js +1 -1
  103. package/dist/primitives/field/Field.d.ts +14 -2
  104. package/dist/primitives/field/Field.js +25 -14
  105. package/dist/primitives/field/copy.d.ts +16 -0
  106. package/dist/primitives/field/copy.js +69 -0
  107. package/dist/primitives/field/index.d.ts +2 -0
  108. package/dist/primitives/field/index.js +4 -0
  109. package/dist/primitives/field/note.d.ts +55 -0
  110. package/dist/primitives/field/note.js +117 -0
  111. package/dist/primitives/index.d.ts +1 -0
  112. package/dist/primitives/index.js +1 -0
  113. package/dist/primitives/menu/Menu.d.ts +28 -0
  114. package/dist/primitives/menu/Menu.js +47 -17
  115. package/dist/primitives/notice/Callout.js +1 -1
  116. package/dist/primitives/notice/Flag.d.ts +13 -0
  117. package/dist/primitives/notice/Flag.js +31 -0
  118. package/dist/primitives/notice/index.d.ts +2 -1
  119. package/dist/primitives/notice/index.js +2 -1
  120. package/dist/primitives/overlay/Preview.d.ts +19 -0
  121. package/dist/primitives/overlay/Preview.js +69 -0
  122. package/dist/primitives/overlay/index.d.ts +1 -0
  123. package/dist/primitives/overlay/index.js +3 -1
  124. package/dist/primitives/picker/MatchedText.d.ts +10 -0
  125. package/dist/primitives/picker/MatchedText.js +12 -0
  126. package/dist/primitives/picker/Picker.d.ts +67 -4
  127. package/dist/primitives/picker/Picker.js +142 -23
  128. package/dist/primitives/table/CellCopy.d.ts +20 -0
  129. package/dist/primitives/table/CellCopy.js +23 -0
  130. package/dist/primitives/table/DataTable.d.ts +55 -1
  131. package/dist/primitives/table/DataTable.js +126 -40
  132. package/dist/primitives/table/EditableTable.d.ts +102 -2
  133. package/dist/primitives/table/EditableTable.js +287 -32
  134. package/dist/primitives/table/ReportTable.js +2 -1
  135. package/dist/primitives/table/RowActions.js +7 -2
  136. package/dist/primitives/table/SheetTable.d.ts +4 -1
  137. package/dist/primitives/table/SheetTable.js +38 -5
  138. package/dist/primitives/table/Table.d.ts +5 -0
  139. package/dist/primitives/table/Table.js +17 -8
  140. package/dist/primitives/table/index.d.ts +2 -2
  141. package/dist/primitives/table/index.js +1 -1
  142. package/dist/primitives/tabs/Tabs.d.ts +2 -2
  143. package/dist/primitives/tabs/Tabs.js +1 -1
  144. package/dist/primitives/tag/Tags.d.ts +2 -0
  145. package/dist/primitives/tag/Tags.js +2 -1
  146. package/dist/primitives/tag/TagsField.d.ts +13 -0
  147. package/dist/primitives/tag/TagsField.js +39 -10
  148. package/dist/primitives/tag/choices.d.ts +2 -0
  149. package/dist/primitives/tree/Tree.d.ts +56 -0
  150. package/dist/primitives/tree/Tree.js +74 -0
  151. package/dist/primitives/tree/index.d.ts +1 -0
  152. package/dist/primitives/tree/index.js +3 -0
  153. package/dist/primitives/value/Avatar.d.ts +16 -0
  154. package/dist/primitives/value/Avatar.js +91 -0
  155. package/dist/primitives/value/Copyable.d.ts +11 -0
  156. package/dist/primitives/value/Copyable.js +27 -6
  157. package/dist/primitives/value/Fecha.js +10 -1
  158. package/dist/primitives/value/Person.d.ts +6 -51
  159. package/dist/primitives/value/Person.js +9 -148
  160. package/dist/primitives/value/ProgressBar.d.ts +31 -0
  161. package/dist/primitives/value/ProgressBar.js +53 -0
  162. package/dist/primitives/value/Relation.d.ts +22 -5
  163. package/dist/primitives/value/Relation.js +42 -14
  164. package/dist/primitives/value/Totals.d.ts +102 -7
  165. package/dist/primitives/value/Totals.js +52 -2
  166. package/dist/primitives/value/Value.d.ts +0 -37
  167. package/dist/primitives/value/Value.js +0 -39
  168. package/dist/primitives/value/contact.d.ts +30 -0
  169. package/dist/primitives/value/contact.js +51 -0
  170. package/dist/primitives/value/index.d.ts +5 -4
  171. package/dist/primitives/value/index.js +5 -4
  172. package/dist/tokens.css +179 -7
  173. package/dist/views/list/List.d.ts +32 -3
  174. package/dist/views/list/List.js +51 -7
  175. package/dist/views/list/ListView.d.ts +1 -1
  176. package/dist/views/list/ListView.js +70 -13
  177. package/dist/views/list/columns.js +8 -1
  178. package/dist/views/list/derive.d.ts +12 -1
  179. package/dist/views/list/derive.js +29 -3
  180. package/dist/views/list/presentations.d.ts +5 -2
  181. package/dist/views/list/presentations.js +35 -3
  182. package/dist/views/list/types.d.ts +57 -1
  183. package/dist/views/list/useNarrowList.d.ts +10 -0
  184. package/dist/views/list/useNarrowList.js +20 -6
  185. package/dist/views/record/Record.d.ts +41 -8
  186. package/dist/views/record/Record.js +113 -28
  187. package/dist/views/record/index.d.ts +1 -1
  188. package/package.json +15 -4
  189. package/dist/components/chatter/Chatter.d.ts +0 -161
  190. package/dist/components/chatter/Chatter.js +0 -232
  191. package/dist/components/chatter/index.d.ts +0 -3
  192. package/dist/components/chatter/index.js +0 -4
  193. package/dist/components/navigation/CommandPalette.d.ts +0 -40
  194. package/dist/components/navigation/CommandPalette.js +0 -114
  195. package/dist/components/navigation/commands.d.ts +0 -30
  196. package/dist/components/navigation/commands.js +0 -30
  197. /package/dist/components/{chatter → activity}/Timeline.d.ts +0 -0
  198. /package/dist/components/{chatter → activity}/Timeline.js +0 -0
@@ -1,20 +1,69 @@
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
- import { ComboBox as AriaComboBox, ComboBoxStateContext, Input, ListBox, ListBoxItem, Text, useFilter, } from "react-aria-components";
15
- /** The id of the «see all…» row. Not a record id: it cannot collide with one. */
18
+ import { Collection, ComboBox as AriaComboBox, ComboBoxStateContext, Header, Input, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, Text, useFilter, } from "react-aria-components";
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;
35
+ /**
36
+ * What the list shows and how many are left out of it. Capped at 8 with the footer count,
37
+ * unless the list loads incrementally — then everything that arrived is listed and the
38
+ * scroll asks for more.
39
+ */
40
+ export function shownRows(filtered, { loading, incremental }) {
41
+ if (loading)
42
+ return { visible: [], remaining: 0 };
43
+ if (incremental)
44
+ return { visible: filtered, remaining: 0 };
45
+ const visible = filtered.slice(0, CAP);
46
+ return { visible, remaining: filtered.length - visible.length };
47
+ }
48
+ /** Folds consecutive items of the same `section` into one run under a header. The ids of
49
+ * the runs cannot collide with a record id, like the «Buscar más…» one. */
50
+ function sectioned(items) {
51
+ const out = [];
52
+ for (const item of items) {
53
+ if (item.section === undefined) {
54
+ out.push(item);
55
+ continue;
56
+ }
57
+ const last = out.at(-1);
58
+ if (last !== undefined && "items" in last && last.section === item.section) {
59
+ last.items.push(item);
60
+ }
61
+ else {
62
+ out.push({ id: `\u0000section-${out.length}`, section: item.section, items: [item] });
63
+ }
64
+ }
65
+ return out;
66
+ }
18
67
  /* No focus ring on an option: the focus stays in the input (the list is driven from it),
19
68
  and the marked row is said by its tint — a ring inside the list as well as around the
20
69
  field is two marks for one place. */
@@ -48,7 +97,7 @@ export function Picker({ mode, ...props }) {
48
97
  to be ABOVE the component that asks for it. */
49
98
  return (_jsx(AriaLocale, { children: _jsx(EditablePicker, { ...props }) }));
50
99
  }
51
- function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrowseAll, onOpen, emptyText, searchPlaceholder, loading = false, loadingText, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, }) {
100
+ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrowseAll, onCreate, onCreateAndEdit, onOpen, openTargets = ["modal", "peek", "side", "page"], emptyText, searchPlaceholder, loading = false, loadingText, onLoadMore, loadingMore = false, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, "aria-label": ariaLabel, }) {
52
101
  const { id, labelledBy, describedBy, invalid } = useFieldIds({
53
102
  id: ownId,
54
103
  describedBy: ownDescribedBy,
@@ -80,29 +129,55 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
80
129
  return items;
81
130
  return items.filter((i) => contains(`${i.name} ${i.meta ?? ""} ${i.haystack ?? ""}`, q));
82
131
  }, [items, query, contains]);
83
- const visible = loading ? [] : filtered.slice(0, CAP);
84
- const remaining = loading ? 0 : filtered.length - visible.length;
132
+ const { visible, remaining } = shownRows(filtered, {
133
+ loading,
134
+ incremental: onLoadMore !== undefined,
135
+ });
85
136
  const hasMarks = items.some((i) => i.icon !== undefined);
86
137
  const hasHints = visible.some((i) => i.meta !== undefined);
87
138
  /* «See all…» is an OPTION at the end of the list and not a button in a footer: the
88
139
  arrows reach it like any other row. As a footer button it was out of the keyboard's
89
140
  path — Tab leaves the field and closes the list before getting there. */
90
- const browsable = remaining > 0 && onBrowseAll !== undefined;
91
- const listed = browsable
92
- ? [...visible, { id: BROWSE_ALL, name: copy.picker.browseAll }]
93
- : visible;
141
+ const browsable = onBrowseAll !== undefined;
142
+ const typed = query.trim();
143
+ /* Exactly the name of an option already: creating it again would make a twin. */
144
+ const named = typed !== "" &&
145
+ items.some((i) => i.name.localeCompare(typed, copy.locale, { sensitivity: "base" }) === 0);
146
+ const actions = loading
147
+ ? []
148
+ : [
149
+ ...(onCreate !== undefined && typed !== "" && !named
150
+ ? [{ id: CREATE, name: fillCopy(copy.picker.create, { name: typed }) }]
151
+ : []),
152
+ ...(onCreateAndEdit === undefined
153
+ ? []
154
+ : [{ id: CREATE_EDIT, name: copy.picker.createAndEdit }]),
155
+ ...(browsable ? [{ id: BROWSE_ALL, name: copy.picker.browseAll }] : []),
156
+ ];
157
+ const listed = [...visible, ...actions];
94
158
  const closeList = useRef(null);
95
159
  const take = (key) => {
96
160
  if (key === null)
97
161
  return;
98
162
  const next = String(key);
99
- if (next === BROWSE_ALL) {
100
- /* The full list opens somewhere else (a dialog, a panel): this one closes and the
101
- field keeps the chosen name. */
163
+ if (ACTIONS.has(next)) {
164
+ /* The full list, or the new record's form, opens somewhere else (a dialog, a panel):
165
+ this one closes and the field keeps the chosen name. A quick create chooses the new
166
+ record once it exists. */
167
+ const asked = query;
102
168
  closeList.current?.();
103
169
  setText(chosenName);
104
170
  setQuery("");
105
- onBrowseAll?.();
171
+ if (next === BROWSE_ALL)
172
+ onBrowseAll?.(asked);
173
+ if (next === CREATE_EDIT)
174
+ onCreateAndEdit?.(asked.trim());
175
+ if (next === CREATE && onCreate !== undefined) {
176
+ /* The field keeps the new name from now on, not the old one until the record exists:
177
+ a controlled text that changes while the input has focus reopens the list. */
178
+ setText(asked.trim());
179
+ void Promise.resolve(onCreate(asked.trim())).then((created) => onChange?.(created));
180
+ }
106
181
  return;
107
182
  }
108
183
  setText(items.find((i) => i.id === next)?.name ?? "");
@@ -110,7 +185,7 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
110
185
  if (next !== value)
111
186
  onChange?.(next);
112
187
  };
113
- return (_jsxs(AriaComboBox, { id: id, "aria-labelledby": labelledBy, "aria-describedby": describedBy, items: listed, value: value ?? null, onChange: take, inputValue: text, onInputChange: (next) => {
188
+ return (_jsxs(AriaComboBox, { id: id, "aria-labelledby": labelledBy, "aria-label": ariaLabel, "aria-describedby": describedBy, items: listed, value: value ?? null, onChange: take, inputValue: text, onInputChange: (next) => {
114
189
  setText(next);
115
190
  setQuery(next);
116
191
  }, onOpenChange: (isOpen, trigger) => {
@@ -126,23 +201,67 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
126
201
  the options are the answer before the first letter. */
127
202
  menuTrigger: "focus",
128
203
  /* An empty list stays open to SAY it is empty (or loading) instead of vanishing. */
129
- 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,
204
+ 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,
130
205
  /* Arriving selects the name, so the first letter typed replaces it. Only when
131
206
  ARRIVING: React Aria hands the focus back to the input while the list is open,
132
207
  and selecting again then would swallow every second letter. */
133
208
  onFocus: (event) => {
134
209
  if (!open)
135
210
  event.currentTarget.select();
136
- }, 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(ListBox, { items: listed, renderEmptyState: () => (_jsx("p", { className: "px-3 py-3 text-center text-meta text-muted", children: loading ? shownLoading : shownEmpty })), className: "min-h-0 flex-1 overflow-y-auto p-1 outline-0", children: (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, {
137
- count: remaining.toLocaleString(copy.locale),
138
- }) })] }) })) : (_jsx(ListBoxItem, { id: item.id, textValue: item.name, isDisabled: item.disabled, className: (state) => option({ ...state, hints: hasHints }), children: ({ isSelected }) => (_jsx(Row, { item: item, selected: isSelected, marks: hasMarks, hints: hasHints })) })) }), 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] })] }));
211
+ }, 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 &&
212
+ value !== "" &&
213
+ onOpen !== undefined &&
214
+ 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: [
215
+ {
216
+ options: openTargets.slice(1).map((to) => ({
217
+ id: to,
218
+ label: copy.picker.openIn[to],
219
+ icon: OPEN_ICON[to],
220
+ })),
221
+ },
222
+ ], 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,
223
+ /* Not while the first page loads: the sentinel of an empty list is in view and
224
+ would ask for page two before page one arrived. */
225
+ 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] })] }));
139
226
  }
140
- function Row({ item, selected, marks, hints, }) {
227
+ /**
228
+ * The list of a `Picker`: its rows, the section headers, the «Buscar más…» row and the loading
229
+ * row at the end. Internal — not exported from the family — and apart from the combobox so it
230
+ * can be rendered on the server without a popover, which is how the tests see the rows.
231
+ */
232
+ export function PickerOptions({ items, marks, hints, remaining, query = "", emptyText, onLoadMore, loadingMore = false, "aria-label": ariaLabel, }) {
233
+ const copy = useUiCopy();
234
+ const entries = useMemo(() => sectioned(items), [items]);
235
+ /* The actions are one block under the records: one hairline above the first of them. */
236
+ const firstAction = items.find((i) => ACTIONS.has(i.id))?.id;
237
+ 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
238
+ ? "mt-1 border-t border-line"
239
+ : ""), 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, {
240
+ count: remaining.toLocaleString(copy.locale),
241
+ }) }))] }) })) : (_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 })) }));
242
+ return (_jsxs(ListBox, { "aria-label": ariaLabel,
243
+ /* Nothing matched YET while the next page is coming: «nothing matches» above «loading
244
+ more…» says two contradicting things, so the empty list says only the second. */
245
+ renderEmptyState: () => (_jsx("p", { className: "px-3 py-3 text-center text-meta text-muted", children: loadingMore ? copy.picker.loadingMore : emptyText })),
246
+ /* Incremental, the list is not capped: it scrolls inside its own height and the
247
+ sentinel at its end is what asks for more. */
248
+ 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 }))] }));
249
+ }
250
+ function Row({ item, selected, marks, hints, query, locale, }) {
141
251
  const Icon = item.icon;
142
- 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] }));
252
+ const copy = useUiCopy();
253
+ /* Listed for a code that is not drawn — the barcode, the NIT —: say which, or the row reads
254
+ as a wrong match. */
255
+ const hidden = query.trim() === "" ||
256
+ matches(item.name, query, locale) ||
257
+ (item.meta !== undefined && matches(item.meta, query, locale)) ||
258
+ item.haystack === undefined
259
+ ? null
260
+ : matchedWord(item.haystack, query, locale);
261
+ 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] }));
143
262
  }
144
263
  /** Hands the combobox's `close` to the code outside it: React Aria owns the open state and
145
- * offers no controlled `isOpen`, and the «see all…» row has to close the list it lives in. */
264
+ * offers no controlled `isOpen`, and the «Buscar más…» row has to close the list it lives in. */
146
265
  function CloseBridge({ into }) {
147
266
  const state = useContext(ComboBoxStateContext);
148
267
  into.current = state === null ? null : () => state.close();
@@ -0,0 +1,20 @@
1
+ import { type ReactNode } from "react";
2
+ /**
3
+ * A cell's value with its copy button — the table's version of a field's (see
4
+ * `primitives/field/copy.tsx`): the number of an invoice, a customer's name, an amount, copied
5
+ * without opening the row or selecting by hand.
6
+ *
7
+ * Context: the button appears where the pointer or the focus is (on a touch screen it is
8
+ * always there) and always takes its room, so nothing in the row moves when it shows. In a
9
+ * numeric cell it goes BEFORE the figure, so the figures stay aligned on the right edge —
10
+ * which is what makes a column of amounts comparable. It copies what the cell shows
11
+ * («Bs. 4.132,50», not `4132.5`) unless the column says what to copy.
12
+ */
13
+ export declare function CellCopy({ children, header, numeric, value, }: {
14
+ children: ReactNode;
15
+ /** The column's name, for the button's: «Copiar «Cliente»». */
16
+ header: string;
17
+ numeric?: boolean;
18
+ /** What to copy instead of what is seen. */
19
+ value?: string;
20
+ }): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
4
+ import { shownText } from "../field/copy.js";
5
+ import { CopyButton } from "../value/Copyable.js";
6
+ import { useRef } from "react";
7
+ /**
8
+ * A cell's value with its copy button — the table's version of a field's (see
9
+ * `primitives/field/copy.tsx`): the number of an invoice, a customer's name, an amount, copied
10
+ * without opening the row or selecting by hand.
11
+ *
12
+ * Context: the button appears where the pointer or the focus is (on a touch screen it is
13
+ * always there) and always takes its room, so nothing in the row moves when it shows. In a
14
+ * numeric cell it goes BEFORE the figure, so the figures stay aligned on the right edge —
15
+ * which is what makes a column of amounts comparable. It copies what the cell shows
16
+ * («Bs. 4.132,50», not `4132.5`) unless the column says what to copy.
17
+ */
18
+ export function CellCopy({ children, header, numeric = false, value, }) {
19
+ const ui = useUiCopy();
20
+ const shown = useRef(null);
21
+ const button = (_jsx("span", { "data-copy-skip": "", className: "inline-flex shrink-0 opacity-0 transition-opacity duration-(--duration-instant) group-hover/cell:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100", children: _jsx(CopyButton, { label: fillCopy(ui.copyable.field, { label: header }), value: () => value ?? (shown.current === null ? "" : shownText(shown.current)) }) }));
22
+ return (_jsxs("span", { className: `group/cell flex w-full min-w-0 items-center gap-1 ${numeric ? "justify-end" : ""}`, children: [numeric ? button : null, _jsx("span", { ref: shown, className: "min-w-0", children: children }), numeric ? null : button] }));
23
+ }
@@ -8,6 +8,17 @@ import { type ReactNode } from "react";
8
8
  * identifiers or averaging file sizes produces numbers that look like data and
9
9
  * mean nothing. An aggregate has to be chosen on purpose. */
10
10
  export type Aggregate = "count" | "sum" | "mean" | "min" | "max" | "unique";
11
+ /**
12
+ * How the aggregate of a column is written: its `format` when the aggregate is in the
13
+ * column's units, `undefined` (the bare number) otherwise.
14
+ *
15
+ * Exported for the other places that draw `aggregateOf` — the bands of the sheet, the report
16
+ * built from the piles — so the footer and the band never write the same total two ways.
17
+ * Not public.
18
+ */
19
+ export declare function aggregateFormat<T>(column: DataColumn<T>): ((value: number) => string) | undefined;
20
+ /** An aggregate of a column, written — or the bare number when nothing says how. */
21
+ export declare function aggregateText<T>(column: DataColumn<T>, value: number): string | number;
11
22
  export interface DataColumn<T> {
12
23
  id: string;
13
24
  header: string;
@@ -44,6 +55,19 @@ export interface DataColumn<T> {
44
55
  numeric?: boolean;
45
56
  width?: number;
46
57
  aggregate?: Aggregate;
58
+ /**
59
+ * How a number of this column is WRITTEN where the code draws it instead of a row: the
60
+ * footer's aggregate, the total of a band of the sheet, the lines of a report built
61
+ * from the piles. `(n) => money(n)` makes the footer read «Bs. 78.752,90» like the cells
62
+ * above it, and not «78.752,9».
63
+ *
64
+ * Context: the cells already had their way (`cell`), and that way takes a ROW — the
65
+ * footer has no row, only a number. A string and not a node because two of the three
66
+ * places are grids of text: the sheet and the report. Applies to the aggregates in the
67
+ * column's units (`sum`, `mean`, `min`, `max`); a `count` stays a count. Without it,
68
+ * the number in the copy table's locale, as before.
69
+ */
70
+ format?: (value: number) => string;
47
71
  sortable?: boolean;
48
72
  /** Odoo: fields without `optional` cannot leave the view. When true, the
49
73
  * column appears in the columns submenu and can be hidden. */
@@ -66,7 +90,32 @@ export interface DataColumn<T> {
66
90
  /** Which rows can be changed — a paid invoice cannot. All of them by default. */
67
91
  when?: (row: T) => boolean;
68
92
  };
93
+ /** The control of this column in the row of a NEW record (`DataTable`'s `create`).
94
+ * The same contract as `EditableTable`'s `edit`: `set` replaces the draft. Give it an
95
+ * `aria-label` — a cell has no `Field` to name it. Without it the column stays
96
+ * empty in that row: a computed amount, a sequence the backend assigns. */
97
+ input?: (draft: T, set: (next: T) => void) => ReactNode;
98
+ /** The copy button of the cells (`CellCopy`): on by default, copying what the cell shows.
99
+ * A function copies what it returns for the row instead; `false` removes it from this
100
+ * column — a column of tags or of buttons has nothing worth pasting. */
101
+ copy?: false | ((row: T) => string);
69
102
  }
103
+ /** Writing a new record in the list. See the file's header. */
104
+ export type DataTableCreate<T> = {
105
+ /** The record being written, or `null` when there is none. */
106
+ draft: T | null;
107
+ /** «Add» was asked for: the consumer sets a blank `draft`. */
108
+ onStart: () => void;
109
+ onChange: (next: T) => void;
110
+ /** `Enter`, leaving the row after writing in it, or `Tab` past its last control. The
111
+ * consumer validates and saves, and sets `draft` back to `null` — or keeps it, to
112
+ * say what is missing. */
113
+ onCommit: (draft: T) => void;
114
+ /** `Escape`, or leaving the row without having written anything. */
115
+ onCancel: () => void;
116
+ /** The «add» under the table. Defaults to the copy table's. */
117
+ label?: string;
118
+ };
70
119
  /**
71
120
  * El agregado de una columna sobre un conjunto de filas.
72
121
  *
@@ -99,7 +148,7 @@ export type GroupColumn<T> = {
99
148
  /** Cómo se lee esa clave. */
100
149
  labelOf: (key: string) => string;
101
150
  };
102
- export declare function DataTable<T extends object>({ data, columns, getRowId, sort, onSortChange, levels, selected, onSelectedChange, height, onRowClick, rowActions, totals, collapsed, onGroupToggle, }: {
151
+ export declare function DataTable<T extends object>({ data, columns, getRowId, sort, onSortChange, levels, selected, onSelectedChange, height, onRowClick, rowActions, totals, collapsed, onGroupToggle, create, copyable, }: {
103
152
  data: readonly T[];
104
153
  columns: readonly DataColumn<T>[];
105
154
  getRowId: (row: T) => string;
@@ -132,4 +181,9 @@ export declare function DataTable<T extends object>({ data, columns, getRowId, s
132
181
  * table does not reopen what rows just folded. */
133
182
  collapsed?: readonly string[];
134
183
  onGroupToggle?: (key: string) => void;
184
+ /** Write a new record as a row of the list. See the file's header. */
185
+ create?: DataTableCreate<T>;
186
+ /** The cells' copy buttons, on by default. `false` takes them off the whole table; a
187
+ * column turns its own off with `copy: false`. */
188
+ copyable?: boolean;
135
189
  }): import("react").JSX.Element;
@@ -2,27 +2,54 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { fillCopy, useUiCopy } from "../../foundations/copy.js";
4
4
  import { pathKey } from "../../foundations/grouping.js";
5
+ import { CellCopy } from "./CellCopy.js";
5
6
  import { CellEditor } from "./CellEditor.js";
7
+ import { EditRow, firstControl } from "./EditableTable.js";
6
8
  import { RowActions } from "./RowActions.js";
7
9
  import { SelectBox, Table, Td, Th, THead, Tr, } from "./Table.js";
8
10
  import { aggregationFns, columnGroupingFeature, createColumnHelper, createExpandedRowModel, createGroupedRowModel, createSortedRowModel, rowAggregationFeature, rowExpandingFeature, rowSortingFeature, sortFns, tableFeatures, useTable, } from "@tanstack/react-table";
9
- import { ChevronRight } from "lucide-react";
10
- import { useMemo } from "react";
11
- function aggregateLabel(copy, kind, v) {
11
+ import { ChevronRight, Plus } from "lucide-react";
12
+ import { useEffect, useMemo, useRef, useState } from "react";
13
+ /** The aggregates that are an amount OF the column — a sum of bolivianos is bolivianos — and
14
+ * so are written the way the column writes its numbers. `count` and `unique` count rows
15
+ * and values: «Bs. 12,00 rows» would be nonsense. */
16
+ const IN_COLUMN_UNITS = new Set(["sum", "mean", "min", "max"]);
17
+ /**
18
+ * How the aggregate of a column is written: its `format` when the aggregate is in the
19
+ * column's units, `undefined` (the bare number) otherwise.
20
+ *
21
+ * Exported for the other places that draw `aggregateOf` — the bands of the sheet, the report
22
+ * built from the piles — so the footer and the band never write the same total two ways.
23
+ * Not public.
24
+ */
25
+ export function aggregateFormat(column) {
26
+ return column.aggregate !== undefined && IN_COLUMN_UNITS.has(column.aggregate)
27
+ ? column.format
28
+ : undefined;
29
+ }
30
+ /** An aggregate of a column, written — or the bare number when nothing says how. */
31
+ export function aggregateText(column, value) {
32
+ const format = aggregateFormat(column);
33
+ return format === undefined ? value : format(value);
34
+ }
35
+ function aggregateLabel(copy, column, v) {
12
36
  const n = v.toLocaleString(copy.locale);
13
- switch (kind) {
37
+ /* Raw, a sum of amounts read «78.752,9»: one decimal lost and no currency, under a
38
+ column whose every cell says «Bs. 12.300,00». */
39
+ const amount = (fallback) => column.format === undefined ? fallback : column.format(v);
40
+ switch (column.aggregate ?? "count") {
14
41
  case "sum":
15
- return n;
42
+ return amount(n);
16
43
  case "count":
17
44
  return `${n} ${v === 1 ? copy.table.row : copy.table.rows}`;
18
45
  case "mean":
19
46
  return fillCopy(copy.table.mean, {
20
- value: v.toLocaleString(copy.locale, { maximumFractionDigits: 2 }),
47
+ value: amount(v.toLocaleString(copy.locale, { maximumFractionDigits: 2 })),
21
48
  });
22
49
  case "min":
23
- return fillCopy(copy.table.min, { value: n });
50
+ return fillCopy(copy.table.min, { value: amount(n) });
24
51
  case "max":
25
- return fillCopy(copy.table.max, { value: n });
52
+ return fillCopy(copy.table.max, { value: amount(n) });
26
53
  case "unique":
27
54
  return fillCopy(copy.table.distinct, { value: n });
28
55
  }
@@ -90,8 +117,46 @@ export function aggregateOf(column, rows) {
90
117
  return numbers.length;
91
118
  }
92
119
  }
93
- export function DataTable({ data, columns, getRowId, sort, onSortChange, levels, selected, onSelectedChange, height, onRowClick, rowActions, totals = false, collapsed, onGroupToggle, }) {
120
+ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels, selected, onSelectedChange, height, onRowClick, rowActions, totals = false, collapsed, onGroupToggle, create, copyable = true, }) {
94
121
  const copy = useUiCopy();
122
+ const draft = create?.draft ?? null;
123
+ const draftRow = useRef(null);
124
+ const addButton = useRef(null);
125
+ /* Whether anything was written in the draft: leaving a blank one drops it. */
126
+ const touched = useRef(false);
127
+ const writing = draft !== null;
128
+ /* Each «add» is a session. With `Tab` the next draft replaces the saved one without
129
+ ever being `null`, so `writing` alone would not say a new record began. */
130
+ const [session, setSession] = useState(0);
131
+ /* «add» is hidden while writing: the focus goes back to it once it is there again. */
132
+ const backToAdd = useRef(false);
133
+ const start = () => {
134
+ setSession((n) => n + 1);
135
+ create?.onStart();
136
+ };
137
+ /* A draft that appears takes the focus on its first control — «add» is a request to
138
+ write, not to look for where to. */
139
+ useEffect(() => {
140
+ if (!writing) {
141
+ if (backToAdd.current)
142
+ addButton.current?.focus();
143
+ backToAdd.current = false;
144
+ return;
145
+ }
146
+ touched.current = false;
147
+ const node = draftRow.current;
148
+ if (node !== null)
149
+ (firstControl(node) ?? node).focus();
150
+ }, [writing, session]);
151
+ const saveDraft = (refocus) => {
152
+ if (create === undefined || draft === null)
153
+ return;
154
+ backToAdd.current = refocus;
155
+ if (touched.current)
156
+ create.onCommit(draft);
157
+ else
158
+ create.onCancel();
159
+ };
95
160
  // In v9 the helper is bound to the features AND to the data type: that is what
96
161
  // makes the compiler know about `enableGrouping` only if we ask for the
97
162
  // feature.
@@ -208,37 +273,58 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
208
273
  n.delete(id);
209
274
  onSelectedChange?.(n);
210
275
  }
211
- return (_jsxs(Table, { height: height, children: [_jsxs(THead, { children: [selecting ? (_jsx(Th, { width: 36, children: _jsx(SelectBox, { label: copy.selection.all, checked: all, indeterminate: some, onChange: (v) => onSelectedChange?.(v ? new Set(leaves.map((r) => r.id)) : new Set()) }) })) : null, columns.map((c) => (_jsx(Th, { sortKey: c.sortable === false ? undefined : c.id, sort: sort, onSort: onSortChange, numeric: c.numeric, width: c.width, children: c.header }, c.id))), rowActions === undefined ? null : _jsx(Th, { width: 1, children: "" })] }), _jsx("tbody", { children: drawn.map((row) => {
212
- const isGroup = row.subRows.length > 0;
213
- if (isGroup) {
214
- const path = pathOf(row);
215
- const key = pathKey(path);
216
- const depth = path.length - 1;
217
- const id = row.groupingColumnId ?? undefined;
218
- const level = levels?.find((one) => one.id === id);
219
- /* La CLAVE del montón —la del motor— y el rótulo, que lo pone el
220
- nivel: son dos textos y sólo el segundo cambia con el idioma. */
221
- const value = path[depth] ?? key;
222
- const open = collapsed === undefined
223
- ? row.getIsExpanded()
224
- : collapsed.includes(key) !== true;
225
- return (
226
- /* The group row: it spans the whole width, says which value it
227
- groups by and how many are inside. The counter is what makes it
228
- useful without opening it. */
229
- _jsx("tr", { className: "border-b border-line bg-ground", children: _jsx("td", { colSpan: columns.length + (selecting ? 1 : 0) + (rowActions === undefined ? 0 : 1), className: "px-3 py-1.5", style: depth === 0
230
- ? undefined
231
- : { paddingLeft: `calc(var(--spacing-indent) * ${depth} + 0.75rem)` }, children: _jsxs("button", { type: "button", onClick: () => onGroupToggle === undefined ? row.toggleExpanded() : onGroupToggle(key), "aria-expanded": open, className: "inline-flex items-center gap-1.5 rounded-control text-meta font-medium text-ink transition-colors duration-(--duration-instant) hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: [_jsx(ChevronRight, { className: `size-(--size-icon-sm) text-muted transition-transform duration-(--duration-instant) ${open ? "rotate-90" : ""}`, strokeWidth: 2, "aria-hidden": true }), level === undefined ? null : (_jsx("span", { className: "text-micro font-normal text-muted/70", children: level.label })), _jsx("span", { children: level === undefined ? value : level.labelOf(value) }), _jsx("span", { className: "inline-flex shrink-0 items-center rounded-pill border border-line-strong bg-ink/5 px-2 py-0.5 text-micro tabular-nums text-muted", children: row.getLeafRows().length.toLocaleString(copy.locale) })] }) }) }, row.id));
232
- }
233
- const original = row.original;
234
- return (_jsxs(Tr, { selected: selected?.has(row.id) === true, onClick: onRowClick === undefined ? undefined : () => onRowClick(original), children: [selecting ? (_jsx(Td, { children: _jsx(SelectBox, { label: copy.selection.row, checked: selected?.has(row.id) === true, onChange: (v) => toggle(row.id, v) }) })) : null, columns.map((c) => {
235
- const t = c.tone?.(original);
236
- const n = c.note?.(original);
237
- const edit = c.edit?.when?.(original) === false ? undefined : c.edit;
238
- return (_jsx(Td, { numeric: c.numeric, mono: c.mono === true, ...(t === undefined ? {} : { tone: t }), ...(n === undefined ? {} : { title: n }), children: edit !== undefined ? (_jsx(CellEditor, { label: fillCopy(copy.inlineEdit.editCell, { column: c.header }), numeric: c.numeric === true, editor: (done) => edit.render(original, done), children: drawCell(copy, c, original) })) : (drawCell(copy, c, original)) }, c.id));
239
- }), rowActions === undefined ? null : (_jsx(RowActions, { actions: rowActions(original) }))] }, row.id));
240
- }) }), footer === null ? null : (_jsx("tfoot", { className: "sticky bottom-0 z-10 bg-ground", children: _jsxs("tr", { className: "border-t border-line-strong", children: [selecting ? _jsx("td", {}) : null, columns.map((c, i) => {
276
+ const grid = (_jsxs(Table, { height: height, children: [_jsxs(THead, { children: [selecting ? (_jsx(Th, { width: 36, children: _jsx(SelectBox, { label: copy.selection.all, checked: all, indeterminate: some, onChange: (v) => onSelectedChange?.(v ? new Set(leaves.map((r) => r.id)) : new Set()) }) })) : null, columns.map((c) => (_jsx(Th, { sortKey: c.sortable === false ? undefined : c.id, sort: sort, onSort: onSortChange, numeric: c.numeric, width: c.width, children: c.header }, c.id))), rowActions === undefined ? null : _jsx(Th, { width: 1, children: "" })] }), _jsxs("tbody", { children: [drawn.map((row) => {
277
+ const isGroup = row.subRows.length > 0;
278
+ if (isGroup) {
279
+ const path = pathOf(row);
280
+ const key = pathKey(path);
281
+ const depth = path.length - 1;
282
+ const id = row.groupingColumnId ?? undefined;
283
+ const level = levels?.find((one) => one.id === id);
284
+ /* La CLAVE del montón —la del motor— y el rótulo, que lo pone el
285
+ nivel: son dos textos y sólo el segundo cambia con el idioma. */
286
+ const value = path[depth] ?? key;
287
+ const open = collapsed === undefined
288
+ ? row.getIsExpanded()
289
+ : collapsed.includes(key) !== true;
290
+ return (
291
+ /* The group row: it spans the whole width, says which value it
292
+ groups by and how many are inside. The counter is what makes it
293
+ useful without opening it. */
294
+ _jsx("tr", { className: "border-b border-line bg-ground", children: _jsx("td", { colSpan: columns.length + (selecting ? 1 : 0) + (rowActions === undefined ? 0 : 1), className: "px-3 py-1.5", style: depth === 0
295
+ ? undefined
296
+ : { paddingLeft: `calc(var(--spacing-indent) * ${depth} + 0.75rem)` }, children: _jsxs("button", { type: "button", onClick: () => onGroupToggle === undefined ? row.toggleExpanded() : onGroupToggle(key), "aria-expanded": open, className: "inline-flex items-center gap-1.5 rounded-control text-meta font-medium text-ink transition-colors duration-(--duration-instant) hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: [_jsx(ChevronRight, { className: `size-(--size-icon-sm) text-muted transition-transform duration-(--duration-instant) ${open ? "rotate-90" : ""}`, strokeWidth: 2, "aria-hidden": true }), level === undefined ? null : (_jsx("span", { className: "text-micro font-normal text-muted/70", children: level.label })), _jsx("span", { children: level === undefined ? value : level.labelOf(value) }), _jsx("span", { className: "inline-flex shrink-0 items-center rounded-pill border border-line-strong bg-ink/5 px-2 py-0.5 text-micro tabular-nums text-muted", children: row.getLeafRows().length.toLocaleString(copy.locale) })] }) }) }, row.id));
297
+ }
298
+ const original = row.original;
299
+ return (_jsxs(Tr, { selected: selected?.has(row.id) === true, onClick: onRowClick === undefined ? undefined : () => onRowClick(original), children: [selecting ? (_jsx(Td, { children: _jsx(SelectBox, { label: copy.selection.row, checked: selected?.has(row.id) === true, onChange: (v) => toggle(row.id, v) }) })) : null, columns.map((c) => {
300
+ const t = c.tone?.(original);
301
+ const n = c.note?.(original);
302
+ const edit = c.edit?.when?.(original) === false ? undefined : c.edit;
303
+ return (_jsx(Td, { numeric: c.numeric, mono: c.mono === true, ...(t === undefined ? {} : { tone: t }), ...(n === undefined ? {} : { title: n }), children: edit !== undefined ? (_jsx(CellEditor, { label: fillCopy(copy.inlineEdit.editCell, { column: c.header }), numeric: c.numeric === true, editor: (done) => edit.render(original, done), children: drawCell(copy, c, original) })) : copyable && c.copy !== false ? (_jsx(CellCopy, { header: c.header, numeric: c.numeric === true, ...(c.copy === undefined ? {} : { value: c.copy(original) }), children: drawCell(copy, c, original) })) : (drawCell(copy, c, original)) }, c.id));
304
+ }), rowActions === undefined ? null : (_jsx(RowActions, { actions: rowActions(original) }))] }, row.id));
305
+ }), create === undefined || draft === null ? null : (_jsxs(EditRow, { as: "tr", rowId: "\\u0000draft", rowRef: (node) => {
306
+ draftRow.current = node;
307
+ }, editable: true, editing: true, tabbable: false, className: "group/row", onStart: () => { }, onCommit: saveDraft, onCancel: () => {
308
+ backToAdd.current = true;
309
+ create.onCancel();
310
+ }, onMove: () => { }, onFocusRow: () => { }, onTabOut: (direction) => {
311
+ /* Past the last control: saved, and the next one started — unless nothing
312
+ was written, which is «I am done». Backwards, the browser's way. */
313
+ if (direction === "prev" || !touched.current)
314
+ return false;
315
+ create.onCommit(draft);
316
+ start();
317
+ return true;
318
+ }, children: [selecting ? _jsx("td", { className: DRAFT_CELL }) : null, columns.map((c) => (_jsx("td", { ...(c.input === undefined ? {} : { "data-edit": "" }), className: `${DRAFT_CELL} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: c.input?.(draft, (next) => {
319
+ touched.current = true;
320
+ create.onChange(next);
321
+ }) ?? null }, c.id))), rowActions === undefined ? null : _jsx("td", { className: DRAFT_CELL })] }))] }), footer === null ? null : (_jsx("tfoot", { className: "sticky bottom-0 z-10 bg-ground", children: _jsxs("tr", { className: "border-t border-line-strong", children: [selecting ? _jsx("td", {}) : null, columns.map((c, i) => {
241
322
  const value = footer[i] ?? null;
242
- return (_jsx("td", { className: `px-3 py-1.5 text-meta font-semibold text-ink ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: i === 0 && value === null ? (_jsxs("span", { className: "text-muted", children: [data.length.toLocaleString(copy.locale), " ", data.length === 1 ? copy.table.row : copy.table.rows] })) : value === null ? null : (aggregateLabel(copy, c.aggregate ?? "count", value)) }, c.id));
323
+ return (_jsx("td", { className: `px-3 py-1.5 text-meta font-semibold text-ink ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: i === 0 && value === null ? (_jsxs("span", { className: "text-muted", children: [data.length.toLocaleString(copy.locale), " ", data.length === 1 ? copy.table.row : copy.table.rows] })) : value === null ? null : (aggregateLabel(copy, c, value)) }, c.id));
243
324
  }), rowActions === undefined ? null : _jsx("td", {})] }) }))] }));
325
+ if (create === undefined)
326
+ return grid;
327
+ return (_jsxs("div", { className: "flex min-w-0 flex-col", children: [grid, writing ? null : (_jsx("div", { className: "px-1 py-0.5", children: _jsxs("button", { ref: addButton, type: "button", onClick: start, className: "flex w-full items-center gap-1.5 rounded-control px-3 py-1 text-left text-meta text-primary transition-colors duration-(--duration-instant) hover:bg-primary/5 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-primary", children: [_jsx(Plus, { className: "size-(--size-icon-sm)", strokeWidth: 2.5, "aria-hidden": true }), create.label ?? copy.table.add] }) }))] }));
244
328
  }
329
+ /* A cell of the row being written: the table's cell, washed like an edited row. */
330
+ const DRAFT_CELL = "border-b border-line bg-primary/5 px-3 py-1 align-middle text-meta text-ink first:rounded-l-control last:rounded-r-control";