@sincpro/web-ui 0.1.0 → 0.2.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 (119) hide show
  1. package/README.md +37 -10
  2. package/dist/components/activity/Activity.d.ts +69 -0
  3. package/dist/components/activity/Activity.js +279 -0
  4. package/dist/components/{chatter → activity}/Messages.d.ts +2 -2
  5. package/dist/components/{chatter → activity}/Messages.js +9 -6
  6. package/dist/components/{chatter → activity}/index.d.ts +1 -1
  7. package/dist/components/activity/index.js +4 -0
  8. package/dist/components/assist/AssistMark.d.ts +3 -3
  9. package/dist/components/assist/AssistMark.js +1 -1
  10. package/dist/components/assist/assist.d.ts +1 -1
  11. package/dist/components/assist/assist.js +1 -1
  12. package/dist/components/form/Sheet.d.ts +4 -0
  13. package/dist/components/form/Sheet.js +11 -4
  14. package/dist/components/index.d.ts +1 -1
  15. package/dist/components/index.js +1 -1
  16. package/dist/components/lines/LineItems.d.ts +42 -5
  17. package/dist/components/lines/LineItems.js +106 -18
  18. package/dist/components/listing/Listing.js +1 -1
  19. package/dist/components/listing/ListingBar.d.ts +41 -2
  20. package/dist/components/listing/ListingBar.js +43 -4
  21. package/dist/components/listing/Row.d.ts +5 -1
  22. package/dist/components/listing/Row.js +3 -2
  23. package/dist/components/record/RecordHeader.d.ts +21 -17
  24. package/dist/components/record/RecordHeader.js +46 -14
  25. package/dist/components/record/RecordNotices.d.ts +37 -0
  26. package/dist/components/record/RecordNotices.js +75 -0
  27. package/dist/components/record/ThumbBar.js +1 -1
  28. package/dist/foundations/copy.d.ts +96 -12
  29. package/dist/foundations/copy.es.js +63 -10
  30. package/dist/foundations/copy.js +55 -7
  31. package/dist/primitives/choice/ComboBox.d.ts +3 -0
  32. package/dist/primitives/choice/ComboBox.js +2 -2
  33. package/dist/primitives/choice/SearchField.d.ts +10 -1
  34. package/dist/primitives/choice/SearchField.js +21 -2
  35. package/dist/primitives/date/Calendar.js +30 -6
  36. package/dist/primitives/date/DatePicker.d.ts +11 -3
  37. package/dist/primitives/date/DatePicker.js +12 -8
  38. package/dist/primitives/date/DateTime.d.ts +35 -0
  39. package/dist/primitives/date/DateTime.js +100 -0
  40. package/dist/primitives/date/index.d.ts +1 -0
  41. package/dist/primitives/date/index.js +4 -2
  42. package/dist/primitives/dialog/Drill.d.ts +90 -0
  43. package/dist/primitives/dialog/Drill.js +172 -0
  44. package/dist/primitives/dialog/index.d.ts +1 -0
  45. package/dist/primitives/dialog/index.js +2 -0
  46. package/dist/primitives/field/DropZone.js +1 -1
  47. package/dist/primitives/field/Field.d.ts +14 -2
  48. package/dist/primitives/field/Field.js +25 -14
  49. package/dist/primitives/field/copy.d.ts +16 -0
  50. package/dist/primitives/field/copy.js +69 -0
  51. package/dist/primitives/field/index.d.ts +2 -0
  52. package/dist/primitives/field/index.js +4 -0
  53. package/dist/primitives/field/note.d.ts +55 -0
  54. package/dist/primitives/field/note.js +117 -0
  55. package/dist/primitives/index.d.ts +1 -0
  56. package/dist/primitives/index.js +1 -0
  57. package/dist/primitives/menu/Menu.d.ts +28 -0
  58. package/dist/primitives/menu/Menu.js +47 -17
  59. package/dist/primitives/notice/Callout.js +1 -1
  60. package/dist/primitives/notice/Flag.d.ts +13 -0
  61. package/dist/primitives/notice/Flag.js +31 -0
  62. package/dist/primitives/notice/index.d.ts +1 -0
  63. package/dist/primitives/notice/index.js +1 -0
  64. package/dist/primitives/overlay/Preview.d.ts +19 -0
  65. package/dist/primitives/overlay/Preview.js +60 -0
  66. package/dist/primitives/overlay/index.d.ts +1 -0
  67. package/dist/primitives/overlay/index.js +3 -1
  68. package/dist/primitives/picker/Picker.d.ts +41 -0
  69. package/dist/primitives/picker/Picker.js +62 -8
  70. package/dist/primitives/table/CellCopy.d.ts +20 -0
  71. package/dist/primitives/table/CellCopy.js +23 -0
  72. package/dist/primitives/table/DataTable.d.ts +55 -1
  73. package/dist/primitives/table/DataTable.js +126 -40
  74. package/dist/primitives/table/EditableTable.d.ts +97 -2
  75. package/dist/primitives/table/EditableTable.js +223 -29
  76. package/dist/primitives/table/SheetTable.d.ts +4 -1
  77. package/dist/primitives/table/SheetTable.js +38 -5
  78. package/dist/primitives/table/index.d.ts +2 -2
  79. package/dist/primitives/table/index.js +1 -1
  80. package/dist/primitives/tabs/Tabs.d.ts +2 -2
  81. package/dist/primitives/tabs/Tabs.js +1 -1
  82. package/dist/primitives/tag/TagsField.d.ts +3 -0
  83. package/dist/primitives/tag/TagsField.js +6 -2
  84. package/dist/primitives/tree/Tree.d.ts +56 -0
  85. package/dist/primitives/tree/Tree.js +74 -0
  86. package/dist/primitives/tree/index.d.ts +1 -0
  87. package/dist/primitives/tree/index.js +3 -0
  88. package/dist/primitives/value/Copyable.d.ts +11 -0
  89. package/dist/primitives/value/Copyable.js +27 -6
  90. package/dist/primitives/value/Fecha.js +10 -1
  91. package/dist/primitives/value/ProgressBar.d.ts +31 -0
  92. package/dist/primitives/value/ProgressBar.js +53 -0
  93. package/dist/primitives/value/Relation.d.ts +6 -1
  94. package/dist/primitives/value/Relation.js +27 -10
  95. package/dist/primitives/value/Totals.d.ts +102 -7
  96. package/dist/primitives/value/Totals.js +52 -2
  97. package/dist/primitives/value/Value.d.ts +0 -37
  98. package/dist/primitives/value/Value.js +0 -39
  99. package/dist/primitives/value/index.d.ts +4 -3
  100. package/dist/primitives/value/index.js +4 -3
  101. package/dist/tokens.css +44 -7
  102. package/dist/views/list/List.d.ts +8 -2
  103. package/dist/views/list/List.js +6 -2
  104. package/dist/views/list/ListView.d.ts +1 -1
  105. package/dist/views/list/ListView.js +34 -8
  106. package/dist/views/list/derive.d.ts +12 -1
  107. package/dist/views/list/derive.js +29 -3
  108. package/dist/views/list/presentations.d.ts +2 -2
  109. package/dist/views/list/presentations.js +5 -3
  110. package/dist/views/list/types.d.ts +13 -1
  111. package/dist/views/record/Record.d.ts +27 -8
  112. package/dist/views/record/Record.js +97 -27
  113. package/dist/views/record/index.d.ts +1 -1
  114. package/package.json +5 -1
  115. package/dist/components/chatter/Chatter.d.ts +0 -161
  116. package/dist/components/chatter/Chatter.js +0 -232
  117. package/dist/components/chatter/index.js +0 -4
  118. /package/dist/components/{chatter → activity}/Timeline.d.ts +0 -0
  119. /package/dist/components/{chatter → activity}/Timeline.js +0 -0
@@ -1,6 +1,6 @@
1
1
  /* COMPONENTS — primitives composed to answer ONE question of the product, by family. */
2
+ export * from "./activity/index.js";
2
3
  export * from "./assist/index.js";
3
- export * from "./chatter/index.js";
4
4
  export * from "./form/index.js";
5
5
  export * from "./frame/index.js";
6
6
  export * from "./lines/index.js";
@@ -1,8 +1,9 @@
1
1
  import { type DialogContext } from "../../primitives/dialog/Dialog.js";
2
2
  import { type FormMode } from "../../primitives/field/mode.js";
3
+ import type { FlagTone } from "../../primitives/notice/Flag.js";
3
4
  import type { DataColumn } from "../../primitives/table/DataTable.js";
4
5
  import { type EditColumn, type EditLayout } from "../../primitives/table/EditableTable.js";
5
- import { type TotalsRow } from "../../primitives/value/Totals.js";
6
+ import { type TotalsProps, type TotalsRow } from "../../primitives/value/Totals.js";
6
7
  import { type ReactNode } from "react";
7
8
  /**
8
9
  * A column of the lines. The same contract as `DataTable`'s columns —`value` is
@@ -10,11 +11,25 @@ import { type ReactNode } from "react";
10
11
  * reads as plain text— plus `edit`, the control that replaces the cell when the
11
12
  * row is being edited in place.
12
13
  */
13
- export type LineItemColumn<T> = Pick<DataColumn<T>, "id" | "header" | "value" | "cell" | "text" | "numeric" | "width"> & Pick<EditColumn<T>, "stack" | "brief"> & {
14
+ export type LineItemColumn<T> = Pick<DataColumn<T>, "id" | "header" | "value" | "cell" | "text" | "numeric" | "width" | "optional" | "defaultHidden"> & Pick<EditColumn<T>, "stack" | "brief"> & {
14
15
  /** The control while the row is edited in place. Without it the cell stays read
15
16
  * only — a computed amount, a code. Only simple values belong here; the whole
16
17
  * line is edited in the `editor`. */
17
18
  edit?: (line: T, set: (next: T) => void) => ReactNode;
19
+ /** What the lines of a section add up to in this column, already formatted. Only
20
+ * with `structure`; without it the section says nothing in this column. */
21
+ subtotal?: (lines: readonly T[]) => ReactNode;
22
+ };
23
+ /** Which lines are sections or notes, and how their text is read and written. See
24
+ * «Sections and notes» in the file's header. */
25
+ export type LineStructure<T> = {
26
+ kind: (line: T) => "line" | "section" | "note";
27
+ text: (line: T) => string;
28
+ withText: (line: T, text: string) => T;
29
+ /** A blank section; enables «Agregar una sección». */
30
+ newSection?: () => T;
31
+ /** A blank note; enables «Agregar una nota». */
32
+ newNote?: () => T;
18
33
  };
19
34
  /** What the popup editor receives besides the draft. */
20
35
  export type LineEditorState = {
@@ -56,11 +71,33 @@ export type LineItemsProps<T> = {
56
71
  /** The search shows up only with MORE lines than this. Four lines are read, not
57
72
  * searched; a search box over them is a control that answers nothing. */
58
73
  searchFrom?: number;
59
- /** The money summary under the lines, on the right: the system's `Totals`. */
60
- totals?: readonly TotalsRow[];
74
+ /** The money summary under the lines, on the right: the system's `Totals`. Its rows,
75
+ * or all of it — payments deducted, the balance due, the credits that can be applied. */
76
+ totals?: readonly TotalsRow[] | TotalsProps;
61
77
  /** `auto` (the default) draws the lines as stacked cards below
62
78
  * `--container-handheld`; `table` and `stacked` force one. */
63
79
  layout?: EditLayout;
80
+ /** How «Agregar una línea» adds: `editor` opens the blank line in the dialog;
81
+ * `inline` puts it in the table, open, under the others (see the file's header).
82
+ * By default the dialog when there is an `editor`, the table when there is not. */
83
+ adding?: "inline" | "editor";
84
+ /** Open the whole line somewhere else than this block's dialog — the app's `Drill`, so
85
+ * the line opens as «S00142 › Línea 3» and can go deeper (its product, its lot) without
86
+ * a modal over a modal. When given, it replaces `editor` for existing lines. */
87
+ onOpenLine?: (id: string) => void;
88
+ /** A warning about one line, on the line: «Sin existencias», «Precio bajo el costo».
89
+ * `EditableTable`'s `flag`, passed through. */
90
+ flag?: (line: T) => {
91
+ tone: FlagTone;
92
+ label: string;
93
+ detail?: ReactNode;
94
+ owner?: string;
95
+ } | null;
96
+ /** Sections and notes among the lines. See the file's header. */
97
+ structure?: LineStructure<T>;
98
+ /** Which columns are on screen, when the owner remembers it (a preference). */
99
+ visibleColumnIds?: readonly string[];
100
+ onVisibleColumnIdsChange?: (ids: readonly string[]) => void;
64
101
  };
65
102
  /**
66
103
  * The lines of a document inside its record: a count and a search above, the lines
@@ -71,4 +108,4 @@ export type LineItemsProps<T> = {
71
108
  * removing asked first, what an empty document said. One component, built on
72
109
  * `EditableTable` and `Dialog`, makes the one2many the same block everywhere.
73
110
  */
74
- export declare function LineItems<T>({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom, totals, layout, }: LineItemsProps<T>): import("react").JSX.Element;
111
+ export declare function LineItems<T>({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom, totals, layout, adding, onOpenLine, flag, structure, visibleColumnIds, onVisibleColumnIdsChange, }: LineItemsProps<T>): import("react").JSX.Element;
@@ -1,16 +1,19 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { commitDraft, filterLines, planRemoval, } from "./lines.js";
4
+ import { useColumnControl, } from "../listing/columns.js";
5
+ import { ColumnsMenu } from "../listing/ColumnsMenu.js";
4
6
  import { fillCopy, useUiCopy } from "../../foundations/copy.js";
5
7
  import { Button, IconButton } from "../../primitives/button/Button.js";
6
8
  import { SearchField } from "../../primitives/choice/SearchField.js";
7
9
  import { Dialog } from "../../primitives/dialog/Dialog.js";
8
10
  import { FieldIdsContext } from "../../primitives/field/context.js";
11
+ import { TextInput } from "../../primitives/field/Field.js";
9
12
  import { useFormMode } from "../../primitives/field/mode.js";
10
13
  import { Empty } from "../../primitives/notice/Notice.js";
11
14
  import { useToast } from "../../primitives/notice/Toast.js";
12
15
  import { EditableTable, } from "../../primitives/table/EditableTable.js";
13
- import { Totals } from "../../primitives/value/Totals.js";
16
+ import { Totals, } from "../../primitives/value/Totals.js";
14
17
  import { Pencil, Plus, Trash2 } from "lucide-react";
15
18
  import { useEffect, useId, useMemo, useRef, useState } from "react";
16
19
  /** The notice is optional: without a `ToastProvider` the line is still removed, just
@@ -35,7 +38,7 @@ function plain(v) {
35
38
  * removing asked first, what an empty document said. One component, built on
36
39
  * `EditableTable` and `Dialog`, makes the one2many the same block everywhere.
37
40
  */
38
- export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom = 8, totals, layout = "auto", }) {
41
+ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom = 8, totals, layout = "auto", adding, onOpenLine, flag, structure, visibleColumnIds, onVisibleColumnIdsChange, }) {
39
42
  const copy = useUiCopy();
40
43
  const toast = useOptionalToast();
41
44
  const read = useFormMode(mode) === "read";
@@ -54,7 +57,17 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
54
57
  const canChange = !read && onChange !== undefined;
55
58
  const canAdd = !read && onAdd !== undefined && newLine !== undefined;
56
59
  const canRemove = !read && onRemove !== undefined;
57
- const canEdit = canChange && editor !== undefined;
60
+ /* The whole line opens either in this block's own dialog (`editor`) or wherever the app
61
+ opens records (`onOpenLine`, a `Drill` context) — never both, so a line's form never
62
+ sits on top of another modal. */
63
+ const canEdit = (canChange && editor !== undefined) || onOpenLine !== undefined;
64
+ const openLine = (id, line) => {
65
+ if (onOpenLine !== undefined)
66
+ onOpenLine(id);
67
+ else
68
+ openEditor(id, line);
69
+ };
70
+ const inline = canAdd && (adding ?? (editor === undefined ? "inline" : "editor")) === "inline";
58
71
  const labelOf = (line) => {
59
72
  if (lineLabel !== undefined)
60
73
  return lineLabel(line);
@@ -63,11 +76,35 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
63
76
  return getLineId(line);
64
77
  return first.text?.(line) ?? plain(first.value(line));
65
78
  };
66
- const searchable = lines.length > searchFrom;
79
+ const kindOf = (line) => structure?.kind(line) ?? "line";
80
+ /* Sections and notes are not lines: they are not counted, and a search leaves only lines. */
81
+ const lineCount = structure === undefined ? lines.length : lines.filter((l) => kindOf(l) === "line").length;
82
+ const searchable = lineCount > searchFrom;
67
83
  const q = searchable ? query.trim() : "";
68
- const shown = useMemo(() => filterLines(lines, q, (line) => searchText !== undefined
69
- ? searchText(line)
70
- : columns.map((c) => c.text?.(line) ?? plain(c.value(line))).join(" "), copy.locale), [lines, columns, searchText, q, copy.locale]);
84
+ const shown = useMemo(() => {
85
+ const found = filterLines(lines, q, (line) => searchText !== undefined
86
+ ? searchText(line)
87
+ : columns.map((c) => c.text?.(line) ?? plain(c.value(line))).join(" "), copy.locale);
88
+ return q === "" || structure === undefined
89
+ ? found
90
+ : found.filter((l) => structure.kind(l) === "line");
91
+ }, [lines, columns, searchText, q, copy.locale, structure]);
92
+ /* ── which columns are on screen ── */
93
+ const specs = useMemo(() => columns.map((c) => ({
94
+ id: c.id,
95
+ label: c.header,
96
+ optional: c.optional === true,
97
+ ...(c.defaultHidden === true ? { defaultHidden: true } : {}),
98
+ })), [columns]);
99
+ /* `visibleColumnIds` alone is a fixed choice, the way a controlled input without
100
+ `onChange` is: the owner said which columns, and the menu cannot change them. */
101
+ const columnControl = useColumnControl(specs, visibleColumnIds === undefined && onVisibleColumnIdsChange === undefined
102
+ ? {}
103
+ : {
104
+ ...(visibleColumnIds === undefined ? {} : { visibleIds: visibleColumnIds }),
105
+ onVisibleIdsChange: onVisibleColumnIdsChange ?? (() => { }),
106
+ });
107
+ const onScreen = columns.filter((c) => columnControl.isVisible(c.id));
71
108
  useEffect(() => {
72
109
  const target = focusNext.current;
73
110
  if (target === null)
@@ -122,6 +159,49 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
122
159
  else
123
160
  openEditor(null, blank);
124
161
  };
162
+ /* In place: the table opens the row whose id this returns. */
163
+ const addInline = (make = newLine) => {
164
+ if (make === undefined)
165
+ return undefined;
166
+ const blank = make();
167
+ setQuery("");
168
+ onAdd?.(blank);
169
+ return getLineId(blank);
170
+ };
171
+ /* A section and a note are always written in place, editor or not. */
172
+ const bandAdds = read || onAdd === undefined || structure === undefined
173
+ ? []
174
+ : [
175
+ ...(structure.newSection === undefined
176
+ ? []
177
+ : [
178
+ {
179
+ label: copy.lines.addSection,
180
+ onAdd: () => addInline(structure.newSection),
181
+ },
182
+ ]),
183
+ ...(structure.newNote === undefined
184
+ ? []
185
+ : [{ label: copy.lines.addNote, onAdd: () => addInline(structure.newNote) }]),
186
+ ];
187
+ const inPlace = inline || bandAdds.length > 0;
188
+ const bands = structure === undefined
189
+ ? undefined
190
+ : {
191
+ kind: (line) => {
192
+ const k = structure.kind(line);
193
+ return k === "line" ? null : k;
194
+ },
195
+ render: (line) => structure.text(line),
196
+ ...(canChange
197
+ ? {
198
+ edit: (line, set) => (_jsx(TextInput, { "aria-label": structure.kind(line) === "section"
199
+ ? copy.lines.section
200
+ : copy.lines.note, value: structure.text(line), onChange: (text) => set(structure.withText(line, text)) })),
201
+ }
202
+ : {}),
203
+ total: (column, under) => columns.find((c) => c.id === column.id)?.subtotal?.(under) ?? null,
204
+ };
125
205
  const remove = (line) => {
126
206
  const id = getLineId(line);
127
207
  const { index, focus } = planRemoval(lines, shown, id, getLineId);
@@ -140,7 +220,7 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
140
220
  });
141
221
  };
142
222
  /* ── the table ──────────────────────────────────────────────────── */
143
- const tableColumns = columns.map((c) => ({
223
+ const tableColumns = onScreen.map((c) => ({
144
224
  id: c.id,
145
225
  header: c.header,
146
226
  ...(c.width === undefined ? {} : { width: c.width }),
@@ -170,6 +250,7 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
170
250
  would be two more targets on a small screen. Without an editor the trash
171
251
  is the only way to remove, so it stays — at the card's top right, lit. */
172
252
  stack: canEdit ? "hidden" : "trail",
253
+ onBands: true,
173
254
  render: (line) => {
174
255
  const id = getLineId(line);
175
256
  const nameId = `${formId}-line-${id}`;
@@ -178,20 +259,21 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
178
259
  and a trash can lit on each of twenty lines are forty invitations. They
179
260
  fade instead of unmounting, so Tab still reaches them. Pressing them does
180
261
  not put the row into in-place editing. */
181
- _jsxs("span", { "data-line": id, className: "flex items-center justify-end gap-0.5 whitespace-nowrap opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100 in-data-stacked:opacity-100", onClick: (e) => e.stopPropagation(), children: [_jsx("span", { id: nameId, className: "sr-only", children: labelOf(line) }), canEdit ? (_jsx(IconButton, { icon: Pencil, label: copy.lines.edit, "aria-describedby": nameId, haspopup: "dialog", onClick: () => openEditor(id, line) })) : null, canRemove ? (_jsx(IconButton, { icon: Trash2, label: copy.lines.remove, tone: "danger", "aria-describedby": nameId, onClick: () => remove(line) })) : null] }));
262
+ _jsxs("span", { "data-line": id, className: "flex items-center justify-end gap-0.5 whitespace-nowrap opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100 in-data-stacked:opacity-100", onClick: (e) => e.stopPropagation(), children: [_jsx("span", { id: nameId, className: "sr-only", children: labelOf(line) }), canEdit && kindOf(line) === "line" ? (_jsx(IconButton, { icon: Pencil, label: copy.lines.edit, "aria-describedby": nameId, haspopup: "dialog", onClick: () => openLine(id, line) })) : null, canRemove ? (_jsx(IconButton, { icon: Trash2, label: copy.lines.remove, tone: "danger", "aria-describedby": nameId, onClick: () => remove(line) })) : null] }));
182
263
  },
183
264
  });
184
265
  }
185
- const addButton = canAdd ? (_jsx(Button, { role: "secondary", size: "sm", icon: Plus, onClick: add, "data-line-add": "", children: copy.lines.add })) : null;
266
+ /* In place, «add» is the table's own row under the grid: where the line will appear. */
267
+ const addButton = canAdd && !inline ? (_jsx(Button, { role: "secondary", size: "sm", icon: Plus, onClick: add, "data-line-add": "", children: copy.lines.add })) : null;
186
268
  const count = q === ""
187
- ? fillCopy(lines.length === 1 ? copy.lineItems.one : copy.lineItems.many, {
188
- count: lines.length.toLocaleString(copy.locale),
269
+ ? fillCopy(lineCount === 1 ? copy.lineItems.one : copy.lineItems.many, {
270
+ count: lineCount.toLocaleString(copy.locale),
189
271
  })
190
272
  : fillCopy(copy.lineItems.shown, {
191
273
  shown: shown.length.toLocaleString(copy.locale),
192
- count: lines.length.toLocaleString(copy.locale),
274
+ count: lineCount.toLocaleString(copy.locale),
193
275
  });
194
- return (_jsxs("section", { ref: root, "aria-label": copy.lineItems.table, className: "flex min-w-0 flex-col gap-4", children: [lines.length === 0 ? (
276
+ return (_jsxs("section", { ref: root, "aria-label": copy.lineItems.table, className: "flex min-w-0 flex-col gap-4", children: [lines.length === 0 && !canAdd ? (
195
277
  /* Said, with its way out: an empty document is a question («what is sold
196
278
  here?»), not a blank box. In read mode there is no way out to offer. */
197
279
  _jsx(Empty, { align: "start", sentence: copy.lines.empty, action: addButton ?? undefined })) : (_jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 px-1", children: [searchable ? (_jsxs("div", { className: "w-full max-w-(--container-search) min-w-0 flex-1", children: [_jsx("span", { id: searchLabelId, className: "sr-only", children: copy.lines.search }), _jsx(FieldIdsContext.Provider, { value: {
@@ -199,19 +281,25 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
199
281
  labelId: searchLabelId,
200
282
  describedBy: undefined,
201
283
  invalid: false,
202
- }, children: _jsx(SearchField, { value: query, onChange: setQuery, placeholder: copy.lines.search }) })] })) : null, _jsx("span", { className: "text-meta text-muted tabular-nums", "aria-live": "polite", children: count }), addButton === null ? null : _jsx("span", { className: "ml-auto", children: addButton })] }), _jsx(EditableTable, { rows: shown, columns: tableColumns, getRowId: getLineId, empty: fillCopy(copy.lineItems.noMatch, { query: query.trim() }), layout: layout, ...(canEdit
284
+ }, children: _jsx(SearchField, { value: query, onChange: setQuery, placeholder: copy.lines.search }) })] })) : null, _jsx("span", { className: "text-meta text-muted tabular-nums", "aria-live": "polite", children: count }), columnControl.hasOptional || addButton !== null ? (_jsxs("span", { className: "ml-auto flex items-center gap-2", children: [_jsx(ColumnsMenu, { control: columnControl }), addButton] })) : null] }), _jsx(EditableTable, { rows: shown, columns: tableColumns, getRowId: getLineId, empty: lines.length === 0
285
+ ? copy.lines.empty
286
+ : fillCopy(copy.lineItems.noMatch, { query: query.trim() }), layout: layout, ...(inline ? { onAdd: () => addInline(), addLabel: copy.lines.add } : {}), ...(bandAdds.length === 0 ? {} : { adds: bandAdds }), ...(bands === undefined ? {} : { bands }), ...(flag === undefined ? {} : { flag }), ...(inPlace && onRemove !== undefined
287
+ ? /* A blank row nobody wrote in is taken back without a notice: nobody
288
+ removed anything, so there is nothing to undo. */
289
+ { onDiscard: onRemove }
290
+ : {}), ...(canEdit
203
291
  ? {
204
292
  onOpen: (id) => {
205
293
  const line = lines.find((l) => getLineId(l) === id);
206
294
  if (line !== undefined)
207
- openEditor(id, line);
295
+ openLine(id, line);
208
296
  },
209
297
  }
210
298
  : {}), ...(canChange && columns.some((c) => c.edit !== undefined)
211
299
  ? { onChange: (id, next) => onChange?.(id, next) }
212
- : {}) })] })), totals === undefined || totals.length === 0 ? null : (
300
+ : {}) })] })), totals === undefined || ("length" in totals && totals.length === 0) ? null : (
213
301
  /* Inset like the table's cells, so the last figure lines up with the amounts. */
214
- _jsx("div", { className: "px-4", children: _jsx(Totals, { rows: totals }) })), editor === undefined || session === null ? null : (_jsx(Dialog, { open: open, width: "sm", title: session.id === null ? copy.lineItems.newLine : copy.lines.edit, ...(context === undefined ? {} : { context }), ...(session.id === null ? {} : { subtitle: labelOf(session.draft) }), onClose: () => setOpen(false), footer: _jsxs(_Fragment, { children: [session.id === null || !canRemove ? null : (
302
+ _jsx("div", { className: "px-4", children: "length" in totals ? _jsx(Totals, { rows: totals }) : _jsx(Totals, { ...totals }) })), editor === undefined || session === null ? null : (_jsx(Dialog, { open: open, width: "sm", title: session.id === null ? copy.lineItems.newLine : copy.lines.edit, ...(context === undefined ? {} : { context }), ...(session.id === null ? {} : { subtitle: labelOf(session.draft) }), onClose: () => setOpen(false), footer: _jsxs(_Fragment, { children: [session.id === null || !canRemove ? null : (
215
303
  /* Apart, at the other end from «Listo»: removing is not the way the
216
304
  form is meant to close. */
217
305
  _jsx("span", { className: "mr-auto", children: _jsx(IconButton, { icon: Trash2, label: copy.lines.remove, tone: "danger", onClick: () => {
@@ -82,5 +82,5 @@ export function Listing({ bar, metrics, sticky = false, surface, children, }) {
82
82
  background is its white and its radius; a frame around a list that is
83
83
  already inside a framed panel is a nested rectangle, and nested
84
84
  rectangles are what make a product read like a cloud console. */
85
- _jsxs("div", { className: `flex min-w-0 flex-col ${byDensity(d, "gap-2.5", "gap-2")}`, children: [inSlot ? (createPortal(bar, slot)) : (_jsx("div", { ref: band.ref, className: `min-w-0 ${pegada ? `sticky z-30 ${surface === "chrome" ? "bg-chrome" : "bg-ground"}` : ""}`, style: pegada ? band.self : undefined, children: bar })), inSlot && metrics !== undefined ? _jsx("div", { className: "pt-2", children: metrics }) : metrics, _jsx("div", { className: "min-w-0 overflow-clip rounded-box bg-surface", style: pegada && !inSlot ? band.below : undefined, children: children })] }));
85
+ _jsxs("div", { className: `flex min-w-0 flex-col ${byDensity(d, "gap-2.5", "gap-2")}`, children: [bar === undefined || bar === null ? null : inSlot ? (createPortal(bar, slot)) : (_jsx("div", { ref: band.ref, className: `min-w-0 ${pegada ? `sticky z-30 ${surface === "chrome" ? "bg-chrome" : "bg-ground"}` : ""}`, style: pegada ? band.self : undefined, children: bar })), inSlot && metrics !== undefined ? _jsx("div", { className: "pt-2", children: metrics }) : metrics, _jsx("div", { className: "min-w-0 overflow-clip rounded-box bg-surface", style: pegada && !inSlot ? band.below : undefined, children: children })] }));
86
86
  }
@@ -32,8 +32,40 @@ import type { ReactNode } from "react";
32
32
  * checkboxes, and the row they were about to tick is no longer where it was.
33
33
  *
34
34
  * The actions on the left do NOT go away: «Nuevo» stays available with three
35
- * rows selected, because selecting is not entering a mode. */
36
- export declare function ListingBar({ actions, title, description, counters, selection, search, pagination, views, crumbs, surface, }: {
35
+ * rows selected, because selecting is not entering a mode.
36
+ *
37
+ * ── Inside a record it is ONE line (`inline`) ────────────────────────
38
+ *
39
+ * ┌───────────────────────────────────────────────────────────────┐
40
+ * │ 🔍 search the lines… 12 records [sort][cols][▤▦] │
41
+ * └───────────────────────────────────────────────────────────────┘
42
+ * WHAT I LOOK FOR (fills) HOW MANY HOW I LOOK
43
+ *
44
+ * A collection that is a FIELD of a record — the lines of an order, the
45
+ * contacts of a customer, the journal items of an invoice — is not a screen.
46
+ * Everything the two rows above answer is already answered by the record
47
+ * around it: where I am (the record's crumbs), what this is (the tab it sits
48
+ * in), what I can do (the record's actions). Drawn with the page bar, it
49
+ * spent two rows and a rule on the one question left —the search, alone and
50
+ * centred on the first row; «Ordenar» and the switcher alone on the second—
51
+ * and pushed the rows, which are what the tab is for, below the fold.
52
+ *
53
+ * So it is a single line, and the order is the order of the questions: the
54
+ * search FILLS from the left (it is the only thing with variable content),
55
+ * then how many there are, then how they are looked at, then what starts a
56
+ * new one. No band, no padding, no hairline: it sits on the record's paper,
57
+ * aligned with the fields above it, and a light band inside a white sheet is
58
+ * a rectangle inside a rectangle.
59
+ *
60
+ * On a phone the line breaks by itself, with no threshold written anywhere:
61
+ * the search asks for `--container-name` before it gives up its place, so
62
+ * when the controls do not fit next to it they drop to a second line, pushed
63
+ * to the right, and the search keeps the whole first one.
64
+ *
65
+ * The count is a live region: typing in the search changes it, and somebody
66
+ * who cannot see the rows thin out hears how many are left. With
67
+ * `pagination` the page range replaces it — it already says how many. */
68
+ export declare function ListingBar({ actions, title, description, counters, selection, search, pagination, views, crumbs, surface, inline, count, }: {
37
69
  /** What starts something new. It stays visible even when there is a selection. */
38
70
  actions?: ReactNode;
39
71
  title?: string;
@@ -62,6 +94,13 @@ export declare function ListingBar({ actions, title, description, counters, sele
62
94
  /** `chrome` paints the whole band navy and everything inside inherits it:
63
95
  * the search box, the pagination and the switcher. See `system/surface.tsx`. */
64
96
  surface?: Surface;
97
+ /** One line, for a collection inside a record: search, count, views, actions. The
98
+ * title, the description, the counters and the crumbs are not drawn — the record
99
+ * around it already says them. Always on the canvas. See the header. */
100
+ inline?: boolean;
101
+ /** How many records there are, said on the inline line when there is no
102
+ * `pagination` to say it. Ignored by the two-row bar: its identity has counters. */
103
+ count?: number;
65
104
  }): import("react").JSX.Element;
66
105
  export type ListIdentity = {
67
106
  title?: string;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useUiCopy } from "../../foundations/copy.js";
3
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
4
4
  import { byDensity, useDensity } from "../../foundations/density.js";
5
5
  import { SurfaceProvider, useSurface } from "../../foundations/surface.js";
6
6
  import { Line } from "../../primitives/block/Line.js";
@@ -37,12 +37,44 @@ import { X } from "lucide-react";
37
37
  * checkboxes, and the row they were about to tick is no longer where it was.
38
38
  *
39
39
  * The actions on the left do NOT go away: «Nuevo» stays available with three
40
- * rows selected, because selecting is not entering a mode. */
41
- export function ListingBar({ actions, title, description, counters, selection, search, pagination, views, crumbs, surface, }) {
40
+ * rows selected, because selecting is not entering a mode.
41
+ *
42
+ * ── Inside a record it is ONE line (`inline`) ────────────────────────
43
+ *
44
+ * ┌───────────────────────────────────────────────────────────────┐
45
+ * │ 🔍 search the lines… 12 records [sort][cols][▤▦] │
46
+ * └───────────────────────────────────────────────────────────────┘
47
+ * WHAT I LOOK FOR (fills) HOW MANY HOW I LOOK
48
+ *
49
+ * A collection that is a FIELD of a record — the lines of an order, the
50
+ * contacts of a customer, the journal items of an invoice — is not a screen.
51
+ * Everything the two rows above answer is already answered by the record
52
+ * around it: where I am (the record's crumbs), what this is (the tab it sits
53
+ * in), what I can do (the record's actions). Drawn with the page bar, it
54
+ * spent two rows and a rule on the one question left —the search, alone and
55
+ * centred on the first row; «Ordenar» and the switcher alone on the second—
56
+ * and pushed the rows, which are what the tab is for, below the fold.
57
+ *
58
+ * So it is a single line, and the order is the order of the questions: the
59
+ * search FILLS from the left (it is the only thing with variable content),
60
+ * then how many there are, then how they are looked at, then what starts a
61
+ * new one. No band, no padding, no hairline: it sits on the record's paper,
62
+ * aligned with the fields above it, and a light band inside a white sheet is
63
+ * a rectangle inside a rectangle.
64
+ *
65
+ * On a phone the line breaks by itself, with no threshold written anywhere:
66
+ * the search asks for `--container-name` before it gives up its place, so
67
+ * when the controls do not fit next to it they drop to a second line, pushed
68
+ * to the right, and the search keeps the whole first one.
69
+ *
70
+ * The count is a live region: typing in the search changes it, and somebody
71
+ * who cannot see the rows thin out hears how many are left. With
72
+ * `pagination` the page range replaces it — it already says how many. */
73
+ export function ListingBar({ actions, title, description, counters, selection, search, pagination, views, crumbs, surface, inline = false, count, }) {
42
74
  const selecting = selection !== undefined && selection.count > 0;
43
75
  const d = useDensity();
44
76
  const copy = useUiCopy();
45
- const activeSurface = useSurface(surface);
77
+ const activeSurface = useSurface(inline ? "canvas" : surface);
46
78
  const isChrome = activeSurface === "chrome";
47
79
  /* In compact the title and the description are NOT drawn, and it is not for
48
80
  lack of room: in a preview or on a phone, the surface header already says
@@ -61,6 +93,13 @@ export function ListingBar({ actions, title, description, counters, selection, s
61
93
  ALREADY filtered, so searching there would change the set underneath the
62
94
  selection. */
63
95
  _jsx("div", { className: "w-full min-w-0", children: search }));
96
+ if (inline) {
97
+ const howMany = pagination ??
98
+ (count === undefined ? null : (_jsx("span", { role: "status", className: "shrink-0 text-meta text-muted tabular-nums", children: count === 1
99
+ ? copy.list.countOne
100
+ : fillCopy(copy.list.count, { count: count.toLocaleString(copy.locale) }) })));
101
+ return (_jsx(SurfaceProvider, { value: "canvas", children: _jsxs("div", { className: `flex min-w-0 flex-wrap items-center gap-y-1.5 ${byDensity(d, "gap-x-3", "gap-x-2")}`, children: [center === null ? null : (_jsx("div", { className: "flex min-w-0 flex-1 basis-(--container-name) items-center overflow-x-clip overflow-y-visible", children: center })), howMany, views === undefined ? null : (_jsx("div", { className: "ml-auto flex shrink-0 items-center gap-2", children: views })), actions === undefined ? null : (_jsx("div", { className: `flex shrink-0 items-center gap-1.5 ${views === undefined ? "ml-auto" : ""}`, children: actions }))] }) }));
102
+ }
64
103
  /* El campo de búsqueda va en el CENTRO de la línea, y centrado contra la
65
104
  línea: pide `--container-search` y los dos lados se reparten lo que sobra
66
105
  en mitades iguales. Antes tomaba el resto, así que un rastro largo a la
@@ -18,9 +18,13 @@ import type { ReactNode } from "react";
18
18
  *
19
19
  * «some metadata that might help is not visible» — and it was literal: to know
20
20
  * whether `FAC/2026/0118` was paid or overdue you had to open it. */
21
- export declare function Row({ icon: Icon, name, onOpen, onPeek, tags, meta, selected, onSelectedChange, actions, more, onMoreSelect, openHint, children, }: {
21
+ export declare function Row({ icon: Icon, name, onOpen, onPeek, tags, meta, selected, onSelectedChange, actions, more, onMoreSelect, openHint, children, copy: copyAs, }: {
22
22
  icon?: LucideIcon;
23
23
  name: string;
24
+ /** The copy button among the row's actions: on by default, copying the row's name — the
25
+ * number of an invoice, a customer — without opening it. A string copies that instead;
26
+ * `false` removes it. */
27
+ copy?: string | false;
24
28
  onOpen?: () => void;
25
29
  onPeek?: () => void;
26
30
  tags?: ReactNode;
@@ -5,6 +5,7 @@ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
5
5
  import { byDensity, useDensity } from "../../foundations/density.js";
6
6
  import { stickyTop } from "../../foundations/sticky.js";
7
7
  import { Menu } from "../../primitives/menu/Menu.js";
8
+ import { CopyButton } from "../../primitives/value/Copyable.js";
8
9
  import { ChevronRight, MoreHorizontal, SquareArrowOutUpRight } from "lucide-react";
9
10
  /* ---------------------------------------------------------------- *
10
11
  * THE RICH ROW — the other density
@@ -26,7 +27,7 @@ import { ChevronRight, MoreHorizontal, SquareArrowOutUpRight } from "lucide-reac
26
27
  *
27
28
  * «some metadata that might help is not visible» — and it was literal: to know
28
29
  * whether `FAC/2026/0118` was paid or overdue you had to open it. */
29
- export function Row({ icon: Icon, name, onOpen, onPeek, tags, meta, selected, onSelectedChange, actions, more, onMoreSelect, openHint, children, }) {
30
+ export function Row({ icon: Icon, name, onOpen, onPeek, tags, meta, selected, onSelectedChange, actions, more, onMoreSelect, openHint, children, copy: copyAs, }) {
30
31
  const d = useDensity();
31
32
  const copy = useUiCopy();
32
33
  const openAs = openHint ?? copy.shell.openAsPanel;
@@ -52,7 +53,7 @@ export function Row({ icon: Icon, name, onOpen, onPeek, tags, meta, selected, on
52
53
  having to open a set to know whether it is a thousand rows or a
53
54
  million— and it truncates instead of wrapping into itself: three
54
55
  lines per row in a 480 px drawer means three rows fit on screen. */
55
- _jsx("span", { className: "min-w-0 truncate text-micro tabular-nums text-muted", children: meta }))] }), tags === undefined ? null : (_jsx("span", { className: "flex min-w-fit shrink basis-(--container-tags) items-center justify-end gap-1.5", children: tags })), _jsxs("span", { className: "flex shrink-0 items-center gap-1 opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100", children: [actions, onOpen === undefined ? null : (_jsx("button", { type: "button", onClick: (e) => {
56
+ _jsx("span", { className: "min-w-0 truncate text-micro tabular-nums text-muted", children: meta }))] }), tags === undefined ? null : (_jsx("span", { className: "flex min-w-fit shrink basis-(--container-tags) items-center justify-end gap-1.5", children: tags })), _jsxs("span", { className: "flex shrink-0 items-center gap-1 opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100", children: [actions, copyAs === false ? null : (_jsx(CopyButton, { value: copyAs ?? name, label: fillCopy(copy.copyable.field, { label: name }) })), onOpen === undefined ? null : (_jsx("button", { type: "button", onClick: (e) => {
56
57
  e.stopPropagation();
57
58
  onOpen();
58
59
  }, title: openAs, "aria-label": `${openAs}: ${name}`, className: "inline-flex size-(--size-control-sm) 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(SquareArrowOutUpRight, { className: "size-(--size-icon-sm)", strokeWidth: 2 }) })), more === undefined || more.length === 0 ? null : (_jsx("span", { onClick: (e) => e.stopPropagation(), role: "presentation", children: _jsx(Menu, { label: fillCopy(copy.selection.moreOf, { name }), groups: more, icon: MoreHorizontal, iconOnly: true, chevron: false, align: "end", width: "sm", variant: "text", ...(onMoreSelect === undefined ? {} : { onSelect: onMoreSelect }) }) }))] })] }) }));
@@ -2,9 +2,9 @@ import { type Crumb } from "./Breadcrumb.js";
2
2
  import { type DirtyState } from "./DirtyActions.js";
3
3
  import type { Surface, Tone } from "../../foundations/types.js";
4
4
  import type { LucideIcon } from "lucide-react";
5
- import { type ReactNode } from "react";
5
+ import type { ReactNode } from "react";
6
6
  export type { Surface };
7
- export declare function RecordHeader({ crumbs, pager, actions, status, kicker, icon: Icon, title, subtitle, tag, figure, railSurface, smart, aspects, dirty, chatter, chatterLayout, children, }: {
7
+ export declare function RecordHeader({ crumbs, pager, actions, status, kicker, icon: Icon, title, subtitle, tag, figure, railSurface, smart, aspects, dirty, activity, activityLayout, blocking, indicator, children, }: {
8
8
  crumbs?: readonly Crumb[];
9
9
  pager?: ReactNode;
10
10
  /** The action buttons. At most three visible + a `Menu` with `iconOnly`, and
@@ -12,25 +12,23 @@ export declare function RecordHeader({ crumbs, pager, actions, status, kicker, i
12
12
  * never the navigation ones. They take the rail's surface from context: write
13
13
  * them without `surface`, light by default, dark on `Record.Rail surface="chrome"`. */
14
14
  actions?: ReactNode;
15
- /** La historia del registro: `<Chatter …/>`. En `page` es la última fila de
16
- * la hoja —el mismo papel, separado por una línea— y no una segunda tarjeta.
17
- * En `aside` sigue al costado, con su propio marco. */
18
- chatter?: ReactNode;
15
+ /** Everything around the record: its notices, conversation and history — an
16
+ * `Activity`. Never inside the sheet: it is about the record, not part of the form. */
17
+ activity?: ReactNode;
19
18
  /**
20
- * Dónde se pone respecto de la hoja.
19
+ * Where the activity goes, as the record's width allows (THE PAGE, below).
21
20
  *
22
- * page debajo y a todo el ancho, como hoja propia. **El default**: un
23
- * formulario necesita su ancho, y una columna al costado se lo
24
- * come en cuanto la pantalla no sobra.
25
- * aside al costado cuando hay ancho, debajo cuando no.
21
+ * auto a column at the RIGHT of the sheet, joined to it, when the page is
22
+ * `--container-beside` wide, and a card BELOW the sheet when it is not.
23
+ * **The default.**
24
+ * below always below the sheet, at its width.
26
25
  *
27
- * Lo dice el REGISTRO y no se adivina del hijo: la dirección del flex la pone
28
- * este contenedor, y un padre que mira qué le pasaron adentro para decidir
29
- * cómo acomodarse se rompe la próxima vez que alguien envuelva el chatter en
30
- * otra cosa.
26
+ * It is said by the RECORD and not guessed from the child: the flex direction is set by
27
+ * this container, and a parent that inspects what it was handed to decide how to lay
28
+ * itself out breaks the next time someone wraps the panel in something else.
31
29
  */
32
- chatterLayout?: "aside" | "page";
33
- /** `StatusBar` (where is it at?), the chatter's `Timeline` (what happened to get
30
+ activityLayout?: "auto" | "below";
31
+ /** `StatusBar` (where is it at?), the activity's `Timeline` (what happened to get
34
32
  * here?), or both: they are different questions and many records only have one. */
35
33
  status?: ReactNode;
36
34
  /** The category, small and above the name: «factura de cliente». It makes a
@@ -65,6 +63,12 @@ export declare function RecordHeader({ crumbs, pager, actions, status, kicker, i
65
63
  * it has no way to know. As long as there are changes, the only thing that
66
64
  * can be done is confirm them or throw them away. */
67
65
  dirty?: DirtyState;
66
+ /** What is WRONG with the record — `RecordBlocking`. Always visible, outside the sheet:
67
+ * between the rail and the paper, never among the fields (see `RecordNotices`). */
68
+ blocking?: ReactNode;
69
+ /** The rest of the record's notices, as one indicator — `RecordNoticeIndicator`. In the
70
+ * rail next to the counters; without a rail, first in the record, next to them. */
71
+ indicator?: ReactNode;
68
72
  /** The record body. It sits IN the paper with the title, so groups, tabs
69
73
  * and the page are one white sheet on the ground — Odoo's `<sheet>`. */
70
74
  children?: ReactNode;