@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,4 +1,4 @@
1
- import { Children, Fragment, isValidElement } from "react";
1
+ import { Children, cloneElement, Fragment, isValidElement } from "react";
2
2
  /**
3
3
  * Marca un componente como ranura y lo devuelve.
4
4
  *
@@ -26,11 +26,16 @@ export function slotOf(node) {
26
26
  * medio del árbol —lo que escribe cualquiera que ponga dos ranuras bajo un
27
27
  * condicional— escondería las ranuras que tiene adentro.
28
28
  */
29
- export function flatten(children) {
29
+ export function flatten(children, prefix = "") {
30
30
  const out = [];
31
31
  for (const child of Children.toArray(children)) {
32
32
  if (isValidElement(child) && child.type === Fragment) {
33
- out.push(...flatten(child.props.children));
33
+ /* The fragment's children are keyed from zero again («.0», «.1»…): without the
34
+ fragment's own key in front they collide with their new siblings. */
35
+ out.push(...flatten(child.props.children, `${prefix}${String(child.key)}/`));
36
+ }
37
+ else if (prefix !== "" && isValidElement(child)) {
38
+ out.push(cloneElement(child, { key: `${prefix}${String(child.key)}` }));
34
39
  }
35
40
  else {
36
41
  out.push(child);
@@ -21,3 +21,10 @@ export type Tone = "neutral" | "info" | "good" | "warn" | "bad" | "accent";
21
21
  * Three and not five: a fourth intermediate step gets picked out of habit and
22
22
  * stops meaning a place. */
23
23
  export type Size = "sm" | "md" | "lg";
24
+ /**
25
+ * WHERE a record opens, in the words a workspace (the composer) uses: the page (with its crumb
26
+ * back), a modal over the surface that asked, a peek (a drawer, reading without leaving), a
27
+ * side panel, a floating window; web-ui only SAYS which one the person chose — opening is the
28
+ * app's (`api.open(ref, { to })`). See the guide «Cómo abrimos las cosas».
29
+ */
30
+ export type OpenTarget = "modal" | "peek" | "page" | "side" | "window";
@@ -12,39 +12,3 @@
12
12
  * `list/`, even if it is shared across its three files.
13
13
  * ==================================================================== */
14
14
  export {};
15
- /* ==================================================================== *
16
- * THE SYSTEM'S TWO API CONVENTIONS
17
- *
18
- * They are here and not in the README because a convention that lives in the
19
- * documentation breaks in the first component written in a hurry. Here it is
20
- * read by whoever is going to write the next component.
21
- *
22
- * ── 1. How a callback is named ──────────────────────────────────────
23
- *
24
- * onXChange the component proposes a NEW value for `x`, and whoever
25
- * uses it decides whether to accept it. It ALWAYS goes with
26
- * the `x` prop.
27
- * → onTermsChange(terms), onSelectedChange(set), onSortChange(sort)
28
- *
29
- * onX something happened that is not a value change: an intent.
30
- * → onClose(), onOpen(), onPromote(), onBrowseAll()
31
- *
32
- * The test for knowing which one: **is there a prop with that name next to
33
- * it?** If `value` exists, it is `onValueChange`. If not, it is an event and
34
- * goes without a suffix.
35
- *
36
- * ── 2. Controlled, always ───────────────────────────────────────────
37
- *
38
- * Any state the consumer might want to read, store in the URL or restore on
39
- * reload is CONTROLLED: `value` + `onValueChange`, with no internal memory.
40
- * That is what lets the entire workspace live in the hash.
41
- *
42
- * The exception, and there is only one: **what dies with the component and
43
- * nobody cares about afterwards.** An open menu, an expanded filter panel, the
44
- * half-typed text in the search box. That does keep state inside, because
45
- * exposing it would force every consumer to declare a `useState` for something
46
- * they are never going to read.
47
- *
48
- * Put another way: if it makes sense to send it to a colleague over chat so
49
- * they see the same thing, it is controlled.
50
- * ==================================================================== */
@@ -1,4 +1,4 @@
1
- export { Block, type BlockProps, type BlockStatus, Rule, SectionHeader, } from "./Block.js";
1
+ export { Block, type BlockProps, type BlockStatus, Rule, } from "./Block.js";
2
2
  export { FullWidth, SectionGrid } from "./SectionGrid.js";
3
3
  export { Line } from "./Line.js";
4
4
  export { Step, type StepProps, Steps, type StepsProps, useSteps, } from "./Steps.js";
@@ -1,5 +1,5 @@
1
1
  /* The structure of a view: where each section starts and ends. */
2
- export { Block, Rule, SectionHeader, } from "./Block.js";
2
+ export { Block, Rule, } from "./Block.js";
3
3
  export { FullWidth, SectionGrid } from "./SectionGrid.js";
4
4
  /* La línea de tres zonas: lo que centra el centro contra la LÍNEA y no contra
5
5
  lo que sobra. La usan las dos bandas — la del registro y la de la lista. */
@@ -31,7 +31,7 @@ export declare function Thumb({ src, alt, icon: Icon, size, }: {
31
31
  * card. The second line wraps or truncates (`wrap`); the first always truncates, with the full
32
32
  * text on hover.
33
33
  */
34
- export declare function CellStack({ primary, secondary, media, wrap, }: {
34
+ export declare function CellStack({ primary, secondary, media, avatar, wrap, }: {
35
35
  /** What the row is — the name. In ink, one line. */
36
36
  primary: ReactNode;
37
37
  /** What tells it apart — a description, a code, a reference. Small and quiet. */
@@ -42,6 +42,12 @@ export declare function CellStack({ primary, secondary, media, wrap, }: {
42
42
  alt?: string;
43
43
  icon?: LucideIcon;
44
44
  };
45
+ /** A person or a company instead of `media`: its round face — the photo, or the initials in
46
+ * the color it has everywhere. The same contract as a list row's `avatar`. */
47
+ avatar?: {
48
+ name: string;
49
+ src?: string;
50
+ };
45
51
  /** Let the second line wrap (a description) instead of truncating (a code). */
46
52
  wrap?: boolean;
47
53
  }): import("react").JSX.Element;
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Avatar } from "../value/Avatar.js";
2
3
  /**
3
4
  * A thumbnail — the photo of a product, the logo of a customer — or, when there is none, its
4
5
  * icon on a quiet tile. One size per context so a column of them lines up.
@@ -28,6 +29,8 @@ export function Thumb({ src, alt, icon: Icon, size = "md", }) {
28
29
  * card. The second line wraps or truncates (`wrap`); the first always truncates, with the full
29
30
  * text on hover.
30
31
  */
31
- export function CellStack({ primary, secondary, media, wrap = false, }) {
32
- return (_jsxs("span", { className: "flex min-w-0 items-center gap-2.5", children: [media === undefined ? null : (_jsx(Thumb, { size: "md", ...(media.src === undefined ? {} : { src: media.src }), ...(media.alt === undefined ? {} : { alt: media.alt }), ...(media.icon === undefined ? {} : { icon: media.icon }) })), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate text-meta font-medium text-ink", ...(typeof primary === "string" ? { title: primary } : {}), children: primary }), secondary === undefined ? null : (_jsx("span", { className: `text-micro text-muted ${wrap ? "line-clamp-2" : "truncate"}`, ...(typeof secondary === "string" ? { title: secondary } : {}), children: secondary }))] })] }));
32
+ export function CellStack({ primary, secondary, media, avatar, wrap = false, }) {
33
+ return (_jsxs("span", { className: "flex min-w-0 items-center gap-2.5", children: [avatar !== undefined ? (
34
+ /* The name is written beside it: the photo's `alt` would say it twice. */
35
+ _jsx("span", { "aria-hidden": true, className: "flex shrink-0", children: _jsx(Avatar, { name: avatar.name, size: "lg", ...(avatar.src === undefined ? {} : { src: avatar.src }) }) })) : media === undefined ? null : (_jsx(Thumb, { size: "md", ...(media.src === undefined ? {} : { src: media.src }), ...(media.alt === undefined ? {} : { alt: media.alt }), ...(media.icon === undefined ? {} : { icon: media.icon }) })), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate text-meta font-medium text-ink", ...(typeof primary === "string" ? { title: primary } : {}), children: primary }), secondary === undefined ? null : (_jsx("span", { className: `text-micro text-muted ${wrap ? "line-clamp-2" : "truncate"}`, ...(typeof secondary === "string" ? { title: secondary } : {}), children: secondary }))] })] }));
33
36
  }
@@ -10,7 +10,7 @@ import { Popover } from "../overlay/Popover.js";
10
10
  import { Check, ChevronDown } from "lucide-react";
11
11
  import { ComboBox as AriaComboBox, Input, ListBox, ListBoxItem, Text, useFilter, } from "react-aria-components";
12
12
  /**
13
- * One of many, found by typing: the customer of an invoice, the product of a line.
13
+ * One of a closed list, found by typing: a country, a currency, a tax regime, a unit.
14
14
  *
15
15
  * Context: a `Select` makes you scroll a list you already know the name in; here you type
16
16
  * three letters of it. The filter is React Aria's `contains` in the locale of the copy table
@@ -18,9 +18,13 @@ import { ComboBox as AriaComboBox, Input, ListBox, ListBoxItem, Text, useFilter,
18
18
  * The value is the option's `value` string — what the backend stores — never React Aria's
19
19
  * `Key`.
20
20
  *
21
- * It is not `Picker`: that one chooses a RECORD among thousands, with metadata rows, a cap
22
- * and a "see more". This is a closed list already in memory — tens to a few hundred — where
23
- * one muted line under the name (a tax id, a city) is enough to tell two apart.
21
+ * The four ways to choose, and which is which:
22
+ * - `Select` a short closed list (a handful): open it and read it.
23
+ * - `ComboBox` a long closed list already in memory (tens to a few hundred): type three
24
+ * letters; one muted line under the name is enough to tell two apart.
25
+ * - `Picker` a RECORD — a customer, a product — among thousands: rows with face, facts
26
+ * and widgets, «Buscar más…», create, and opening the chosen one.
27
+ * - `Suggest` free text that proposes what already exists without forbidding a new one.
24
28
  *
25
29
  * When nothing matches, the list simply closes: an empty floating box tells nothing the
26
30
  * unchanged field does not, and the system has no "no results" sentence of its own here.
@@ -0,0 +1,14 @@
1
+ export type KanbanStateValue = "normal" | "done" | "blocked";
2
+ /**
3
+ * Odoo's kanban state — the traffic light on a card: in progress, ready for the next stage,
4
+ * blocked.
5
+ *
6
+ * Context: it answers «can this move on?» without opening the record, and the stage alone does
7
+ * not say it — two cards in «propuesta», one waiting on the customer, one ready to send. A dot
8
+ * never goes alone (docs/PRINCIPLES.md §3): its word is the button's name and title, and the
9
+ * list it opens says all three. Without `onChange` it only shows.
10
+ */
11
+ export declare function KanbanState({ value, onChange, }: {
12
+ value: KanbanStateValue;
13
+ onChange?: (next: KanbanStateValue) => void;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useUiCopy } from "../../foundations/copy.js";
4
+ import { PopoverTrigger } from "../overlay/PopoverTrigger.js";
5
+ const ORDER = ["normal", "done", "blocked"];
6
+ const FILL = {
7
+ normal: "bg-line-strong",
8
+ done: "bg-good",
9
+ blocked: "bg-bad",
10
+ };
11
+ /** The dot as `PopoverTrigger`'s opener: it hands over `expanded` / `haspopup`, which a bare
12
+ * `<button>` would pass to the DOM as unknown attributes. */
13
+ function DotButton({ expanded, haspopup, ...rest }) {
14
+ return _jsx("button", { type: "button", "aria-expanded": expanded, "aria-haspopup": haspopup, ...rest });
15
+ }
16
+ /**
17
+ * Odoo's kanban state — the traffic light on a card: in progress, ready for the next stage,
18
+ * blocked.
19
+ *
20
+ * Context: it answers «can this move on?» without opening the record, and the stage alone does
21
+ * not say it — two cards in «propuesta», one waiting on the customer, one ready to send. A dot
22
+ * never goes alone (docs/PRINCIPLES.md §3): its word is the button's name and title, and the
23
+ * list it opens says all three. Without `onChange` it only shows.
24
+ */
25
+ export function KanbanState({ value, onChange, }) {
26
+ const copy = useUiCopy();
27
+ const words = copy.kanbanState;
28
+ const said = `${words.label}: ${words[value]}`;
29
+ const dot = (state) => (_jsx("span", { "aria-hidden": true, className: `block size-2.5 rounded-pill ${FILL[state]}` }));
30
+ if (onChange === undefined) {
31
+ return (_jsx("span", { role: "img", "aria-label": said, title: said, className: "inline-flex p-1", children: dot(value) }));
32
+ }
33
+ return (_jsx(PopoverTrigger, { label: words.label, placement: "bottom end", trigger: _jsx(DotButton, { "aria-label": said, title: said, className: "relative z-10 inline-flex rounded-pill p-1 transition-colors duration-(--duration-instant) hover:bg-ink/8 focus-visible:outline-2 focus-visible:outline-primary", children: dot(value) }), children: (close) => (_jsx("div", { role: "radiogroup", "aria-label": words.label, className: "flex flex-col p-1", children: ORDER.map((state) => (_jsxs("button", { type: "button", role: "radio", "aria-checked": state === value, onClick: () => {
34
+ onChange(state);
35
+ close();
36
+ }, className: `flex items-center gap-2 rounded-control px-2 py-1.5 text-left text-meta text-ink transition-colors duration-(--duration-instant) hover:bg-ink/5 ${state === value ? "font-medium" : ""}`, children: [dot(state), words[state]] }, state))) })) }));
37
+ }
@@ -0,0 +1,20 @@
1
+ export type PriorityProps = {
2
+ /** 0 is «sin prioridad». */
3
+ value: number;
4
+ /** How many stars. Odoo's three, by default. */
5
+ max?: number;
6
+ /** Without it the stars only show. Pressing the star already set clears it, the way Odoo
7
+ * does — the only way back to «none» that needs no extra control. */
8
+ onChange?: (next: number) => void;
9
+ /** The group's name, when it is not «Prioridad». */
10
+ label?: string;
11
+ };
12
+ /**
13
+ * Priority as stars: how urgent a lead, a task or a ticket is, read in a list at a glance.
14
+ *
15
+ * Context: a select «baja / media / alta» is read as a word in a column of words; stars are
16
+ * counted without reading. They are a radio group: arrows move, and it is named — «Prioridad:
17
+ * 2 de 3» — so it is not colour alone. In the `warn` tone, not the gold: the gold is the
18
+ * screen's primary action.
19
+ */
20
+ export declare function Priority({ value, max, onChange, label }: PriorityProps): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
4
+ import { Star } from "lucide-react";
5
+ import { useRef } from "react";
6
+ /**
7
+ * Priority as stars: how urgent a lead, a task or a ticket is, read in a list at a glance.
8
+ *
9
+ * Context: a select «baja / media / alta» is read as a word in a column of words; stars are
10
+ * counted without reading. They are a radio group: arrows move, and it is named — «Prioridad:
11
+ * 2 de 3» — so it is not colour alone. In the `warn` tone, not the gold: the gold is the
12
+ * screen's primary action.
13
+ */
14
+ export function Priority({ value, max = 3, onChange, label }) {
15
+ const copy = useUiCopy();
16
+ const name = label ?? copy.priority.label;
17
+ const stars = Array.from({ length: max }, (_, at) => at + 1);
18
+ const buttons = useRef([]);
19
+ const said = value === 0 ? copy.priority.none : fillCopy(copy.priority.level, { value, max });
20
+ const icon = (n) => (_jsx(Star, { "aria-hidden": true, className: `size-(--size-icon-md) ${n <= value ? "fill-warn text-warn" : "text-line-strong"}`, strokeWidth: 1.75 }));
21
+ if (onChange === undefined) {
22
+ return (_jsx("span", { role: "img", "aria-label": `${name}: ${said}`, className: "inline-flex items-center gap-0.5", children: stars.map((n) => (_jsx("span", { children: icon(n) }, n))) }));
23
+ }
24
+ const step = (event, n) => {
25
+ const next = event.key === "ArrowRight" || event.key === "ArrowUp"
26
+ ? Math.min(max, n + 1)
27
+ : event.key === "ArrowLeft" || event.key === "ArrowDown"
28
+ ? Math.max(1, n - 1)
29
+ : null;
30
+ if (next === null)
31
+ return;
32
+ event.preventDefault();
33
+ onChange(next);
34
+ buttons.current[next - 1]?.focus();
35
+ };
36
+ return (_jsx("span", { role: "radiogroup", "aria-label": name, className: "inline-flex items-center gap-0.5", children: stars.map((n) => (_jsx("button", { ref: (node) => {
37
+ buttons.current[n - 1] = node;
38
+ }, type: "button", role: "radio", "aria-checked": value === n, "aria-label": fillCopy(copy.priority.level, { value: n, max }),
39
+ /* One stop in the tab order — the set star, or the first — and the arrows inside. */
40
+ tabIndex: n === Math.max(value, 1) ? 0 : -1, onClick: () => onChange(n === value ? 0 : n), onKeyDown: (event) => step(event, n), className: "relative z-10 rounded-control p-0.5 transition-colors duration-(--duration-instant) hover:bg-ink/6 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: icon(n) }, n))) }));
41
+ }
@@ -12,5 +12,10 @@ export type SliderProps = {
12
12
  describedBy?: string;
13
13
  /** Forces the mode. By default it inherits the form's: in read mode it draws the figure. */
14
14
  mode?: FormMode;
15
+ /** The name when it is NOT inside a `Field` — a toolbar's zoom. Inside a field, the
16
+ * field's label names it. */
17
+ "aria-label"?: string;
18
+ /** The figure beside the track. Off for a control whose number means nothing — a zoom. */
19
+ output?: boolean;
15
20
  };
16
- export declare function Slider({ value, onChange, min, max, step, format, disabled, id: ownId, describedBy: ownDescribedBy, mode, }: SliderProps): import("react").JSX.Element;
21
+ export declare function Slider({ value, onChange, min, max, step, format, disabled, id: ownId, describedBy: ownDescribedBy, mode, "aria-label": ariaLabel, output, }: SliderProps): import("react").JSX.Element;
@@ -33,7 +33,7 @@ const thumb = tv({
33
33
  },
34
34
  },
35
35
  });
36
- export function Slider({ value, onChange, min, max, step = 1, format, disabled = false, id: ownId, describedBy: ownDescribedBy, mode, }) {
36
+ export function Slider({ value, onChange, min, max, step = 1, format, disabled = false, id: ownId, describedBy: ownDescribedBy, mode, "aria-label": ariaLabel, output = true, }) {
37
37
  const copy = useUiCopy();
38
38
  const { id, labelledBy, describedBy } = useFieldIds({
39
39
  id: ownId,
@@ -42,5 +42,5 @@ export function Slider({ value, onChange, min, max, step = 1, format, disabled =
42
42
  if (useFormMode(mode) === "read") {
43
43
  return _jsx(ReadValue, { value: new Intl.NumberFormat(copy.locale, format).format(value) });
44
44
  }
45
- return (_jsx(AriaLocale, { children: _jsxs(AriaSlider, { id: id, "aria-labelledby": labelledBy, "aria-describedby": describedBy, value: value, onChange: (next) => onChange?.(next), minValue: min, maxValue: max, step: step, formatOptions: format, isDisabled: disabled, className: "flex h-(--size-control-md) w-full min-w-0 items-center gap-3 disabled:opacity-45", children: [_jsxs(SliderTrack, { className: "relative flex h-(--size-control-md) min-w-0 flex-1 cursor-pointer items-center disabled:cursor-not-allowed", children: [_jsx("span", { className: "relative h-1 w-full rounded-pill bg-line-strong forced-colors:bg-[ButtonBorder]", children: _jsx(SliderFill, { className: "rounded-pill bg-primary disabled:bg-muted forced-colors:bg-[Highlight]" }) }), _jsx(SliderThumb, { "aria-labelledby": labelledBy, "aria-describedby": describedBy, className: thumb })] }), _jsx(SliderOutput, { className: "w-14 shrink-0 text-right text-meta text-ink tabular-nums" })] }) }));
45
+ return (_jsx(AriaLocale, { children: _jsxs(AriaSlider, { id: id, "aria-labelledby": labelledBy, "aria-label": ariaLabel, "aria-describedby": describedBy, value: value, onChange: (next) => onChange?.(next), minValue: min, maxValue: max, step: step, formatOptions: format, isDisabled: disabled, className: "flex h-(--size-control-md) w-full min-w-0 items-center gap-3 disabled:opacity-45", children: [_jsxs(SliderTrack, { className: "relative flex h-(--size-control-md) min-w-0 flex-1 cursor-pointer items-center disabled:cursor-not-allowed", children: [_jsx("span", { className: "relative h-1 w-full rounded-pill bg-line-strong forced-colors:bg-[ButtonBorder]", children: _jsx(SliderFill, { className: "rounded-pill bg-primary disabled:bg-muted forced-colors:bg-[Highlight]" }) }), _jsx(SliderThumb, { "aria-labelledby": labelledBy, "aria-label": ariaLabel, "aria-describedby": describedBy, className: thumb })] }), output ? (_jsx(SliderOutput, { className: "w-14 shrink-0 text-right text-meta text-ink tabular-nums" })) : null] }) }));
46
46
  }
@@ -1,5 +1,7 @@
1
1
  export { ComboBox, type ComboBoxProps } from "./ComboBox.js";
2
+ export { KanbanState, type KanbanStateValue, } from "./KanbanState.js";
2
3
  export { type ChoiceOption, type SegmentOption, } from "./options.js";
4
+ export { Priority, type PriorityProps } from "./Priority.js";
3
5
  export { RadioGroup, type RadioGroupProps, } from "./RadioGroup.js";
4
6
  export { SearchField, type SearchFieldProps, } from "./SearchField.js";
5
7
  export { SegmentedControl, type SegmentedControlProps, } from "./SegmentedControl.js";
@@ -2,7 +2,9 @@
2
2
  (RadioGroup, SegmentedControl), prender o apagar (Switch), un valor en un rango (Slider)
3
3
  y buscar un texto suelto (SearchField). El valor es siempre un dato plano. */
4
4
  export { ComboBox } from "./ComboBox.js";
5
+ export { KanbanState, } from "./KanbanState.js";
5
6
  export {} from "./options.js";
7
+ export { Priority } from "./Priority.js";
6
8
  export { RadioGroup, } from "./RadioGroup.js";
7
9
  export { SearchField, } from "./SearchField.js";
8
10
  export { SegmentedControl, } from "./SegmentedControl.js";
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import { type PointerEvent, type ReactNode } from "react";
2
2
  /** Where this dialog came from. It is drawn in the header, small. */
3
3
  export interface DialogContext {
4
4
  /** The category, in small caps: «factura», «cliente». It answers *what kind of
@@ -52,13 +52,19 @@ export interface DialogProps {
52
52
  * It is the tooltip and the accessible name, and it is not drawn. */
53
53
  expandLabel?: string;
54
54
  footer?: ReactNode;
55
+ /** The window can be dragged by its header, to see what is behind it — the form a «search
56
+ * more» was opened from. Off on a phone, where the window is a bottom sheet. */
57
+ movable?: boolean;
55
58
  children: ReactNode;
56
59
  }
57
60
  /** The inside of the window: React Aria's `Dialog` with the header, body and footer.
58
61
  *
59
62
  * Internal — exported only so a test can read its markup, which the modal (a portal)
60
63
  * does not produce on the server. */
61
- export declare function DialogSurface({ title, subtitle, context, onClose, onExpand, expandLabel, footer, children, }: Omit<DialogProps, "open" | "width">): import("react").JSX.Element;
64
+ export declare function DialogSurface({ title, subtitle, context, onClose, onExpand, expandLabel, footer, children, onDragStart, }: Omit<DialogProps, "open" | "width" | "movable"> & {
65
+ /** Set by a movable `Dialog`: the header is the handle. */
66
+ onDragStart?: (event: PointerEvent<HTMLElement>) => void;
67
+ }): import("react").JSX.Element;
62
68
  /**
63
69
  * What has to be answered before moving on: a short form that blocks the page and keeps
64
70
  * saying what it is about.
@@ -68,7 +74,7 @@ export declare function DialogSurface({ title, subtitle, context, onClose, onExp
68
74
  * stays mounted for the exit transition after `open` turns `false`, so the window fades
69
75
  * out instead of vanishing.
70
76
  */
71
- export declare function Dialog({ open, width, onClose, ...surface }: DialogProps): import("react").JSX.Element;
77
+ export declare function Dialog({ open, width, onClose, movable, ...surface }: DialogProps): import("react").JSX.Element;
72
78
  export interface WizardStep {
73
79
  id: string;
74
80
  label: string;
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useUiCopy } from "../../foundations/copy.js";
4
+ import { HANDHELD_WIDTH } from "../../foundations/layout.js";
4
5
  import { cn, tv } from "../../foundations/variants.js";
5
6
  import { Button, IconButton } from "../button/Button.js";
6
7
  import { Maximize2, X } from "lucide-react";
7
- import { useId } from "react";
8
+ import { useEffect, useId, useState } from "react";
8
9
  import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
9
10
  /** The scrim. The page stays visible under it — the dialog is ABOUT something on it.
10
11
  * On a phone it drops the padding and pushes the window to the bottom edge. */
@@ -39,11 +40,11 @@ export const dialogStyles = { overlay, modal };
39
40
  *
40
41
  * Internal — exported only so a test can read its markup, which the modal (a portal)
41
42
  * does not produce on the server. */
42
- export function DialogSurface({ title, subtitle, context, onClose, onExpand, expandLabel, footer, children, }) {
43
+ export function DialogSurface({ title, subtitle, context, onClose, onExpand, expandLabel, footer, children, onDragStart, }) {
43
44
  const copy = useUiCopy();
44
45
  const subtitleId = useId();
45
46
  const expand = expandLabel ?? copy.dialog.expand;
46
- return (_jsxs(AriaDialog, { "aria-describedby": subtitle === undefined ? undefined : subtitleId, className: "flex max-h-[inherit] min-h-0 flex-1 flex-col outline-0", children: [_jsxs("header", { className: "flex shrink-0 items-start gap-3 border-b border-line px-4 py-3", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [context === undefined ? null : (
47
+ return (_jsxs(AriaDialog, { "aria-describedby": subtitle === undefined ? undefined : subtitleId, className: "flex max-h-[inherit] min-h-0 flex-1 flex-col outline-0", children: [_jsxs("header", { onPointerDown: onDragStart, className: cn("flex shrink-0 items-start gap-3 border-b border-line px-4 py-3", onDragStart !== undefined && "cursor-grab select-none active:cursor-grabbing"), children: [_jsxs("div", { className: "min-w-0 flex-1", children: [context === undefined ? null : (
47
48
  /* The context, on top and small. It is what keeps the first field
48
49
  from being «about what?». Same voice as a field label, not a
49
50
  console kicker: mono uppercase next to a form title was a
@@ -59,11 +60,46 @@ export function DialogSurface({ title, subtitle, context, onClose, onExpand, exp
59
60
  * stays mounted for the exit transition after `open` turns `false`, so the window fades
60
61
  * out instead of vanishing.
61
62
  */
62
- export function Dialog({ open, width = "md", onClose, ...surface }) {
63
+ export function Dialog({ open, width = "md", onClose, movable = false, ...surface }) {
64
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
65
+ /* Every opening starts centred: where it was dragged last time is not where it belongs. */
66
+ useEffect(() => {
67
+ if (open)
68
+ setOffset({ x: 0, y: 0 });
69
+ }, [open]);
70
+ /* `transform` and not `translate`: the window enters and leaves on `translate`, which is
71
+ transitioned — a drag on it would trail the pointer by the panel's duration. */
72
+ const drag = (event) => {
73
+ if (event.button !== 0 || event.pointerType === "touch")
74
+ return;
75
+ if (event.target.closest("button, a, input") !== null)
76
+ return;
77
+ /* Below it the window is a bottom sheet, and a sheet is not dragged around. */
78
+ if (window.innerWidth < HANDHELD_WIDTH)
79
+ return;
80
+ const from = { x: event.clientX - offset.x, y: event.clientY - offset.y };
81
+ /* Never further than half the screen from the centre: the header — the only handle — has
82
+ to stay where a pointer can reach it. */
83
+ const halfW = window.innerWidth / 2;
84
+ const halfH = window.innerHeight / 2;
85
+ const clamp = (value, half) => Math.max(-half, Math.min(half, value));
86
+ const move = (next) => setOffset({
87
+ x: clamp(next.clientX - from.x, halfW),
88
+ y: clamp(next.clientY - from.y, halfH),
89
+ });
90
+ const stop = () => {
91
+ window.removeEventListener("pointermove", move);
92
+ window.removeEventListener("pointerup", stop);
93
+ };
94
+ window.addEventListener("pointermove", move);
95
+ window.addEventListener("pointerup", stop);
96
+ };
63
97
  return (_jsx(ModalOverlay, { isOpen: open, onOpenChange: (next) => {
64
98
  if (!next)
65
99
  onClose();
66
- }, isDismissable: true, className: overlay, children: _jsx(Modal, { className: (state) => modal({ ...state, width }), children: _jsx(DialogSurface, { onClose: onClose, ...surface }) }) }));
100
+ }, isDismissable: true, className: overlay, children: _jsx(Modal, { className: (state) => modal({ ...state, width }), ...(movable
101
+ ? { style: { transform: `translate(${offset.x}px, ${offset.y}px)` } }
102
+ : {}), children: _jsx(DialogSurface, { onClose: onClose, ...surface, ...(movable ? { onDragStart: drag } : {}) }) }) }));
67
103
  }
68
104
  /** A dialog with steps, for what CANNOT be answered in one go.
69
105
  *
@@ -1,2 +1 @@
1
1
  export { Dialog, type DialogContext, type DialogProps, type DialogWidth, Wizard, type WizardProps, type WizardStep, } from "./Dialog.js";
2
- export { Drill, type DrillContextProps, type DrillOrigin, type DrillProps, type DrillState, useDrill, } from "./Drill.js";
@@ -1,4 +1,2 @@
1
1
  /* What has to be answered before moving on. */
2
2
  export { Dialog, Wizard, } from "./Dialog.js";
3
- /* One dialog that holds a stack of contexts opened from a record, and from each other. */
4
- export { Drill, useDrill, } from "./Drill.js";
@@ -1,4 +1,4 @@
1
1
  export { Callout, type CalloutProps } from "./Callout.js";
2
2
  export { Flag, type FlagProps, type FlagTone } from "./Flag.js";
3
3
  export { Empty, Failed, ICONO_ROTO, Missing, Skeleton, } from "./Notice.js";
4
- export { ToastLayer, ToastProvider, type ToastSpec, useToast, useToastIfAny, } from "./Toast.js";
4
+ export { ToastProvider, type ToastSpec, useToast, useToastIfAny, } from "./Toast.js";
@@ -3,4 +3,4 @@
3
3
  export { Callout } from "./Callout.js";
4
4
  export { Flag } from "./Flag.js";
5
5
  export { Empty, Failed, ICONO_ROTO, Missing, Skeleton, } from "./Notice.js";
6
- export { ToastLayer, ToastProvider, useToast, useToastIfAny, } from "./Toast.js";
6
+ export { ToastProvider, useToast, useToastIfAny, } from "./Toast.js";
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Popover } from "./Popover.js";
4
4
  import { isValidElement } from "react";
5
+ import { setInteractionModality } from "react-aria";
5
6
  import { Focusable, Link, PreviewTrigger } from "react-aria-components";
6
7
  /**
7
8
  * A glance at a record without leaving the screen: the customer of an invoice, the product
@@ -53,8 +54,16 @@ const PREVIEW_DELAY = 600;
53
54
  const PREVIEW_CLOSE_DELAY = 200;
54
55
  export function Preview({ trigger, label, children, placement = "bottom start", delay = PREVIEW_DELAY, closeDelay = PREVIEW_CLOSE_DELAY, disabled = false, }) {
55
56
  const readsContext = isValidElement(trigger) && trigger.type === Link;
56
- return (_jsxs(PreviewTrigger, { delay: delay, closeDelay: closeDelay, isDisabled: disabled, children: [readsContext ? (trigger) : (
57
- /* React Aria types the child as an intrinsic element; a component that forwards
58
- `ref` and spreads native props is the same thing to it at runtime. */
59
- _jsx(Focusable, { children: trigger })), _jsx(Popover, { placement: placement, "aria-label": label, children: _jsx("div", { className: "max-h-[inherit] max-w-sm overflow-auto p-3", children: children }) })] }));
57
+ return (
58
+ /* React Aria opens a preview on hover only once it KNOWS the pointer is in use — and it
59
+ learns that from a press, not from a move. On a page just loaded, before any click, the
60
+ pointer rested on a name and nothing opened. Entering the trigger with a mouse says it,
61
+ in the capture phase so it is said before React Aria's own hover handler asks. */
62
+ _jsx("span", { className: "contents", onPointerOverCapture: (event) => {
63
+ if (event.pointerType === "mouse")
64
+ setInteractionModality("pointer");
65
+ }, children: _jsxs(PreviewTrigger, { delay: delay, closeDelay: closeDelay, isDisabled: disabled, children: [readsContext ? (trigger) : (
66
+ /* React Aria types the child as an intrinsic element; a component that forwards
67
+ `ref` and spreads native props is the same thing to it at runtime. */
68
+ _jsx(Focusable, { children: trigger })), _jsx(Popover, { placement: placement, "aria-label": label, children: _jsx("div", { className: "max-h-[inherit] max-w-sm overflow-auto p-3", children: children }) })] }) }));
60
69
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A text with the part that matched the search marked — internal to the lists that filter
3
+ * as you type (`Picker`, `TagsField`). A `<mark>`, so it is also said as marked text, and
4
+ * bold besides the tint: in forced colors the tint is gone and the weight is what remains.
5
+ */
6
+ export declare function MatchedText({ text, query, locale, }: {
7
+ text: string;
8
+ query: string;
9
+ locale?: string;
10
+ }): string | (string | import("react").JSX.Element)[];
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { matchSpans } from "../../foundations/match.js";
3
+ /**
4
+ * A text with the part that matched the search marked — internal to the lists that filter
5
+ * as you type (`Picker`, `TagsField`). A `<mark>`, so it is also said as marked text, and
6
+ * bold besides the tint: in forced colors the tint is gone and the weight is what remains.
7
+ */
8
+ export function MatchedText({ text, query, locale, }) {
9
+ if (query.trim() === "")
10
+ return text;
11
+ return matchSpans(text, query, locale).map((span, index) => span.hit ? (_jsx("mark", { className: "rounded-mark bg-accent/25 font-semibold text-current", children: span.text }, index)) : (span.text));
12
+ }
@@ -1,3 +1,4 @@
1
+ import type { OpenTarget } from "../../foundations/types.js";
1
2
  import { type FormMode } from "../field/mode.js";
2
3
  import type { LucideIcon } from "lucide-react";
3
4
  import { type ReactNode } from "react";
@@ -21,6 +22,13 @@ export interface PickerItem {
21
22
  /** The header this item is listed under. Consecutive items with the same section share
22
23
  * one header; an item without it is listed loose, outside any section. */
23
24
  section?: string;
25
+ /** A person or a company: its face before the name — the photo, or its initials when
26
+ * `true`. Who is being chosen is recognised before it is read, in the list and in the field. */
27
+ avatar?: true | string;
28
+ /** What is read at a glance on the right, in tabular figures: the balance owed, the stock,
29
+ * a state badge, a priority — a widget, not text. Where `meta` describes, this COMPARES:
30
+ * lined up in one column, eight customers' debts are read top to bottom. */
31
+ aside?: ReactNode;
24
32
  }
25
33
  /**
26
34
  * What the list shows and how many are left out of it. Capped at 8 with the footer count,
@@ -40,12 +48,24 @@ export type PickerProps = {
40
48
  value?: string;
41
49
  onChange?: (id: string) => void;
42
50
  placeholder?: string;
43
- /** What to do with the ones that don't fit. Without this, the footer only
44
- * informs. */
45
- onBrowseAll?: () => void;
51
+ /** «Buscar más…»: the last option of the list, always there when this is — the record
52
+ * may not be among the eight, or two of them may need their columns to tell apart. It
53
+ * gets what was typed, so the list that chooses (`List onChoose`, in a modal of the
54
+ * workspace) opens on the same search. */
55
+ onBrowseAll?: (query: string) => void;
56
+ /** «Crear «…»»: a record made from the typed text alone (Odoo's `name_create`); it returns
57
+ * the new id — or a promise of it — and the field chooses it. Offered while something is
58
+ * typed that no option is called exactly. The consumer appends the new item to `items`. */
59
+ onCreate?: (name: string) => string | Promise<string>;
60
+ /** «Crear y editar…»: the new record needs more than a name — open its form (in a workspace,
61
+ * a modal) with the typed text as the name. Choosing it there is the consumer's. */
62
+ onCreateAndEdit?: (name: string) => void;
46
63
  /** Open the chosen record. Drawn as a trailing affordance, the way a many2one
47
64
  * offers "open" next to "pick". Absent when there is nothing chosen. */
48
- onOpen?: (id: string) => void;
65
+ onOpen?: (id: string, to: OpenTarget) => void;
66
+ /** Where the chosen record can open, the first being the button's (a modal, by default: the
67
+ * form stays behind); the rest go in «Abrir en…». `onOpen` is told which was picked. */
68
+ openTargets?: readonly OpenTarget[];
49
69
  emptyText?: string;
50
70
  /** Placeholder of the field while it is being typed into. It is NOT a second
51
71
  * box — the field itself is the search. */
@@ -56,7 +76,7 @@ export type PickerProps = {
56
76
  loadingText?: string;
57
77
  /** The list grows from the backend: called when its end scrolls into view. The consumer
58
78
  * appends the next page to `items`; with nothing more to fetch it does nothing. Turns off
59
- * the cap of 8, the «see all…» row and the footer count. */
79
+ * the cap of 8 and the footer count. */
60
80
  onLoadMore?: () => void;
61
81
  /** A page asked for by `onLoadMore` is on its way: a quiet row at the end says so. Unlike
62
82
  * `loading`, the rows already there stay. */
@@ -72,15 +92,17 @@ export type PickerProps = {
72
92
  };
73
93
  export declare function Picker({ mode, ...props }: PickerProps): import("react").JSX.Element;
74
94
  /**
75
- * The list of a `Picker`: its rows, the section headers, the «see all…» row and the loading
95
+ * The list of a `Picker`: its rows, the section headers, the «Buscar más…» row and the loading
76
96
  * row at the end. Internal — not exported from the family — and apart from the combobox so it
77
97
  * can be rendered on the server without a popover, which is how the tests see the rows.
78
98
  */
79
- export declare function PickerOptions({ items, marks, hints, remaining, emptyText, onLoadMore, loadingMore, "aria-label": ariaLabel, }: {
99
+ export declare function PickerOptions({ items, marks, hints, remaining, query, emptyText, onLoadMore, loadingMore, "aria-label": ariaLabel, }: {
80
100
  items: readonly PickerItem[];
81
101
  marks: boolean;
82
102
  hints: boolean;
83
103
  remaining: number;
104
+ /** What was typed: it is marked in every row, so each row says why it is there. */
105
+ query?: string;
84
106
  emptyText: string;
85
107
  onLoadMore?: () => void;
86
108
  loadingMore?: boolean;