@sincpro/web-ui 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +3 -3
  2. package/dist/components/activity/ActivityBadge.d.ts +16 -0
  3. package/dist/components/activity/ActivityBadge.js +29 -0
  4. package/dist/components/activity/index.d.ts +1 -0
  5. package/dist/components/activity/index.js +1 -0
  6. package/dist/components/calendar/RecordCalendar.d.ts +48 -0
  7. package/dist/components/calendar/RecordCalendar.js +101 -0
  8. package/dist/components/calendar/index.d.ts +1 -0
  9. package/dist/components/calendar/index.js +1 -0
  10. package/dist/components/form/Sheet.js +5 -1
  11. package/dist/components/gantt/RecordGantt.d.ts +44 -0
  12. package/dist/components/gantt/RecordGantt.js +110 -0
  13. package/dist/components/gantt/index.d.ts +1 -0
  14. package/dist/components/gantt/index.js +1 -0
  15. package/dist/components/index.d.ts +3 -0
  16. package/dist/components/index.js +3 -0
  17. package/dist/components/lines/LineItems.d.ts +5 -2
  18. package/dist/components/lines/LineItems.js +3 -3
  19. package/dist/components/listing/BoardColumns.d.ts +39 -0
  20. package/dist/components/listing/BoardColumns.js +138 -0
  21. package/dist/components/listing/ListingBar.d.ts +5 -0
  22. package/dist/components/listing/ListingBar.js +5 -2
  23. package/dist/components/listing/PivotExplorer.d.ts +34 -0
  24. package/dist/components/listing/PivotExplorer.js +86 -0
  25. package/dist/components/listing/RecordCard.d.ts +6 -8
  26. package/dist/components/listing/RecordCard.js +13 -7
  27. package/dist/components/listing/Row.d.ts +21 -2
  28. package/dist/components/listing/Row.js +29 -13
  29. package/dist/components/listing/index.d.ts +2 -0
  30. package/dist/components/listing/index.js +2 -0
  31. package/dist/components/listing/set.d.ts +1 -1
  32. package/dist/components/navigation/index.d.ts +0 -2
  33. package/dist/components/navigation/index.js +2 -3
  34. package/dist/components/record/RecordAudit.d.ts +17 -0
  35. package/dist/components/record/RecordAudit.js +27 -0
  36. package/dist/components/record/RecordHeader.d.ts +7 -1
  37. package/dist/components/record/RecordHeader.js +30 -18
  38. package/dist/components/record/RecordRibbon.d.ts +13 -0
  39. package/dist/components/record/RecordRibbon.js +22 -0
  40. package/dist/components/record/index.d.ts +2 -0
  41. package/dist/components/record/index.js +2 -0
  42. package/dist/components/search/CriteriaChips.d.ts +6 -1
  43. package/dist/components/search/CriteriaChips.js +71 -20
  44. package/dist/components/search/SearchBar.d.ts +15 -1
  45. package/dist/components/search/SearchBar.js +34 -16
  46. package/dist/components/search/query.d.ts +14 -11
  47. package/dist/components/search/query.js +36 -3
  48. package/dist/components/timeline/RecordTimeline.d.ts +60 -0
  49. package/dist/components/timeline/RecordTimeline.js +214 -0
  50. package/dist/components/timeline/TimelineToolbar.d.ts +30 -0
  51. package/dist/components/timeline/TimelineToolbar.js +31 -0
  52. package/dist/components/timeline/index.d.ts +3 -0
  53. package/dist/components/timeline/index.js +3 -0
  54. package/dist/components/timeline/time.d.ts +43 -0
  55. package/dist/components/timeline/time.js +135 -0
  56. package/dist/foundations/copy.d.ts +110 -14
  57. package/dist/foundations/copy.es.js +85 -9
  58. package/dist/foundations/copy.js +85 -9
  59. package/dist/foundations/grouping.d.ts +3 -0
  60. package/dist/foundations/index.d.ts +1 -1
  61. package/dist/foundations/match.d.ts +23 -0
  62. package/dist/foundations/match.js +63 -0
  63. package/dist/foundations/slots.d.ts +1 -1
  64. package/dist/foundations/slots.js +8 -3
  65. package/dist/foundations/types.d.ts +7 -0
  66. package/dist/foundations/types.js +0 -36
  67. package/dist/primitives/block/index.d.ts +1 -1
  68. package/dist/primitives/block/index.js +1 -1
  69. package/dist/primitives/cell/CellStack.d.ts +7 -1
  70. package/dist/primitives/cell/CellStack.js +5 -2
  71. package/dist/primitives/choice/ComboBox.js +8 -4
  72. package/dist/primitives/choice/KanbanState.d.ts +14 -0
  73. package/dist/primitives/choice/KanbanState.js +37 -0
  74. package/dist/primitives/choice/Priority.d.ts +20 -0
  75. package/dist/primitives/choice/Priority.js +41 -0
  76. package/dist/primitives/choice/Slider.d.ts +6 -1
  77. package/dist/primitives/choice/Slider.js +2 -2
  78. package/dist/primitives/choice/index.d.ts +2 -0
  79. package/dist/primitives/choice/index.js +2 -0
  80. package/dist/primitives/dialog/Dialog.d.ts +9 -3
  81. package/dist/primitives/dialog/Dialog.js +41 -5
  82. package/dist/primitives/dialog/index.d.ts +0 -1
  83. package/dist/primitives/dialog/index.js +0 -2
  84. package/dist/primitives/notice/index.d.ts +1 -1
  85. package/dist/primitives/notice/index.js +1 -1
  86. package/dist/primitives/overlay/Preview.js +13 -4
  87. package/dist/primitives/picker/MatchedText.d.ts +10 -0
  88. package/dist/primitives/picker/MatchedText.js +12 -0
  89. package/dist/primitives/picker/Picker.d.ts +42 -7
  90. package/dist/primitives/picker/Picker.js +107 -29
  91. package/dist/primitives/table/EditableTable.d.ts +6 -1
  92. package/dist/primitives/table/EditableTable.js +70 -9
  93. package/dist/primitives/table/ReportTable.js +2 -1
  94. package/dist/primitives/table/RowActions.js +7 -2
  95. package/dist/primitives/table/Table.d.ts +5 -0
  96. package/dist/primitives/table/Table.js +17 -8
  97. package/dist/primitives/tag/Tags.d.ts +2 -0
  98. package/dist/primitives/tag/Tags.js +2 -1
  99. package/dist/primitives/tag/TagsField.d.ts +17 -0
  100. package/dist/primitives/tag/TagsField.js +46 -11
  101. package/dist/primitives/tag/choices.d.ts +6 -1
  102. package/dist/primitives/tag/choices.js +6 -2
  103. package/dist/primitives/value/Avatar.d.ts +16 -0
  104. package/dist/primitives/value/Avatar.js +91 -0
  105. package/dist/primitives/value/Person.d.ts +6 -51
  106. package/dist/primitives/value/Person.js +9 -148
  107. package/dist/primitives/value/Relation.d.ts +21 -9
  108. package/dist/primitives/value/Relation.js +35 -24
  109. package/dist/primitives/value/contact.d.ts +30 -0
  110. package/dist/primitives/value/contact.js +51 -0
  111. package/dist/primitives/value/index.d.ts +1 -1
  112. package/dist/primitives/value/index.js +1 -1
  113. package/dist/tokens.css +136 -1
  114. package/dist/views/list/List.d.ts +25 -2
  115. package/dist/views/list/List.js +48 -8
  116. package/dist/views/list/ListView.d.ts +1 -1
  117. package/dist/views/list/ListView.js +40 -9
  118. package/dist/views/list/columns.js +8 -1
  119. package/dist/views/list/presentations.d.ts +5 -2
  120. package/dist/views/list/presentations.js +32 -2
  121. package/dist/views/list/types.d.ts +44 -0
  122. package/dist/views/list/useNarrowList.d.ts +10 -0
  123. package/dist/views/list/useNarrowList.js +20 -6
  124. package/dist/views/record/Record.d.ts +14 -0
  125. package/dist/views/record/Record.js +22 -7
  126. package/package.json +11 -4
  127. package/dist/components/navigation/CommandPalette.d.ts +0 -40
  128. package/dist/components/navigation/CommandPalette.js +0 -114
  129. package/dist/components/navigation/commands.d.ts +0 -30
  130. package/dist/components/navigation/commands.js +0 -30
  131. package/dist/primitives/dialog/Drill.d.ts +0 -90
  132. package/dist/primitives/dialog/Drill.js +0 -172
@@ -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,22 @@ 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
+ /**
19
+ * The options are SEARCHED by whoever gives them — the server's literal search over
20
+ * thousands of products or people — and this hears what is typed (`""` when it starts over).
21
+ * With it the field filters nothing itself: `options` is the answer, plus the chosen ones for
22
+ * their labels; the chosen still never come back as options.
23
+ */
24
+ onSearch?: (query: string) => void;
25
+ /** «Buscar más…» at the end of the list: the tags to add chosen from a table
26
+ * (the list that chooses, `List onChoose` with `selection`, in a modal). It gets what was typed. */
27
+ onBrowseAll?: (query: string) => void;
28
+ /** Open the record behind a chip — a follower, a product, a category. Pressing the chip (or
29
+ * Enter on it) opens it in `openTarget`; its × still only removes. Absent, a chip is a value. */
30
+ onOpen?: (id: string, to: OpenTarget) => void;
31
+ /** Where a pressed chip opens. A peek by default: a chip is a glance at one of many — «who is
32
+ * this follower?» — not a place to go and work. */
33
+ openTarget?: OpenTarget;
17
34
  /** The input's placeholder while no tag is chosen. Defaults to the copy table's «Add…». */
18
35
  placeholder?: string;
19
36
  disabled?: boolean;
@@ -8,9 +8,11 @@ import { useFieldIds } from "../field/context.js";
8
8
  import { ReadValue } from "../field/Field.js";
9
9
  import { useFormMode } from "../field/mode.js";
10
10
  import { Popover } from "../overlay/Popover.js";
11
+ import { MatchedText } from "../picker/MatchedText.js";
11
12
  import { tagChoices, withoutTag, withTag, } from "./choices.js";
12
13
  import { Tags } from "./Tags.js";
13
- import { Plus } from "lucide-react";
14
+ import { Avatar } from "../value/Person.js";
15
+ import { Plus, Search } from "lucide-react";
14
16
  import { useContext, useEffect, useMemo, useRef, useState, } from "react";
15
17
  import { ComboBox as AriaComboBox, ComboBoxStateContext, Group, Input, ListBox, ListBoxItem, useFilter, useLocale, } from "react-aria-components";
16
18
  /* ==================================================================== *
@@ -43,6 +45,15 @@ import { ComboBox as AriaComboBox, ComboBoxStateContext, Group, Input, ListBox,
43
45
  * ==================================================================== */
44
46
  /** The key of the «create» row. Not an id: it cannot collide with one. */
45
47
  const CREATE = "\u0000create";
48
+ /** The key of the «Buscar más…» row. */
49
+ const BROWSE = "\u0000browse";
50
+ /** What a chip needs to open its record: the press, and the place it opens named as its title. */
51
+ function useChipOpening(onOpen, to = "peek") {
52
+ const copy = useUiCopy();
53
+ return (id) => onOpen === undefined
54
+ ? {}
55
+ : { onSelect: () => onOpen(id, to), title: copy.picker.openIn[to] };
56
+ }
46
57
  /** The chosen tags as chips, in the order of `value`. An id missing from `options` is still
47
58
  * drawn (as itself): hiding it would hide data the record has. */
48
59
  function chosenOptions(options, value) {
@@ -56,6 +67,7 @@ function chosenOptions(options, value) {
56
67
  * chips, and its hint and error describe the input. In read mode it is `Tags`.
57
68
  */
58
69
  export function TagsField({ mode, ...props }) {
70
+ const opening = useChipOpening(props.onOpen, props.openTarget);
59
71
  if (useFormMode(mode) === "read") {
60
72
  const chosen = chosenOptions(props.options, props.value);
61
73
  if (chosen.length === 0)
@@ -64,23 +76,36 @@ export function TagsField({ mode, ...props }) {
64
76
  id: option.value,
65
77
  label: option.label,
66
78
  ...(option.tone === undefined ? {} : { tone: option.tone }),
79
+ ...(option.avatar === undefined ? {} : { avatar: option.avatar }),
80
+ ...opening(option.value),
67
81
  })) }) }));
68
82
  }
69
83
  /* The filter reads the locale from React Aria's context, so the box that provides it has
70
84
  to be ABOVE the component that asks for it. */
71
85
  return (_jsx(AriaLocale, { children: _jsx(EditableTagsField, { ...props }) }));
72
86
  }
73
- function EditableTagsField({ value, onChange, options, onCreate, placeholder, disabled = false, id: ownId, describedBy: ownDescribedBy, invalid: ownInvalid, "aria-label": ariaLabel, }) {
87
+ function EditableTagsField({ value, onChange, options, onCreate, onSearch, onBrowseAll, onOpen, openTarget, placeholder, disabled = false, id: ownId, describedBy: ownDescribedBy, invalid: ownInvalid, "aria-label": ariaLabel, }) {
74
88
  const { id, labelledBy, describedBy, invalid } = useFieldIds({
75
89
  id: ownId,
76
90
  describedBy: ownDescribedBy,
77
91
  invalid: ownInvalid,
78
92
  });
79
93
  const copy = useUiCopy();
94
+ const opening = useChipOpening(onOpen, openTarget);
80
95
  const { contains } = useFilter({ sensitivity: "base" });
81
96
  const { locale } = useLocale();
82
97
  const collator = useMemo(() => new Intl.Collator(locale, { sensitivity: "base", usage: "search" }), [locale]);
83
- const [text, setText] = useState("");
98
+ const [text, setTextOnly] = useState("");
99
+ /* What the consumer last heard: an add or a reopen starts the search over, and telling it
100
+ `""` twice would ask the server twice for the same page. */
101
+ const heard = useRef("");
102
+ const setText = (next) => {
103
+ setTextOnly(next);
104
+ if (onSearch !== undefined && next !== heard.current) {
105
+ heard.current = next;
106
+ onSearch(next);
107
+ }
108
+ };
84
109
  const boxRef = useRef(null);
85
110
  const [width, setWidth] = useState(null);
86
111
  const inputRef = useRef(null);
@@ -88,18 +113,26 @@ function EditableTagsField({ value, onChange, options, onCreate, placeholder, di
88
113
  creating: a tag removed meanwhile must not come back. */
89
114
  const latest = useRef(value);
90
115
  latest.current = value;
91
- const { available, create } = tagChoices(options, value, text, { contains, same: (a, b) => collator.compare(a.trim(), b) === 0 }, onCreate !== undefined && !disabled);
92
- const listed = create === null
93
- ? available
94
- : [
95
- ...available,
96
- { value: CREATE, label: fillCopy(copy.inlineEdit.createTag, { name: create }) },
97
- ];
116
+ const { available, create } = tagChoices(options, value, text, { contains, same: (a, b) => collator.compare(a.trim(), b) === 0 }, onCreate !== undefined && !disabled, onSearch !== undefined);
117
+ const listed = [
118
+ ...available,
119
+ ...(create === null
120
+ ? []
121
+ : [{ value: CREATE, label: fillCopy(copy.inlineEdit.createTag, { name: create }) }]),
122
+ ...(onBrowseAll === undefined || disabled
123
+ ? []
124
+ : [{ value: BROWSE, label: copy.picker.browseAll }]),
125
+ ];
98
126
  const commit = (key) => {
99
127
  if (key === null)
100
128
  return;
101
129
  const chosen = String(key);
130
+ const typed = text;
102
131
  setText("");
132
+ if (chosen === BROWSE) {
133
+ onBrowseAll?.(typed);
134
+ return;
135
+ }
103
136
  if (chosen !== CREATE) {
104
137
  onChange?.(withTag(latest.current, chosen));
105
138
  return;
@@ -120,6 +153,8 @@ function EditableTagsField({ value, onChange, options, onCreate, placeholder, di
120
153
  id: option.value,
121
154
  label: option.label,
122
155
  ...(option.tone === undefined ? {} : { tone: option.tone }),
156
+ ...(option.avatar === undefined ? {} : { avatar: option.avatar }),
157
+ ...opening(option.value),
123
158
  ...(disabled ? {} : { onRemove: () => remove(option.value) }),
124
159
  }));
125
160
  return (
@@ -149,7 +184,7 @@ function EditableTagsField({ value, onChange, options, onCreate, placeholder, di
149
184
  onChange?.(withoutTag(value, last));
150
185
  }, empty: text === "" }), _jsx(Popover, { triggerRef: boxRef, ...(width === null
151
186
  ? {}
152
- : { style: { "--trigger-width": `${width}px` } }), placement: "bottom start", className: "w-(--trigger-width)", children: _jsx(ListBox, { items: listed, className: "max-h-[inherit] overflow-auto p-1 outline-0", children: (item) => (_jsx(ListBoxItem, { id: item.value, textValue: item.label, className: (state) => cn(listOptionStyles(state), item.value === CREATE ? "text-primary" : ""), children: item.value === CREATE ? (_jsxs(_Fragment, { children: [_jsx(Plus, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "truncate font-medium", children: item.label })] })) : (_jsx("span", { className: "truncate", children: item.label })) })) }) })] })] }));
187
+ : { style: { "--trigger-width": `${width}px` } }), placement: "bottom start", className: "w-(--trigger-width)", children: _jsx(ListBox, { items: listed, dependencies: [text], className: "max-h-[inherit] overflow-auto p-1 outline-0", children: (item) => (_jsx(ListBoxItem, { id: item.value, textValue: item.label, className: (state) => cn(listOptionStyles(state), item.value === CREATE || item.value === BROWSE ? "text-primary" : ""), children: item.value === CREATE ? (_jsxs(_Fragment, { children: [_jsx(Plus, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "truncate font-medium", children: item.label })] })) : item.value === BROWSE ? (_jsxs(_Fragment, { children: [_jsx(Search, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "truncate font-medium", children: item.label })] })) : (_jsxs(_Fragment, { children: [item.avatar === undefined ? null : (_jsx(Avatar, { name: item.label, size: "sm", ...(typeof item.avatar === "string" ? { src: item.avatar } : {}) })), _jsx("span", { className: "truncate", children: _jsx(MatchedText, { text: item.label, query: text, locale: copy.locale }) })] })) })) }) })] })] }));
153
188
  }
154
189
  /** The input after the chips. It reads the combobox's state to know whether the keyboard
155
190
  * marked an option — Enter with nothing marked takes the first match. */
@@ -11,6 +11,8 @@ export interface TagOption {
11
11
  label: string;
12
12
  /** Neutral by default: «mayorista» classifies, it does not warn. */
13
13
  tone?: Tone;
14
+ /** A person: its face on the chip and in the list — `true` for initials, a URL for a photo. */
15
+ avatar?: true | string;
14
16
  }
15
17
  /** How the typed text is compared — the locale's, handed in by the component. */
16
18
  export type TagMatch = {
@@ -28,7 +30,10 @@ export type TagMatch = {
28
30
  * existing tag, chosen or not: creating «Mayorista» next to «mayorista» is how a catalog
29
31
  * ends up with both.
30
32
  */
31
- export declare function tagChoices(options: readonly TagOption[], value: readonly string[], text: string, match: TagMatch, creatable: boolean): {
33
+ export declare function tagChoices(options: readonly TagOption[], value: readonly string[], text: string, match: TagMatch, creatable: boolean,
34
+ /** The options are already the answer to `text` (searched by the consumer, on a server):
35
+ * they are not filtered again — the server may have matched a field no label shows. */
36
+ searched?: boolean): {
32
37
  available: TagOption[];
33
38
  create: string | null;
34
39
  };
@@ -7,10 +7,14 @@
7
7
  * existing tag, chosen or not: creating «Mayorista» next to «mayorista» is how a catalog
8
8
  * ends up with both.
9
9
  */
10
- export function tagChoices(options, value, text, match, creatable) {
10
+ export function tagChoices(options, value, text, match, creatable,
11
+ /** The options are already the answer to `text` (searched by the consumer, on a server):
12
+ * they are not filtered again — the server may have matched a field no label shows. */
13
+ searched = false) {
11
14
  const chosen = new Set(value);
12
15
  const query = text.trim();
13
- const available = options.filter((option) => !chosen.has(option.value) && (query === "" || match.contains(option.label, query)));
16
+ const available = options.filter((option) => !chosen.has(option.value) &&
17
+ (searched || query === "" || match.contains(option.label, query)));
14
18
  const create = creatable && query !== "" && !options.some((option) => match.same(option.label, query))
15
19
  ? query
16
20
  : null;
@@ -0,0 +1,16 @@
1
+ export declare const SIZE: {
2
+ readonly sm: "size-5 text-micro";
3
+ readonly md: "size-6 text-micro";
4
+ readonly lg: "size-8 text-meta";
5
+ readonly xl: "size-12 text-body";
6
+ };
7
+ export declare const OVERLAP: Record<AvatarSize, string>;
8
+ export type AvatarSize = keyof typeof SIZE;
9
+ export declare function Avatar({ name, size,
10
+ /** The photo, if there is one. Without it the initials go, which is the normal
11
+ * case here. */
12
+ src, }: {
13
+ name: string;
14
+ size?: AvatarSize;
15
+ src?: string;
16
+ }): import("react").JSX.Element;
@@ -0,0 +1,91 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useSurface } from "../../foundations/surface.js";
4
+ /* ==================================================================== *
5
+ * WHO — the person as a datum, not as a string of text.
6
+ *
7
+ * «andres.g@sincpro.com.bo» in a cell is a correct and unreadable datum: twelve
8
+ * rows of emails get read letter by letter. An initial in a circle is
9
+ * recognized without reading, which is what is needed when the question is «who
10
+ * does this belong to?» and not «what is their email?».
11
+ *
12
+ * ── The color comes out of the name and nobody picks it ──────────────
13
+ *
14
+ * A hash of the name against the tone palette. It is deterministic —the same
15
+ * person always has the same color, on every screen— and that is why the color
16
+ * gets to mean something. If the consumer picked it, the same person would be
17
+ * green in one list and blue in another, and recognition without reading is lost
18
+ * entirely.
19
+ *
20
+ * The color here is NOT a state: it is identity. That is why it uses the tone
21
+ * palette but never `bad` nor `warn` — a person is not «in error».
22
+ * ==================================================================== */
23
+ const PALETTE = [
24
+ "bg-info/15 text-info",
25
+ "bg-good/15 text-good",
26
+ "bg-accent/25 text-ink",
27
+ "bg-primary/12 text-primary",
28
+ "bg-ink/10 text-ink",
29
+ ];
30
+ /** The initials: two, and from the first two WORDS. «Ana María Gutiérrez» gives
31
+ * `AG` and not `AM` — the surname distinguishes more than the middle name. */
32
+ function initials(name) {
33
+ const parts = name
34
+ .trim()
35
+ .split(/\s+/)
36
+ .filter((p) => p.length > 0);
37
+ const first = parts[0]?.[0] ?? "?";
38
+ const last = parts.length > 1 ? parts[parts.length - 1]?.[0] : undefined;
39
+ return `${first}${last ?? ""}`.toUpperCase();
40
+ }
41
+ /* The same five identities on the dark chrome. The canvas palette there composed its tints
42
+ over the navy and left ink letters on a dark disc — 1.61:1, unreadable (measured on the
43
+ application bar). Same index, so a person keeps their color across the two surfaces. */
44
+ const CHROME_PALETTE = [
45
+ "bg-chrome-link/20 text-chrome-link",
46
+ "bg-chrome-good/20 text-chrome-good",
47
+ "bg-chrome-accent/12 text-chrome-accent",
48
+ "bg-chrome-link/14 text-chrome-ink",
49
+ "bg-chrome-ink/14 text-chrome-ink",
50
+ ];
51
+ function toneOf(name, surface = "canvas") {
52
+ let h = 0;
53
+ for (const ch of name)
54
+ h = (h * 31 + ch.codePointAt(0)) % 100000;
55
+ const palette = surface === "chrome" ? CHROME_PALETTE : PALETTE;
56
+ return palette[h % palette.length];
57
+ }
58
+ /* The text doesn't go below `micro`: two initials at 10 px can't be read, and an
59
+ unreadable initial is a colored circle — which is exactly what we don't want.
60
+ What changes between sizes is the circle. */
61
+ export const SIZE = {
62
+ sm: "size-5 text-micro",
63
+ md: "size-6 text-micro",
64
+ lg: "size-8 text-meta",
65
+ /* The head of a contact card: the face is what the card is about. */
66
+ xl: "size-12 text-body",
67
+ };
68
+ /* Two initials, so the overlap stays short of the second letter. The ring is
69
+ 1px: a 2px ring of the surface color reads as a gap between people, which
70
+ is the thing a stack is not. */
71
+ export const OVERLAP = {
72
+ sm: "-ml-1.5",
73
+ md: "-ml-2",
74
+ lg: "-ml-2.5",
75
+ xl: "-ml-3.5",
76
+ };
77
+ export function Avatar({ name, size = "md",
78
+ /** The photo, if there is one. Without it the initials go, which is the normal
79
+ * case here. */
80
+ src, }) {
81
+ const surface = useSurface();
82
+ if (src !== undefined) {
83
+ return (
84
+ /* The photo lies over the initials, drawn invisible: an image has no text baseline, so
85
+ a chip with a photo sat on the image's bottom edge in a row aligned by baseline (a
86
+ `DataField`), higher than the label next to it. The hidden initials give the photo
87
+ the same baseline the initials disc has. */
88
+ _jsxs("span", { title: name, className: `relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-pill ${SIZE[size]}`, children: [_jsx("span", { "aria-hidden": true, className: "invisible", children: initials(name) }), _jsx("img", { src: src, alt: name, className: "absolute inset-0 size-full object-cover" })] }));
89
+ }
90
+ return (_jsx("span", { title: name, "aria-hidden": true, className: `inline-flex shrink-0 items-center justify-center rounded-pill font-medium select-none ${SIZE[size]} ${toneOf(name, surface)}`, children: initials(name) }));
91
+ }
@@ -1,52 +1,8 @@
1
+ import { type AvatarSize } from "./Avatar.js";
2
+ import type { PersonContact } from "./contact.js";
1
3
  import type { ReactNode } from "react";
2
- declare const SIZE: {
3
- readonly sm: "size-5 text-micro";
4
- readonly md: "size-6 text-micro";
5
- readonly lg: "size-8 text-meta";
6
- readonly xl: "size-12 text-body";
7
- };
8
- export type AvatarSize = keyof typeof SIZE;
9
- export declare function Avatar({ name, size,
10
- /** The photo, if there is one. Without it the initials go, which is the normal
11
- * case here. */
12
- src, }: {
13
- name: string;
14
- size?: AvatarSize;
15
- src?: string;
16
- }): import("react").JSX.Element;
17
- /** The ways to reach a person. Each one that is present becomes a row of the contact card. */
18
- export type PersonContact = {
19
- email?: string;
20
- /** As people write it — «+591 7 123-4567». The `tel:` link keeps only the digits and `+`. */
21
- phone?: string;
22
- /** The WhatsApp number with its country code, written any way; `wa.me` gets the digits. */
23
- whatsapp?: string;
24
- };
25
- export type ContactCardProps = {
26
- name: string;
27
- /** The photo. Without it, the initials — in the same color the person has everywhere. */
28
- src?: string;
29
- /** What the person is here: «Vendedora · Sucursal La Paz». */
30
- role?: string;
31
- contact?: PersonContact;
32
- /** Go to the person's own record — «Ver ficha». Without it the card only reaches them. */
33
- onOpen?: () => void;
34
- };
35
- /**
36
- * Who a person is and how to reach them: the face, the name, the role, and one row per way
37
- * of contact — write, call, WhatsApp — then their record.
38
- *
39
- * Context: in an ERP the question after «who is the salesperson of this invoice?» is almost
40
- * always «how do I reach her?». Copying an email out of another screen to paste it into the
41
- * mail client is the detour this card removes. Each way of contact is a ROW and not a bare
42
- * icon: the address and the number are drawn, because often what the person wants is to read
43
- * the number aloud, not to dial it from the browser. Every row is a real link (`mailto:`,
44
- * `tel:`, `wa.me`), so middle-click, «copy address» and the phone's own dialer work.
45
- *
46
- * It is what opens from a `UserChip` with `contact`, and it is exported on its own for the
47
- * side panel of a record, where the same person is shown without a chip to open.
48
- */
49
- export declare function ContactCard({ name, src, role, contact, onOpen }: ContactCardProps): import("react").JSX.Element;
4
+ export { Avatar, type AvatarSize } from "./Avatar.js";
5
+ export type { PersonContact } from "./contact.js";
50
6
  export type UserChipProps = {
51
7
  name: string;
52
8
  size?: AvatarSize;
@@ -57,7 +13,7 @@ export type UserChipProps = {
57
13
  role?: string;
58
14
  /** The ways to reach them. Present = the chip opens a contact card. */
59
15
  contact?: PersonContact;
60
- /** Go to the person's record. Present = the chip opens a card with «Ver ficha». */
16
+ /** Go to the person's record. Present = the chip opens its card with «Abrir». */
61
17
  onOpen?: () => void;
62
18
  };
63
19
  /**
@@ -65,7 +21,7 @@ export type UserChipProps = {
65
21
  * a fact of a record.
66
22
  *
67
23
  * Context: a chip is a button ONLY when it has somewhere to go. With `contact` or `onOpen` it
68
- * opens the `ContactCard` (press, Enter or Space — React Aria's `DialogTrigger` through
24
+ * opens the person's `RelationCard` (press, Enter or Space — React Aria's `DialogTrigger` through
69
25
  * `PopoverTrigger`: focus goes into the card, Escape and a click outside close it, and focus
70
26
  * comes back to the chip). Without them it stays text: a button that does nothing is a lie a
71
27
  * keyboard user pays for with one more Tab stop on every row.
@@ -87,4 +43,3 @@ export declare function UserStack({ names, max, size, }: {
87
43
  max?: number;
88
44
  size?: AvatarSize;
89
45
  }): import("react").JSX.Element;
90
- export {};