@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
@@ -97,6 +97,7 @@ export function readTyped(text) {
97
97
  export function globToLike(value) {
98
98
  return value.replace(/\*/g, "%");
99
99
  }
100
+ const NUMERIC = new Set(["integer", "number", "float", "decimal", "monetary", "money"]);
100
101
  /** Qué clase de valor es un campo, leída del tipo que publica el modelo. */
101
102
  function kindOf(definition) {
102
103
  if (definition.kind === "relation")
@@ -104,7 +105,9 @@ function kindOf(definition) {
104
105
  if (definition.choices !== undefined && definition.choices.length > 0)
105
106
  return "choice";
106
107
  const type = definition.type.toLowerCase();
107
- if (type === "integer" || type === "number" || type === "float")
108
+ /* An amount is a number: «decimal» and «monetary» read as text made «total <= 1000»
109
+ impossible to ask of an invoice. */
110
+ if (NUMERIC.has(type))
108
111
  return "number";
109
112
  if (type === "date" || type === "datetime")
110
113
  return "date";
@@ -265,7 +268,24 @@ function matches(field, text) {
265
268
  * ya. Las dos intenciones se escriben igual, así que se ofrecen las dos, con los
266
269
  * campos primero: es el paso que todavía no decidió nada.
267
270
  */
268
- export function suggest(draft, fields) {
271
+ /**
272
+ * Whether the MODEL reads what was typed as a question of its own: an operator written in
273
+ * front («> 100»), the name of a field («estado»), a value of an enum («pagada»), or a number or
274
+ * a date that a field with order can answer. Plain text («Globex») is none of those — every
275
+ * text field «contains» it, which says nothing — so the literal search goes first for it.
276
+ */
277
+ function readByModel(typed, byName, fields) {
278
+ if (typed.op !== undefined || byName.length > 0)
279
+ return true;
280
+ const value = typed.value.toLowerCase();
281
+ if (fields.some((field) => field.choices?.some((one) => one.toLowerCase() === value)))
282
+ return true;
283
+ return ((typed.shape === "number" || typed.shape === "date") &&
284
+ fields.some((field) => ORDERED.has(field.kind) && answers(field, typed)));
285
+ }
286
+ export function suggest(draft, fields,
287
+ /** The set has a literal search (`SearchBar literal`): offer it for free text. */
288
+ options = {}) {
269
289
  const typed = readTyped(draft.text);
270
290
  /* Paso 3 — el campo y el operador ya están: falta el valor. */
271
291
  if (draft.field !== undefined && draft.op !== undefined) {
@@ -409,10 +429,23 @@ export function suggest(draft, fields) {
409
429
  hecha — salvo que lo tipeado sea también un valor razonable, que es
410
430
  justamente el caso de «test». */
411
431
  const named = new Set(byName.map((one) => (one.kind === "field" ? one.field.field : "")));
412
- return [
432
+ const byModel = [
413
433
  ...byName,
414
434
  ...ready.filter((one) => one.kind !== "condition" || !named.has(one.field.field)),
415
435
  ];
436
+ if (options.literal !== true)
437
+ return byModel;
438
+ /* The literal search is always offered for text: what the model reads goes first, and
439
+ when it reads nothing, the literal search is what Enter takes. */
440
+ const text = draft.text.trim();
441
+ const literal = {
442
+ kind: "literal",
443
+ id: "l:literal",
444
+ label: currentCopy().search.literal,
445
+ hint: text,
446
+ text,
447
+ };
448
+ return readByModel(typed, byName, fields) ? [...byModel, literal] : [literal, ...byModel];
416
449
  }
417
450
  /**
418
451
  * CÓMO SE LEE UNA CONDICIÓN, en un solo lugar.
@@ -0,0 +1,60 @@
1
+ import { type TimelineScale } from "./time.js";
2
+ import type { Tone } from "../../foundations/types.js";
3
+ import { type ReactNode } from "react";
4
+ /** A row of the timeline: what the records are grouped by — a person, a room, a machine. */
5
+ export type TimelineRow = {
6
+ id: string;
7
+ label: string;
8
+ /** The row's face: `true` draws the initials of `label`, a string is a photo's URL. */
9
+ avatar?: boolean | string;
10
+ /** A second line, quiet: the role, the department. */
11
+ detail?: string;
12
+ };
13
+ export type RecordTimelineProps<T> = {
14
+ items: readonly T[];
15
+ keyOf: (item: T) => string;
16
+ /** The row a record goes in: its employee, its room. A grouping, as Odoo's «group by». */
17
+ rowOf: (item: T) => string;
18
+ /** The rows, in order — including the ones with nothing in this period (someone who did not
19
+ * come is part of the answer). Without it they come from the records. */
20
+ rows?: readonly TimelineRow[];
21
+ startOf: (item: T) => Date;
22
+ endOf: (item: T) => Date;
23
+ /** The record's name, on its bar. */
24
+ titleOf?: (item: T) => string;
25
+ /** What the bar says, replacing the default: the name, and on a day or a week its duration
26
+ * and its hours — «03:48 (09:12–13:13)». */
27
+ labelOf?: (item: T) => ReactNode;
28
+ toneOf?: (item: T) => Tone;
29
+ /** The head of the names column: «Empleado». */
30
+ rowHeader?: string;
31
+ /** Controlled scale, or the one it starts on. */
32
+ scale?: TimelineScale;
33
+ defaultScale?: TimelineScale;
34
+ onScaleChange?: (next: TimelineScale) => void;
35
+ scales?: readonly TimelineScale[];
36
+ /** Controlled date, or the one it starts on (today). */
37
+ date?: Date;
38
+ defaultDate?: Date;
39
+ onDateChange?: (next: Date) => void;
40
+ /** A bar was dragged (in time, or to another row) or stretched. The app saves and passes the
41
+ * new `items`; until then the bar goes back. */
42
+ onMove?: (item: T, start: Date, end: Date, row: string) => void;
43
+ /** An empty stretch of a row was pressed: create a record there, one column long. */
44
+ onCreate?: (row: string, start: Date, end: Date) => void;
45
+ onOpen?: (item: T) => void;
46
+ };
47
+ /**
48
+ * Records on a timeline, grouped in rows — Odoo's Gantt view: attendances by employee, shifts,
49
+ * room bookings, work orders by machine.
50
+ *
51
+ * Context: a project Gantt (`RecordGantt`) is one task per row with dependencies; THIS is the
52
+ * other one people mean by «Gantt» in an ERP — a grouping with N records per row, read across
53
+ * time. The scale says the period (a day by hours, a week, a month by days, a year by months);
54
+ * the toolbar moves it (‹ › date · today), zooms, switches scale and takes the whole screen.
55
+ *
56
+ * Controlled: a drag shows a draft and reports `onMove` on release; the bar stays where
57
+ * `items` says. Everything the pointer does has a key: Alt + ← / → moves by the snap, with
58
+ * Shift it stretches, Alt + ↑ / ↓ changes the row; every move is announced.
59
+ */
60
+ export declare function RecordTimeline<T>({ items, keyOf, rowOf, rows: givenRows, startOf, endOf, titleOf, labelOf, toneOf, rowHeader, scale: outerScale, defaultScale, onScaleChange, scales, date: outerDate, defaultDate, onDateChange, onMove, onCreate, onOpen, }: RecordTimelineProps<T>): import("react").JSX.Element;
@@ -0,0 +1,214 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { hoursMinutes, lanesOf, rangeOf, shiftDate, snap, snapOf, TIMELINE_SCALES, unitSpan, } from "./time.js";
4
+ import { TimelineToolbar } from "./TimelineToolbar.js";
5
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
6
+ import { Avatar } from "../../primitives/value/Person.js";
7
+ import { useEffect, useRef, useState, } from "react";
8
+ const FILL = {
9
+ neutral: "bg-muted text-surface",
10
+ info: "bg-info text-surface",
11
+ good: "bg-good text-surface",
12
+ warn: "bg-warn text-surface",
13
+ bad: "bg-bad text-surface",
14
+ accent: "bg-primary text-surface",
15
+ };
16
+ /* Pixels a column asks for at the middle zoom; the zoom multiplies them. Past the screen's width
17
+ the track scrolls sideways — the names column stays. */
18
+ const BASE = { day: 44, week: 150, month: 44, year: 96 };
19
+ const ZOOM = [0.5, 0.75, 1, 1.5, 2.25];
20
+ const BAR = 26;
21
+ const GAP = 4;
22
+ /**
23
+ * Records on a timeline, grouped in rows — Odoo's Gantt view: attendances by employee, shifts,
24
+ * room bookings, work orders by machine.
25
+ *
26
+ * Context: a project Gantt (`RecordGantt`) is one task per row with dependencies; THIS is the
27
+ * other one people mean by «Gantt» in an ERP — a grouping with N records per row, read across
28
+ * time. The scale says the period (a day by hours, a week, a month by days, a year by months);
29
+ * the toolbar moves it (‹ › date · today), zooms, switches scale and takes the whole screen.
30
+ *
31
+ * Controlled: a drag shows a draft and reports `onMove` on release; the bar stays where
32
+ * `items` says. Everything the pointer does has a key: Alt + ← / → moves by the snap, with
33
+ * Shift it stretches, Alt + ↑ / ↓ changes the row; every move is announced.
34
+ */
35
+ export function RecordTimeline({ items, keyOf, rowOf, rows: givenRows, startOf, endOf, titleOf, labelOf, toneOf, rowHeader, scale: outerScale, defaultScale = "week", onScaleChange, scales = TIMELINE_SCALES, date: outerDate, defaultDate, onDateChange, onMove, onCreate, onOpen, }) {
36
+ const copy = useUiCopy();
37
+ const words = copy.timeline;
38
+ const [ownScale, setOwnScale] = useState(defaultScale);
39
+ const [ownDate, setOwnDate] = useState(() => defaultDate ?? new Date());
40
+ const scale = outerScale ?? ownScale;
41
+ const date = outerDate ?? ownDate;
42
+ const setScale = (next) => {
43
+ if (outerScale === undefined)
44
+ setOwnScale(next);
45
+ onScaleChange?.(next);
46
+ };
47
+ const setDate = (next) => {
48
+ if (outerDate === undefined)
49
+ setOwnDate(next);
50
+ onDateChange?.(next);
51
+ };
52
+ const [zoom, setZoom] = useState(3);
53
+ const [draft, setDraft] = useState(null);
54
+ const draftRef = useRef(null);
55
+ const justDragged = useRef(false);
56
+ const [said, setSaid] = useState("");
57
+ const root = useRef(null);
58
+ const [fullscreen, setFullscreen] = useState(false);
59
+ useEffect(() => {
60
+ const follow = () => setFullscreen(document.fullscreenElement === root.current);
61
+ document.addEventListener("fullscreenchange", follow);
62
+ return () => document.removeEventListener("fullscreenchange", follow);
63
+ }, []);
64
+ const range = rangeOf(scale, date, copy.locale);
65
+ const from = +range.start;
66
+ const span = +range.end - from;
67
+ const step = snapOf(scale);
68
+ const cell = BASE[scale] * (ZOOM[zoom - 1] ?? 1);
69
+ const time = new Intl.DateTimeFormat(copy.locale, {
70
+ hour: "2-digit",
71
+ minute: "2-digit",
72
+ /* 09:12–13:13 and not «09:12 a. m.–01:13 p. m.»: a bar has room for the hours. */
73
+ hourCycle: "h23",
74
+ });
75
+ const day = new Intl.DateTimeFormat(copy.locale, { day: "numeric", month: "short" });
76
+ const rows = givenRows ?? [...new Set(items.map(rowOf))].map((id) => ({ id, label: id }));
77
+ const shown = items.filter((item) => +endOf(item) > from && +startOf(item) < from + span);
78
+ /* Where each record is drawn: its own dates, or the draft while it is being dragged. */
79
+ const placed = (item) => {
80
+ const id = keyOf(item);
81
+ if (draft !== null && draft.id === id) {
82
+ return { start: new Date(draft.start), end: new Date(draft.end), row: draft.row };
83
+ }
84
+ return { start: startOf(item), end: endOf(item), row: rowOf(item) };
85
+ };
86
+ const describe = (start, end) => scale === "day" || scale === "week"
87
+ ? `${hoursMinutes(+end - +start)} (${time.format(start)}–${time.format(end)})`
88
+ : `${day.format(start)} – ${day.format(end)}`;
89
+ const report = (item, start, end, row) => {
90
+ onMove?.(item, start, end, row);
91
+ setSaid(fillCopy(words.moved, {
92
+ name: titleOf?.(item) ?? keyOf(item),
93
+ range: describe(start, end),
94
+ }));
95
+ };
96
+ const drag = (event, item, mode) => {
97
+ if (onMove === undefined || event.button !== 0)
98
+ return;
99
+ event.stopPropagation();
100
+ const track = event.currentTarget.closest("[data-track]");
101
+ if (track === null)
102
+ return;
103
+ const perPx = span / track.getBoundingClientRect().width;
104
+ const x0 = event.clientX;
105
+ const start0 = +startOf(item);
106
+ const end0 = +endOf(item);
107
+ const id = keyOf(item);
108
+ const row0 = rowOf(item);
109
+ const move = (next) => {
110
+ const delta = snap((next.clientX - x0) * perPx, step);
111
+ const under = document
112
+ .elementFromPoint(next.clientX, next.clientY)
113
+ ?.closest("[data-row]")?.dataset["row"];
114
+ const draftNow = mode === "move"
115
+ ? { id, start: start0 + delta, end: end0 + delta, row: under ?? row0 }
116
+ : { id, start: start0, end: Math.max(start0 + step, end0 + delta), row: row0 };
117
+ draftRef.current = draftNow;
118
+ setDraft(draftNow);
119
+ };
120
+ const stop = () => {
121
+ window.removeEventListener("pointermove", move);
122
+ window.removeEventListener("pointerup", stop);
123
+ const done = draftRef.current;
124
+ draftRef.current = null;
125
+ setDraft(null);
126
+ if (done === null)
127
+ return;
128
+ const changed = done.start !== start0 || done.end !== end0 || done.row !== row0;
129
+ if (!changed)
130
+ return;
131
+ justDragged.current = true;
132
+ report(item, new Date(done.start), new Date(done.end), done.row);
133
+ };
134
+ window.addEventListener("pointermove", move);
135
+ window.addEventListener("pointerup", stop);
136
+ };
137
+ const byKeyboard = (event, item) => {
138
+ if (onMove === undefined || !event.altKey)
139
+ return;
140
+ const start = +startOf(item);
141
+ const end = +endOf(item);
142
+ const row = rowOf(item);
143
+ const at = rows.findIndex((one) => one.id === row);
144
+ const dir = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0;
145
+ if (dir !== 0) {
146
+ event.preventDefault();
147
+ if (event.shiftKey) {
148
+ report(item, new Date(start), new Date(Math.max(start + step, end + dir * step)), row);
149
+ }
150
+ else {
151
+ report(item, new Date(start + dir * step), new Date(end + dir * step), row);
152
+ }
153
+ return;
154
+ }
155
+ const vertical = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
156
+ const target = rows[at + vertical];
157
+ if (vertical !== 0 && target !== undefined) {
158
+ event.preventDefault();
159
+ report(item, new Date(start), new Date(end), target.id);
160
+ }
161
+ };
162
+ const create = (event, row) => {
163
+ if (onCreate === undefined)
164
+ return;
165
+ const rect = event.currentTarget.getBoundingClientRect();
166
+ const at = new Date(from + ((event.clientX - rect.left) / rect.width) * span);
167
+ const { start, end } = unitSpan(scale, at);
168
+ onCreate(row.id, start, end);
169
+ };
170
+ const now = Date.now();
171
+ const nowAt = now > from && now < from + span ? ((now - from) / span) * 100 : null;
172
+ const percent = (time) => ((Math.min(Math.max(time, from), from + span) - from) / span) * 100;
173
+ return (_jsxs("div", { ref: root, className: "flex min-w-0 flex-col overflow-hidden rounded-box border border-line bg-surface", children: [_jsx(TimelineToolbar, { title: range.title, date: date, onDateChange: setDate, onStep: (steps) => setDate(shiftDate(scale, date, steps)), scale: scale, onScaleChange: setScale, scales: scales, zoom: zoom, onZoomChange: setZoom, fullscreen: fullscreen, onFullscreenChange: (next) => {
174
+ if (next)
175
+ void root.current?.requestFullscreen?.();
176
+ else if (document.fullscreenElement !== null)
177
+ void document.exitFullscreen();
178
+ } }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: _jsxs("div", { style: {
179
+ minWidth: `calc(var(--container-timeline-label) + ${range.units.length * cell}px)`,
180
+ }, children: [_jsxs("div", { className: "sticky top-0 z-20 flex border-b border-line-strong bg-surface", children: [_jsx("div", { className: "sticky left-0 z-10 flex w-(--container-timeline-label) shrink-0 items-end border-r border-line bg-surface px-3 py-2 text-meta font-semibold text-ink", children: rowHeader }), _jsx("div", { className: "flex min-w-0 flex-1", children: range.units.map((unit) => (_jsx("div", { className: `flex min-w-0 flex-1 items-center justify-center border-l border-line py-2 text-micro font-semibold whitespace-nowrap text-ink tabular-nums first:border-l-0 ${unit.weekend ? "bg-ink/4" : ""}`, children: unit.label }, +unit.start))) })] }), rows.map((row) => {
181
+ const inRow = shown.filter((item) => placed(item).row === row.id);
182
+ const { lane, count } = lanesOf(inRow, (item) => placed(item).start, (item) => placed(item).end);
183
+ const height = count * (BAR + GAP) + GAP * 2;
184
+ return (_jsxs("div", { "data-row": row.id, className: "flex border-b border-line", children: [_jsxs("div", { className: "sticky left-0 z-10 flex w-(--container-timeline-label) shrink-0 items-center gap-2 border-r border-line bg-surface px-3", children: [row.avatar === undefined || row.avatar === false ? null : (_jsx(Avatar, { name: row.label, size: "sm", ...(typeof row.avatar === "string" ? { src: row.avatar } : {}) })), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-meta text-ink", children: row.label }), row.detail === undefined ? null : (_jsx("p", { className: "truncate text-micro text-muted", children: row.detail }))] })] }), _jsxs("div", { "data-track": "", onClick: (event) => create(event, row), className: `relative flex min-w-0 flex-1 ${onCreate === undefined ? "" : "cursor-cell"}`, style: { height }, children: [range.units.map((unit) => (_jsx("div", { "aria-hidden": true, className: `min-w-0 flex-1 border-l border-line first:border-l-0 ${unit.weekend ? "bg-ink/4" : ""}` }, +unit.start))), nowAt === null ? null : (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-y-0 w-0.5 bg-bad", style: { left: `${nowAt}%` } })), inRow.map((item) => {
185
+ const { start, end } = placed(item);
186
+ const left = percent(+start);
187
+ const width = Math.max(percent(+end) - left, 0.6);
188
+ const tone = toneOf?.(item) ?? "neutral";
189
+ const title = titleOf?.(item);
190
+ const text = labelOf?.(item) ??
191
+ [title, describe(start, end)]
192
+ .filter((part) => part !== undefined)
193
+ .join(" · ");
194
+ const dragging = draft !== null && draft.id === keyOf(item);
195
+ return (_jsxs("button", { type: "button", title: onMove === undefined ? undefined : words.moveHint, "aria-label": typeof text === "string" ? text : title, ...(onMove === undefined
196
+ ? {}
197
+ : {
198
+ "aria-keyshortcuts": "Alt+ArrowLeft Alt+ArrowRight Alt+ArrowUp Alt+ArrowDown",
199
+ }), onPointerDown: (event) => drag(event, item, "move"), onKeyDown: (event) => byKeyboard(event, item), onClick: (event) => {
200
+ event.stopPropagation();
201
+ if (justDragged.current) {
202
+ justDragged.current = false;
203
+ return;
204
+ }
205
+ onOpen?.(item);
206
+ }, className: `absolute z-1 flex items-center overflow-hidden rounded-control px-2 text-left text-micro font-medium whitespace-nowrap shadow-raised focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${FILL[tone]} ${onMove === undefined ? "" : "cursor-grab active:cursor-grabbing"} ${dragging ? "opacity-80 ring-2 ring-primary" : ""}`, style: {
207
+ left: `${left}%`,
208
+ width: `${width}%`,
209
+ top: GAP + (lane.get(item) ?? 0) * (BAR + GAP),
210
+ height: BAR,
211
+ }, children: [_jsx("span", { className: "min-w-0 truncate", children: text }), onMove === undefined ? null : (_jsx("span", { "aria-hidden": true, onPointerDown: (event) => drag(event, item, "resize"), className: "absolute inset-y-0 right-0 w-2 cursor-ew-resize" }))] }, keyOf(item)));
212
+ })] })] }, row.id));
213
+ })] }) }), _jsx("span", { role: "status", className: "sr-only", children: said })] }));
214
+ }
@@ -0,0 +1,30 @@
1
+ import { type TimelineScale } from "./time.js";
2
+ import type { ReactNode } from "react";
3
+ export type TimelineToolbarProps = {
4
+ /** The period being looked at, and what it says: «29 de octubre de 2026». */
5
+ title?: ReactNode;
6
+ /** Where it stands. Without `onDateChange` there is no date navigation (a project Gantt shows
7
+ * its whole span). */
8
+ date?: Date;
9
+ onDateChange?: (next: Date) => void;
10
+ /** The arrows: one period back or forward. */
11
+ onStep?: (steps: -1 | 1) => void;
12
+ scale?: TimelineScale;
13
+ onScaleChange?: (next: TimelineScale) => void;
14
+ /** The scales offered, in order. All four by default. */
15
+ scales?: readonly TimelineScale[];
16
+ /** 1 (widest view) to 5 (closest). */
17
+ zoom?: number;
18
+ onZoomChange?: (next: number) => void;
19
+ fullscreen?: boolean;
20
+ onFullscreenChange?: (next: boolean) => void;
21
+ };
22
+ /**
23
+ * The bar over a timeline — a Gantt, a planning, a schedule: where am I (‹ › a date · today),
24
+ * how close (zoom), at what scale (day · week · month · year) and the whole screen.
25
+ *
26
+ * Context: Odoo's Gantt toolbar, as one piece shared by `RecordTimeline` and `RecordGantt`, so
27
+ * the two read the same. Every part is optional: what has no handler is not drawn — a
28
+ * control that does nothing is a dead end.
29
+ */
30
+ export declare function TimelineToolbar({ title, date, onDateChange, onStep, scale, onScaleChange, scales, zoom, onZoomChange, fullscreen, onFullscreenChange, }: TimelineToolbarProps): import("react").JSX.Element;
@@ -0,0 +1,31 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { TIMELINE_SCALES, } from "./time.js";
4
+ import { useUiCopy } from "../../foundations/copy.js";
5
+ import { Button, IconButton } from "../../primitives/button/Button.js";
6
+ import { SegmentedControl } from "../../primitives/choice/SegmentedControl.js";
7
+ import { Slider } from "../../primitives/choice/Slider.js";
8
+ import { DatePicker } from "../../primitives/date/DatePicker.js";
9
+ import { ChevronLeft, ChevronRight, Maximize2, Minimize2, ZoomIn, ZoomOut, } from "lucide-react";
10
+ /** `2026-10-29` ↔ a local Date, the way `DatePicker` speaks. */
11
+ const isoDay = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
12
+ const fromIso = (iso) => {
13
+ const [y, m, d] = iso.split("-").map(Number);
14
+ return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
15
+ };
16
+ /**
17
+ * The bar over a timeline — a Gantt, a planning, a schedule: where am I (‹ › a date · today),
18
+ * how close (zoom), at what scale (day · week · month · year) and the whole screen.
19
+ *
20
+ * Context: Odoo's Gantt toolbar, as one piece shared by `RecordTimeline` and `RecordGantt`, so
21
+ * the two read the same. Every part is optional: what has no handler is not drawn — a
22
+ * control that does nothing is a dead end.
23
+ */
24
+ export function TimelineToolbar({ title, date, onDateChange, onStep, scale, onScaleChange, scales = TIMELINE_SCALES, zoom, onZoomChange, fullscreen = false, onFullscreenChange, }) {
25
+ const copy = useUiCopy();
26
+ const words = copy.timeline;
27
+ return (_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-line px-3 py-2", children: [onStep === undefined && onDateChange === undefined ? null : (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [onStep === undefined ? null : (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: ChevronLeft, label: words.prev, onClick: () => onStep(-1) }), _jsx(IconButton, { icon: ChevronRight, label: words.next, onClick: () => onStep(1) })] })), onDateChange === undefined || date === undefined ? null : (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-40", children: _jsx(DatePicker, { "aria-label": words.goTo, value: isoDay(date), onChange: (next) => {
28
+ if (next !== null)
29
+ onDateChange(fromIso(next));
30
+ } }) }), _jsx(Button, { role: "secondary", size: "sm", onClick: () => onDateChange(new Date()), children: words.today })] }))] })), title === undefined ? null : (_jsx("p", { className: "min-w-0 flex-1 truncate text-body font-semibold text-ink first-letter:uppercase", children: title })), _jsxs("div", { className: "ml-auto flex shrink-0 flex-wrap items-center gap-3", children: [zoom === undefined || onZoomChange === undefined ? null : (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(IconButton, { icon: ZoomOut, label: words.zoomOut, disabled: zoom <= 1, onClick: () => onZoomChange(Math.max(1, zoom - 1)) }), _jsx("div", { className: "w-24", children: _jsx(Slider, { "aria-label": words.zoom, value: zoom, min: 1, max: 5, output: false, onChange: onZoomChange }) }), _jsx(IconButton, { icon: ZoomIn, label: words.zoomIn, disabled: zoom >= 5, onClick: () => onZoomChange(Math.min(5, zoom + 1)) })] })), scale === undefined || onScaleChange === undefined ? null : (_jsx(SegmentedControl, { "aria-label": words.scale, size: "sm", value: scale, onChange: (next) => onScaleChange(next), options: scales.map((one) => ({ value: one, label: words.scales[one] })) })), onFullscreenChange === undefined ? null : (_jsx(IconButton, { icon: fullscreen ? Minimize2 : Maximize2, label: fullscreen ? words.exitFullscreen : words.fullscreen, onClick: () => onFullscreenChange(!fullscreen) }))] })] }));
31
+ }
@@ -0,0 +1,3 @@
1
+ export { RecordTimeline, type RecordTimelineProps, type TimelineRow, } from "./RecordTimeline.js";
2
+ export { TIMELINE_SCALES, type TimelineScale, } from "./time.js";
3
+ export { TimelineToolbar, type TimelineToolbarProps, } from "./TimelineToolbar.js";
@@ -0,0 +1,3 @@
1
+ export { RecordTimeline, } from "./RecordTimeline.js";
2
+ export { TIMELINE_SCALES, } from "./time.js";
3
+ export { TimelineToolbar, } from "./TimelineToolbar.js";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Context: the arithmetic of a timeline, apart from any screen — what period a scale shows
3
+ * around a date, its columns, how far a drag snaps, and how bars that overlap in one row are
4
+ * stacked. Pure, so it is tested without a browser (`tests/timeline.test.ts`).
5
+ */
6
+ export type TimelineScale = "day" | "week" | "month" | "year";
7
+ export declare const TIMELINE_SCALES: readonly TimelineScale[];
8
+ /** One column of the scale: where it starts and what it says. */
9
+ export type TimeUnit = {
10
+ start: Date;
11
+ label: string;
12
+ weekend: boolean;
13
+ };
14
+ export type TimeRange = {
15
+ start: Date;
16
+ end: Date;
17
+ title: string;
18
+ units: TimeUnit[];
19
+ };
20
+ /** The period a scale shows around `date`, with its columns, written in `locale`. */
21
+ export declare function rangeOf(scale: TimelineScale, date: Date, locale: string): TimeRange;
22
+ /** The date one period before or after: the toolbar's arrows. */
23
+ export declare function shiftDate(scale: TimelineScale, date: Date, steps: number): Date;
24
+ /** How far a drag or an arrow key moves a bar: a quarter of an hour on a day, an hour on a
25
+ * week, a day on a month or a year. */
26
+ export declare function snapOf(scale: TimelineScale): number;
27
+ /** What a click on an empty column creates: that column. */
28
+ export declare function unitSpan(scale: TimelineScale, at: Date): {
29
+ start: Date;
30
+ end: Date;
31
+ };
32
+ export declare function snap(time: number, step: number): number;
33
+ /**
34
+ * The lane of each bar in a row: bars that overlap go one under the other, the way Odoo stacks
35
+ * two attendances of one morning. Greedy, in start order: a bar takes the first lane free when
36
+ * it starts.
37
+ */
38
+ export declare function lanesOf<T>(items: readonly T[], startOf: (item: T) => Date, endOf: (item: T) => Date): {
39
+ lane: Map<T, number>;
40
+ count: number;
41
+ };
42
+ /** «03:48»: a duration as hours and minutes. */
43
+ export declare function hoursMinutes(ms: number): string;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Context: the arithmetic of a timeline, apart from any screen — what period a scale shows
3
+ * around a date, its columns, how far a drag snaps, and how bars that overlap in one row are
4
+ * stacked. Pure, so it is tested without a browser (`tests/timeline.test.ts`).
5
+ */
6
+ export const TIMELINE_SCALES = ["day", "week", "month", "year"];
7
+ const MINUTE = 60_000;
8
+ const HOUR = 60 * MINUTE;
9
+ const DAY = 24 * HOUR;
10
+ const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
11
+ /** Monday: the week of a workplace in Bolivia, not the US Sunday. */
12
+ const startOfWeek = (date) => {
13
+ const day = startOfDay(date);
14
+ return new Date(day.getFullYear(), day.getMonth(), day.getDate() - ((day.getDay() + 6) % 7));
15
+ };
16
+ const isWeekend = (date) => date.getDay() === 0 || date.getDay() === 6;
17
+ /** The period a scale shows around `date`, with its columns, written in `locale`. */
18
+ export function rangeOf(scale, date, locale) {
19
+ const say = (options) => new Intl.DateTimeFormat(locale, options);
20
+ if (scale === "day") {
21
+ const start = startOfDay(date);
22
+ const units = Array.from({ length: 24 }, (_, hour) => ({
23
+ start: new Date(start.getFullYear(), start.getMonth(), start.getDate(), hour),
24
+ label: String(hour),
25
+ weekend: false,
26
+ }));
27
+ return {
28
+ start,
29
+ end: new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1),
30
+ title: say({ weekday: "long", day: "numeric", month: "long", year: "numeric" }).format(start),
31
+ units,
32
+ };
33
+ }
34
+ if (scale === "week") {
35
+ const start = startOfWeek(date);
36
+ const units = Array.from({ length: 7 }, (_, day) => {
37
+ const at = new Date(start.getFullYear(), start.getMonth(), start.getDate() + day);
38
+ return {
39
+ start: at,
40
+ label: say({ weekday: "short", day: "numeric" }).format(at),
41
+ weekend: isWeekend(at),
42
+ };
43
+ });
44
+ const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 7);
45
+ return {
46
+ start,
47
+ end,
48
+ title: say({ day: "numeric", month: "long", year: "numeric" }).formatRange(start, new Date(end.getTime() - DAY)),
49
+ units,
50
+ };
51
+ }
52
+ if (scale === "month") {
53
+ const start = new Date(date.getFullYear(), date.getMonth(), 1);
54
+ const end = new Date(date.getFullYear(), date.getMonth() + 1, 1);
55
+ const days = Math.round((end.getTime() - start.getTime()) / DAY);
56
+ const units = Array.from({ length: days }, (_, day) => {
57
+ const at = new Date(start.getFullYear(), start.getMonth(), day + 1);
58
+ return { start: at, label: String(day + 1), weekend: isWeekend(at) };
59
+ });
60
+ return {
61
+ start,
62
+ end,
63
+ title: say({ month: "long", year: "numeric" }).format(start),
64
+ units,
65
+ };
66
+ }
67
+ const start = new Date(date.getFullYear(), 0, 1);
68
+ const units = Array.from({ length: 12 }, (_, month) => {
69
+ const at = new Date(date.getFullYear(), month, 1);
70
+ return { start: at, label: say({ month: "short" }).format(at), weekend: false };
71
+ });
72
+ return {
73
+ start,
74
+ end: new Date(date.getFullYear() + 1, 0, 1),
75
+ title: String(date.getFullYear()),
76
+ units,
77
+ };
78
+ }
79
+ /** The date one period before or after: the toolbar's arrows. */
80
+ export function shiftDate(scale, date, steps) {
81
+ const y = date.getFullYear();
82
+ const m = date.getMonth();
83
+ const d = date.getDate();
84
+ if (scale === "day")
85
+ return new Date(y, m, d + steps);
86
+ if (scale === "week")
87
+ return new Date(y, m, d + 7 * steps);
88
+ if (scale === "month")
89
+ return new Date(y, m + steps, 1);
90
+ return new Date(y + steps, 0, 1);
91
+ }
92
+ /** How far a drag or an arrow key moves a bar: a quarter of an hour on a day, an hour on a
93
+ * week, a day on a month or a year. */
94
+ export function snapOf(scale) {
95
+ return { day: 15 * MINUTE, week: HOUR, month: DAY, year: DAY }[scale];
96
+ }
97
+ /** What a click on an empty column creates: that column. */
98
+ export function unitSpan(scale, at) {
99
+ if (scale === "day") {
100
+ const start = new Date(at.getFullYear(), at.getMonth(), at.getDate(), at.getHours());
101
+ return { start, end: new Date(start.getTime() + HOUR) };
102
+ }
103
+ if (scale === "year") {
104
+ const start = new Date(at.getFullYear(), at.getMonth(), 1);
105
+ return { start, end: new Date(at.getFullYear(), at.getMonth() + 1, 1) };
106
+ }
107
+ const start = startOfDay(at);
108
+ return { start, end: new Date(start.getTime() + DAY) };
109
+ }
110
+ export function snap(time, step) {
111
+ return Math.round(time / step) * step;
112
+ }
113
+ /**
114
+ * The lane of each bar in a row: bars that overlap go one under the other, the way Odoo stacks
115
+ * two attendances of one morning. Greedy, in start order: a bar takes the first lane free when
116
+ * it starts.
117
+ */
118
+ export function lanesOf(items, startOf, endOf) {
119
+ const ends = [];
120
+ const lane = new Map();
121
+ for (const item of [...items].sort((a, b) => +startOf(a) - +startOf(b))) {
122
+ const start = +startOf(item);
123
+ let at = ends.findIndex((end) => end <= start);
124
+ if (at === -1)
125
+ at = ends.length;
126
+ ends[at] = +endOf(item);
127
+ lane.set(item, at);
128
+ }
129
+ return { lane, count: Math.max(1, ends.length) };
130
+ }
131
+ /** «03:48»: a duration as hours and minutes. */
132
+ export function hoursMinutes(ms) {
133
+ const minutes = Math.round(ms / MINUTE);
134
+ return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
135
+ }