@sincpro/web-ui 0.2.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 (128) 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 +4 -1
  45. package/dist/components/search/SearchBar.js +15 -7
  46. package/dist/components/timeline/RecordTimeline.d.ts +60 -0
  47. package/dist/components/timeline/RecordTimeline.js +214 -0
  48. package/dist/components/timeline/TimelineToolbar.d.ts +30 -0
  49. package/dist/components/timeline/TimelineToolbar.js +31 -0
  50. package/dist/components/timeline/index.d.ts +3 -0
  51. package/dist/components/timeline/index.js +3 -0
  52. package/dist/components/timeline/time.d.ts +43 -0
  53. package/dist/components/timeline/time.js +135 -0
  54. package/dist/foundations/copy.d.ts +106 -14
  55. package/dist/foundations/copy.es.js +83 -9
  56. package/dist/foundations/copy.js +83 -9
  57. package/dist/foundations/index.d.ts +1 -1
  58. package/dist/foundations/match.d.ts +23 -0
  59. package/dist/foundations/match.js +63 -0
  60. package/dist/foundations/slots.d.ts +1 -1
  61. package/dist/foundations/slots.js +8 -3
  62. package/dist/foundations/types.d.ts +7 -0
  63. package/dist/foundations/types.js +0 -36
  64. package/dist/primitives/block/index.d.ts +1 -1
  65. package/dist/primitives/block/index.js +1 -1
  66. package/dist/primitives/cell/CellStack.d.ts +7 -1
  67. package/dist/primitives/cell/CellStack.js +5 -2
  68. package/dist/primitives/choice/ComboBox.js +8 -4
  69. package/dist/primitives/choice/KanbanState.d.ts +14 -0
  70. package/dist/primitives/choice/KanbanState.js +37 -0
  71. package/dist/primitives/choice/Priority.d.ts +20 -0
  72. package/dist/primitives/choice/Priority.js +41 -0
  73. package/dist/primitives/choice/Slider.d.ts +6 -1
  74. package/dist/primitives/choice/Slider.js +2 -2
  75. package/dist/primitives/choice/index.d.ts +2 -0
  76. package/dist/primitives/choice/index.js +2 -0
  77. package/dist/primitives/dialog/Dialog.d.ts +9 -3
  78. package/dist/primitives/dialog/Dialog.js +41 -5
  79. package/dist/primitives/dialog/index.d.ts +0 -1
  80. package/dist/primitives/dialog/index.js +0 -2
  81. package/dist/primitives/notice/index.d.ts +1 -1
  82. package/dist/primitives/notice/index.js +1 -1
  83. package/dist/primitives/overlay/Preview.js +13 -4
  84. package/dist/primitives/picker/MatchedText.d.ts +10 -0
  85. package/dist/primitives/picker/MatchedText.js +12 -0
  86. package/dist/primitives/picker/Picker.d.ts +29 -7
  87. package/dist/primitives/picker/Picker.js +88 -23
  88. package/dist/primitives/table/EditableTable.d.ts +6 -1
  89. package/dist/primitives/table/EditableTable.js +70 -9
  90. package/dist/primitives/table/ReportTable.js +2 -1
  91. package/dist/primitives/table/RowActions.js +7 -2
  92. package/dist/primitives/table/Table.d.ts +5 -0
  93. package/dist/primitives/table/Table.js +17 -8
  94. package/dist/primitives/tag/Tags.d.ts +2 -0
  95. package/dist/primitives/tag/Tags.js +2 -1
  96. package/dist/primitives/tag/TagsField.d.ts +10 -0
  97. package/dist/primitives/tag/TagsField.js +34 -9
  98. package/dist/primitives/tag/choices.d.ts +2 -0
  99. package/dist/primitives/value/Avatar.d.ts +16 -0
  100. package/dist/primitives/value/Avatar.js +91 -0
  101. package/dist/primitives/value/Person.d.ts +6 -51
  102. package/dist/primitives/value/Person.js +9 -148
  103. package/dist/primitives/value/Relation.d.ts +21 -9
  104. package/dist/primitives/value/Relation.js +35 -24
  105. package/dist/primitives/value/contact.d.ts +30 -0
  106. package/dist/primitives/value/contact.js +51 -0
  107. package/dist/primitives/value/index.d.ts +1 -1
  108. package/dist/primitives/value/index.js +1 -1
  109. package/dist/tokens.css +136 -1
  110. package/dist/views/list/List.d.ts +25 -2
  111. package/dist/views/list/List.js +48 -8
  112. package/dist/views/list/ListView.d.ts +1 -1
  113. package/dist/views/list/ListView.js +39 -8
  114. package/dist/views/list/columns.js +8 -1
  115. package/dist/views/list/presentations.d.ts +5 -2
  116. package/dist/views/list/presentations.js +32 -2
  117. package/dist/views/list/types.d.ts +44 -0
  118. package/dist/views/list/useNarrowList.d.ts +10 -0
  119. package/dist/views/list/useNarrowList.js +20 -6
  120. package/dist/views/record/Record.d.ts +14 -0
  121. package/dist/views/record/Record.js +22 -7
  122. package/package.json +11 -4
  123. package/dist/components/navigation/CommandPalette.d.ts +0 -40
  124. package/dist/components/navigation/CommandPalette.js +0 -114
  125. package/dist/components/navigation/commands.d.ts +0 -30
  126. package/dist/components/navigation/commands.js +0 -30
  127. package/dist/primitives/dialog/Drill.d.ts +0 -90
  128. package/dist/primitives/dialog/Drill.js +0 -172
@@ -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) {
@@ -80,7 +97,7 @@ export function Picker({ mode, ...props }) {
80
97
  to be ABOVE the component that asks for it. */
81
98
  return (_jsx(AriaLocale, { children: _jsx(EditablePicker, { ...props }) }));
82
99
  }
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, }) {
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, }) {
84
101
  const { id, labelledBy, describedBy, invalid } = useFieldIds({
85
102
  id: ownId,
86
103
  describedBy: ownDescribedBy,
@@ -121,22 +138,46 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
121
138
  /* «See all…» is an OPTION at the end of the list and not a button in a footer: the
122
139
  arrows reach it like any other row. As a footer button it was out of the keyboard's
123
140
  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;
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];
128
158
  const closeList = useRef(null);
129
159
  const take = (key) => {
130
160
  if (key === null)
131
161
  return;
132
162
  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. */
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;
136
168
  closeList.current?.();
137
169
  setText(chosenName);
138
170
  setQuery("");
139
- 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
+ }
140
181
  return;
141
182
  }
142
183
  setText(items.find((i) => i.id === next)?.name ?? "");
@@ -160,43 +201,67 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
160
201
  the options are the answer before the first letter. */
161
202
  menuTrigger: "focus",
162
203
  /* 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,
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,
164
205
  /* Arriving selects the name, so the first letter typed replaces it. Only when
165
206
  ARRIVING: React Aria hands the focus back to the input while the list is open,
166
207
  and selecting again then would swallow every second letter. */
167
208
  onFocus: (event) => {
168
209
  if (!open)
169
210
  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,
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,
171
223
  /* Not while the first page loads: the sentinel of an empty list is in view and
172
224
  would ask for page two before page one arrived. */
173
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] })] }));
174
226
  }
175
227
  /**
176
- * The list of a `Picker`: its rows, the section headers, the «see all…» row and the loading
228
+ * The list of a `Picker`: its rows, the section headers, the «Buscar más…» row and the loading
177
229
  * row at the end. Internal — not exported from the family — and apart from the combobox so it
178
230
  * can be rendered on the server without a popover, which is how the tests see the rows.
179
231
  */
180
- export function PickerOptions({ items, marks, hints, remaining, emptyText, onLoadMore, loadingMore = false, "aria-label": ariaLabel, }) {
232
+ export function PickerOptions({ items, marks, hints, remaining, query = "", emptyText, onLoadMore, loadingMore = false, "aria-label": ariaLabel, }) {
181
233
  const copy = useUiCopy();
182
234
  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, {
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, {
184
240
  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 })) }));
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 })) }));
186
242
  return (_jsxs(ListBox, { "aria-label": ariaLabel,
187
243
  /* Nothing matched YET while the next page is coming: «nothing matches» above «loading
188
244
  more…» says two contradicting things, so the empty list says only the second. */
189
245
  renderEmptyState: () => (_jsx("p", { className: "px-3 py-3 text-center text-meta text-muted", children: loadingMore ? copy.picker.loadingMore : emptyText })),
190
246
  /* Incremental, the list is not capped: it scrolls inside its own height and the
191
247
  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 }))] }));
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 }))] }));
193
249
  }
194
- function Row({ item, selected, marks, hints, }) {
250
+ function Row({ item, selected, marks, hints, query, locale, }) {
195
251
  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] }));
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] }));
197
262
  }
198
263
  /** 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. */
264
+ * offers no controlled `isOpen`, and the «Buscar más…» row has to close the list it lives in. */
200
265
  function CloseBridge({ into }) {
201
266
  const state = useContext(ComboBoxStateContext);
202
267
  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
@@ -15,6 +15,9 @@ height, }) {
15
15
  medición. */
16
16
  _jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsx("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: children }) }));
17
17
  }
18
+ /** The text of a column header, shared by every table of the family. Ink and semibold, not
19
+ * muted: in grey at the cells' weight the names of the columns read as one more row. */
20
+ export const HEADER_TEXT = "text-meta font-semibold whitespace-nowrap text-ink";
18
21
  export function THead({ children }) {
19
22
  return (
20
23
  /* Se pega DEBAJO de lo que ya esté pegado —la barra de la aplicación y la
@@ -43,19 +46,21 @@ export function Th({ children, sortKey, sort, onSort, numeric = false, align, wi
43
46
  onSort(null);
44
47
  }
45
48
  const Icon = dir === "asc" ? ArrowUp : dir === "desc" ? ArrowDown : ChevronsUpDown;
46
- return (_jsx("th", { scope: "col", style: width === undefined ? undefined : { width }, "aria-sort": active ? (dir === "asc" ? "ascending" : "descending") : "none", className: `border-b border-line-strong px-3 py-2 text-meta font-medium whitespace-nowrap text-muted ${(align ?? (numeric ? "right" : "left")) === "right" ? "text-right" : "text-left"}`, children: sortKey === undefined ? (children) : (_jsxs("button", { type: "button", onClick: nextSort, className: `inline-flex items-center gap-1 rounded-control transition-colors duration-(--duration-instant) hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${(align ?? (numeric ? "right" : "left")) === "right" ? "flex-row-reverse" : ""} ${active ? "text-ink" : ""}`, children: [children, _jsx(Icon, { className: `size-(--size-icon-sm) shrink-0 ${active ? "text-primary" : "text-line-strong"}`, strokeWidth: 2, "aria-hidden": true })] })) }));
49
+ return (_jsx("th", { scope: "col", style: width === undefined ? undefined : { width }, "aria-sort": active ? (dir === "asc" ? "ascending" : "descending") : "none", className: `border-b border-line-strong px-3 py-2 ${HEADER_TEXT} ${(align ?? (numeric ? "right" : "left")) === "right" ? "text-right" : "text-left"}`, children: sortKey === undefined ? (children) : (_jsxs("button", { type: "button", onClick: nextSort, className: `inline-flex items-center gap-1 rounded-control transition-colors duration-(--duration-instant) hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${(align ?? (numeric ? "right" : "left")) === "right" ? "flex-row-reverse" : ""} ${active ? "text-ink" : ""}`, children: [children, _jsx(Icon, { className: `size-(--size-icon-sm) shrink-0 ${active ? "text-primary" : "text-line-strong"}`, strokeWidth: 2, "aria-hidden": true })] })) }));
47
50
  }
48
51
  /** The wash of a row lives on the cells, not on the `<tr>`. A background on the
49
52
  * row paints edge to edge; on the cells the ends can round and the table's
50
53
  * padding keeps the mark off the card. */
51
- const RowMark = createContext(false);
54
+ const RowMark = createContext({ selected: false, interactive: false });
52
55
  const ROW_WASH = "group-hover/row:bg-ink/4";
53
56
  const ROW_ON = "bg-primary/8";
54
- function rowWash(selected) {
55
- return selected ? ROW_ON : ROW_WASH;
57
+ /* The hover wash only where a click does something: on a read-only row it promised a click
58
+ that did nothing. */
59
+ function rowWash({ selected, interactive }) {
60
+ return selected ? ROW_ON : interactive ? ROW_WASH : "";
56
61
  }
57
62
  export function Tr({ children, onClick, selected = false, }) {
58
- return (_jsx(RowMark.Provider, { value: selected, children: _jsx("tr", { onClick: onClick, className: `group/row ${onClick === undefined ? "" : "cursor-pointer"}`, children: children }) }));
63
+ return (_jsx(RowMark.Provider, { value: { selected, interactive: onClick !== undefined }, children: _jsx("tr", { onClick: onClick, className: `group/row ${onClick === undefined ? "" : "cursor-pointer"}`, children: children }) }));
59
64
  }
60
65
  /**
61
66
  * A row that opens its own detail underneath.
@@ -71,7 +76,7 @@ export function Tr({ children, onClick, selected = false, }) {
71
76
  */
72
77
  export function TrDetail({ children, detail, open, onToggle, columns, label, selected = false, }) {
73
78
  const id = useId();
74
- return (_jsx(RowMark.Provider, { value: selected, children: _jsxs(_Fragment, { children: [_jsxs("tr", { onClick: onToggle, className: "group/row cursor-pointer", children: [_jsx("td", { className: `border-b border-line py-1.5 pr-0 pl-2.5 align-middle first:rounded-l-control ${rowWash(selected)}`, children: _jsx("button", { type: "button", onClick: (e) => {
79
+ return (_jsx(RowMark.Provider, { value: { selected, interactive: true }, children: _jsxs(_Fragment, { children: [_jsxs("tr", { onClick: onToggle, className: "group/row cursor-pointer", children: [_jsx("td", { className: `border-b border-line py-1.5 pr-0 pl-2.5 align-middle first:rounded-l-control ${rowWash({ selected, interactive: true })}`, children: _jsx("button", { type: "button", onClick: (e) => {
75
80
  e.stopPropagation();
76
81
  onToggle();
77
82
  }, "aria-expanded": open, "aria-controls": id, "aria-label": label, className: "inline-flex size-5 items-center justify-center rounded-pill text-muted transition-colors duration-(--duration-instant) hover:bg-ink/6 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary", children: _jsx(ChevronRight, { className: `size-(--size-icon-sm) transition-transform duration-(--duration-instant) ${open ? "rotate-90" : ""}`, strokeWidth: 2 }) }) }), children] }), _jsx("tr", { id: id, hidden: !open, children: _jsx("td", { colSpan: columns + 1, className: "border-b border-line bg-ground px-3 py-3", children: detail }) })] }) }));
@@ -87,6 +92,10 @@ export const CELL_TONE = {
87
92
  bad: "bg-bad/12",
88
93
  accent: "bg-accent/20",
89
94
  };
95
+ /** The wash of the row a cell sits in: for cells drawn outside `Td`, like the row's actions. */
96
+ export function useRowWash() {
97
+ return rowWash(useContext(RowMark));
98
+ }
90
99
  export function Td({ children, numeric = false, align,
91
100
  /** A CODE — an id, a hash, a key — set in the monospaced face. Off by default: a
92
101
  * number does not need it (a numeric cell gets tabular figures on its own), and a
@@ -100,8 +109,8 @@ mono = false,
100
109
  * something else — and in a table of forty rows by six columns, colored text
101
110
  * gets lost. */
102
111
  tone, title, }) {
103
- const selected = useContext(RowMark);
104
- return (_jsx("td", { title: title, className: `border-b border-line px-3 py-1.5 text-meta text-ink first:rounded-l-control last:rounded-r-control ${(align ?? (numeric ? "right" : "left")) === "right" ? "text-right" : "text-left"} ${mono ? "font-mono tabular-nums" : numeric ? "tabular-nums" : ""} ${tone === undefined ? rowWash(selected) : CELL_TONE[tone]}`, children: children }));
112
+ const mark = useContext(RowMark);
113
+ return (_jsx("td", { title: title, className: `border-b border-line px-3 py-1.5 text-meta text-ink first:rounded-l-control last:rounded-r-control ${(align ?? (numeric ? "right" : "left")) === "right" ? "text-right" : "text-left"} ${mono ? "font-mono tabular-nums" : numeric ? "tabular-nums" : ""} ${tone === undefined ? rowWash(mark) : CELL_TONE[tone]}`, children: children }));
105
114
  }
106
115
  /** The selection checkbox, with its third state.
107
116
  *
@@ -7,6 +7,8 @@ export type TagItem = {
7
7
  * comes from, which symbol a comparison is. It goes muted and inside. */
8
8
  note?: string;
9
9
  icon?: LucideIcon;
10
+ /** A person on the chip: its photo, or its initials when `true` — followers, assignees. */
11
+ avatar?: true | string;
10
12
  /** Choose it. Without this the chip is a value and is not pressed. */
11
13
  onSelect?: () => void;
12
14
  /** Remove it, when removing it means something. It is a separate button
@@ -4,6 +4,7 @@ import { AriaLocale } from "../../foundations/aria.js";
4
4
  import { bySurface, useSurface } from "../../foundations/surface.js";
5
5
  import { cn, focusRing } from "../../foundations/variants.js";
6
6
  import { tagTone } from "./Tag.js";
7
+ import { Avatar } from "../value/Person.js";
7
8
  import { X } from "lucide-react";
8
9
  import { Button as AriaButton, Tag as AriaTag, TagGroup, TagList, } from "react-aria-components";
9
10
  export function Tags({ items, surface, label, labelledBy, stack = false, multiple = false, }) {
@@ -95,7 +96,7 @@ function shell({ item, surface, stack, removable, pressable, }) {
95
96
  }
96
97
  function Inside({ item }) {
97
98
  const Icon = item.icon;
98
- return (_jsxs(_Fragment, { children: [Icon === undefined ? null : (_jsx(Icon, { className: "size-3 shrink-0 opacity-70", strokeWidth: 2, "aria-hidden": true })), _jsx("span", { className: "min-w-0 truncate", children: item.label }), item.note === undefined ? null : (_jsxs("span", { className: "shrink-0 opacity-60", children: ["\u00B7 ", item.note] }))] }));
99
+ return (_jsxs(_Fragment, { children: [Icon === undefined ? null : (_jsx(Icon, { className: "size-3 shrink-0 opacity-70", strokeWidth: 2, "aria-hidden": true })), item.avatar === undefined ? null : (_jsx("span", { "aria-hidden": true, className: "-my-0.5 -ml-1.5 flex shrink-0", children: _jsx(Avatar, { name: item.label, size: "sm", ...(typeof item.avatar === "string" ? { src: item.avatar } : {}) }) })), _jsx("span", { className: "min-w-0 truncate", children: item.label }), item.note === undefined ? null : (_jsxs("span", { className: "shrink-0 opacity-60", children: ["\u00B7 ", item.note] }))] }));
99
100
  }
100
101
  /* The × is React Aria's `remove` slot: it removes THIS chip and never presses it, so
101
102
  removing a pressable chip does not also choose it. */
@@ -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 TagOption } from "./choices.js";
3
4
  export type TagsFieldProps = {
@@ -14,6 +15,15 @@ export type TagsFieldProps = {
14
15
  * backend (Odoo's `name_create`); the consumer also appends the new option to `options`.
15
16
  */
16
17
  onCreate?: (label: string) => string | Promise<string>;
18
+ /** «Buscar más…» at the end of the list: the tags to add chosen from a table
19
+ * (the list that chooses, `List onChoose` with `selection`, in a modal). It gets what was typed. */
20
+ onBrowseAll?: (query: string) => void;
21
+ /** Open the record behind a chip — a follower, a product, a category. Pressing the chip (or
22
+ * Enter on it) opens it in `openTarget`; its × still only removes. Absent, a chip is a value. */
23
+ onOpen?: (id: string, to: OpenTarget) => void;
24
+ /** Where a pressed chip opens. A peek by default: a chip is a glance at one of many — «who is
25
+ * this follower?» — not a place to go and work. */
26
+ openTarget?: OpenTarget;
17
27
  /** The input's placeholder while no tag is chosen. Defaults to the copy table's «Add…». */
18
28
  placeholder?: string;
19
29
  disabled?: boolean;