@sincpro/web-ui 0.1.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.
- package/README.md +40 -13
- package/dist/components/activity/Activity.d.ts +69 -0
- package/dist/components/activity/Activity.js +279 -0
- package/dist/components/activity/ActivityBadge.d.ts +16 -0
- package/dist/components/activity/ActivityBadge.js +29 -0
- package/dist/components/{chatter → activity}/Messages.d.ts +2 -2
- package/dist/components/{chatter → activity}/Messages.js +9 -6
- package/dist/components/activity/index.d.ts +4 -0
- package/dist/components/activity/index.js +5 -0
- package/dist/components/assist/AssistMark.d.ts +3 -3
- package/dist/components/assist/AssistMark.js +1 -1
- package/dist/components/assist/assist.d.ts +1 -1
- package/dist/components/assist/assist.js +1 -1
- package/dist/components/calendar/RecordCalendar.d.ts +48 -0
- package/dist/components/calendar/RecordCalendar.js +101 -0
- package/dist/components/calendar/index.d.ts +1 -0
- package/dist/components/calendar/index.js +1 -0
- package/dist/components/form/Sheet.d.ts +4 -0
- package/dist/components/form/Sheet.js +16 -5
- package/dist/components/gantt/RecordGantt.d.ts +44 -0
- package/dist/components/gantt/RecordGantt.js +110 -0
- package/dist/components/gantt/index.d.ts +1 -0
- package/dist/components/gantt/index.js +1 -0
- package/dist/components/index.d.ts +4 -1
- package/dist/components/index.js +4 -1
- package/dist/components/lines/LineItems.d.ts +45 -5
- package/dist/components/lines/LineItems.js +106 -18
- package/dist/components/listing/BoardColumns.d.ts +39 -0
- package/dist/components/listing/BoardColumns.js +138 -0
- package/dist/components/listing/Listing.js +1 -1
- package/dist/components/listing/ListingBar.d.ts +46 -2
- package/dist/components/listing/ListingBar.js +47 -5
- package/dist/components/listing/PivotExplorer.d.ts +34 -0
- package/dist/components/listing/PivotExplorer.js +86 -0
- package/dist/components/listing/RecordCard.d.ts +6 -8
- package/dist/components/listing/RecordCard.js +13 -7
- package/dist/components/listing/Row.d.ts +25 -2
- package/dist/components/listing/Row.js +30 -13
- package/dist/components/listing/index.d.ts +2 -0
- package/dist/components/listing/index.js +2 -0
- package/dist/components/listing/set.d.ts +1 -1
- package/dist/components/navigation/index.d.ts +0 -2
- package/dist/components/navigation/index.js +2 -3
- package/dist/components/record/RecordAudit.d.ts +17 -0
- package/dist/components/record/RecordAudit.js +27 -0
- package/dist/components/record/RecordHeader.d.ts +27 -17
- package/dist/components/record/RecordHeader.js +64 -20
- package/dist/components/record/RecordNotices.d.ts +37 -0
- package/dist/components/record/RecordNotices.js +75 -0
- package/dist/components/record/RecordRibbon.d.ts +13 -0
- package/dist/components/record/RecordRibbon.js +22 -0
- package/dist/components/record/ThumbBar.js +1 -1
- package/dist/components/record/index.d.ts +2 -0
- package/dist/components/record/index.js +2 -0
- package/dist/components/search/CriteriaChips.d.ts +6 -1
- package/dist/components/search/CriteriaChips.js +71 -20
- package/dist/components/search/SearchBar.d.ts +4 -1
- package/dist/components/search/SearchBar.js +15 -7
- package/dist/components/timeline/RecordTimeline.d.ts +60 -0
- package/dist/components/timeline/RecordTimeline.js +214 -0
- package/dist/components/timeline/TimelineToolbar.d.ts +30 -0
- package/dist/components/timeline/TimelineToolbar.js +31 -0
- package/dist/components/timeline/index.d.ts +3 -0
- package/dist/components/timeline/index.js +3 -0
- package/dist/components/timeline/time.d.ts +43 -0
- package/dist/components/timeline/time.js +135 -0
- package/dist/foundations/copy.d.ts +194 -18
- package/dist/foundations/copy.es.js +142 -15
- package/dist/foundations/copy.js +133 -11
- package/dist/foundations/index.d.ts +1 -1
- package/dist/foundations/match.d.ts +23 -0
- package/dist/foundations/match.js +63 -0
- package/dist/foundations/slots.d.ts +1 -1
- package/dist/foundations/slots.js +8 -3
- package/dist/foundations/types.d.ts +7 -0
- package/dist/foundations/types.js +0 -36
- package/dist/primitives/block/index.d.ts +1 -1
- package/dist/primitives/block/index.js +1 -1
- package/dist/primitives/cell/CellStack.d.ts +7 -1
- package/dist/primitives/cell/CellStack.js +5 -2
- package/dist/primitives/choice/ComboBox.d.ts +3 -0
- package/dist/primitives/choice/ComboBox.js +10 -6
- package/dist/primitives/choice/KanbanState.d.ts +14 -0
- package/dist/primitives/choice/KanbanState.js +37 -0
- package/dist/primitives/choice/Priority.d.ts +20 -0
- package/dist/primitives/choice/Priority.js +41 -0
- package/dist/primitives/choice/SearchField.d.ts +10 -1
- package/dist/primitives/choice/SearchField.js +21 -2
- package/dist/primitives/choice/Slider.d.ts +6 -1
- package/dist/primitives/choice/Slider.js +2 -2
- package/dist/primitives/choice/index.d.ts +2 -0
- package/dist/primitives/choice/index.js +2 -0
- package/dist/primitives/date/Calendar.js +30 -6
- package/dist/primitives/date/DatePicker.d.ts +11 -3
- package/dist/primitives/date/DatePicker.js +12 -8
- package/dist/primitives/date/DateTime.d.ts +35 -0
- package/dist/primitives/date/DateTime.js +100 -0
- package/dist/primitives/date/index.d.ts +1 -0
- package/dist/primitives/date/index.js +4 -2
- package/dist/primitives/dialog/Dialog.d.ts +9 -3
- package/dist/primitives/dialog/Dialog.js +41 -5
- package/dist/primitives/field/DropZone.js +1 -1
- package/dist/primitives/field/Field.d.ts +14 -2
- package/dist/primitives/field/Field.js +25 -14
- package/dist/primitives/field/copy.d.ts +16 -0
- package/dist/primitives/field/copy.js +69 -0
- package/dist/primitives/field/index.d.ts +2 -0
- package/dist/primitives/field/index.js +4 -0
- package/dist/primitives/field/note.d.ts +55 -0
- package/dist/primitives/field/note.js +117 -0
- package/dist/primitives/index.d.ts +1 -0
- package/dist/primitives/index.js +1 -0
- package/dist/primitives/menu/Menu.d.ts +28 -0
- package/dist/primitives/menu/Menu.js +47 -17
- package/dist/primitives/notice/Callout.js +1 -1
- package/dist/primitives/notice/Flag.d.ts +13 -0
- package/dist/primitives/notice/Flag.js +31 -0
- package/dist/primitives/notice/index.d.ts +2 -1
- package/dist/primitives/notice/index.js +2 -1
- package/dist/primitives/overlay/Preview.d.ts +19 -0
- package/dist/primitives/overlay/Preview.js +69 -0
- package/dist/primitives/overlay/index.d.ts +1 -0
- package/dist/primitives/overlay/index.js +3 -1
- package/dist/primitives/picker/MatchedText.d.ts +10 -0
- package/dist/primitives/picker/MatchedText.js +12 -0
- package/dist/primitives/picker/Picker.d.ts +67 -4
- package/dist/primitives/picker/Picker.js +142 -23
- package/dist/primitives/table/CellCopy.d.ts +20 -0
- package/dist/primitives/table/CellCopy.js +23 -0
- package/dist/primitives/table/DataTable.d.ts +55 -1
- package/dist/primitives/table/DataTable.js +126 -40
- package/dist/primitives/table/EditableTable.d.ts +102 -2
- package/dist/primitives/table/EditableTable.js +287 -32
- package/dist/primitives/table/ReportTable.js +2 -1
- package/dist/primitives/table/RowActions.js +7 -2
- package/dist/primitives/table/SheetTable.d.ts +4 -1
- package/dist/primitives/table/SheetTable.js +38 -5
- package/dist/primitives/table/Table.d.ts +5 -0
- package/dist/primitives/table/Table.js +17 -8
- package/dist/primitives/table/index.d.ts +2 -2
- package/dist/primitives/table/index.js +1 -1
- package/dist/primitives/tabs/Tabs.d.ts +2 -2
- package/dist/primitives/tabs/Tabs.js +1 -1
- package/dist/primitives/tag/Tags.d.ts +2 -0
- package/dist/primitives/tag/Tags.js +2 -1
- package/dist/primitives/tag/TagsField.d.ts +13 -0
- package/dist/primitives/tag/TagsField.js +39 -10
- package/dist/primitives/tag/choices.d.ts +2 -0
- package/dist/primitives/tree/Tree.d.ts +56 -0
- package/dist/primitives/tree/Tree.js +74 -0
- package/dist/primitives/tree/index.d.ts +1 -0
- package/dist/primitives/tree/index.js +3 -0
- package/dist/primitives/value/Avatar.d.ts +16 -0
- package/dist/primitives/value/Avatar.js +91 -0
- package/dist/primitives/value/Copyable.d.ts +11 -0
- package/dist/primitives/value/Copyable.js +27 -6
- package/dist/primitives/value/Fecha.js +10 -1
- package/dist/primitives/value/Person.d.ts +6 -51
- package/dist/primitives/value/Person.js +9 -148
- package/dist/primitives/value/ProgressBar.d.ts +31 -0
- package/dist/primitives/value/ProgressBar.js +53 -0
- package/dist/primitives/value/Relation.d.ts +22 -5
- package/dist/primitives/value/Relation.js +42 -14
- package/dist/primitives/value/Totals.d.ts +102 -7
- package/dist/primitives/value/Totals.js +52 -2
- package/dist/primitives/value/Value.d.ts +0 -37
- package/dist/primitives/value/Value.js +0 -39
- package/dist/primitives/value/contact.d.ts +30 -0
- package/dist/primitives/value/contact.js +51 -0
- package/dist/primitives/value/index.d.ts +5 -4
- package/dist/primitives/value/index.js +5 -4
- package/dist/tokens.css +179 -7
- package/dist/views/list/List.d.ts +32 -3
- package/dist/views/list/List.js +51 -7
- package/dist/views/list/ListView.d.ts +1 -1
- package/dist/views/list/ListView.js +70 -13
- package/dist/views/list/columns.js +8 -1
- package/dist/views/list/derive.d.ts +12 -1
- package/dist/views/list/derive.js +29 -3
- package/dist/views/list/presentations.d.ts +5 -2
- package/dist/views/list/presentations.js +35 -3
- package/dist/views/list/types.d.ts +57 -1
- package/dist/views/list/useNarrowList.d.ts +10 -0
- package/dist/views/list/useNarrowList.js +20 -6
- package/dist/views/record/Record.d.ts +41 -8
- package/dist/views/record/Record.js +113 -28
- package/dist/views/record/index.d.ts +1 -1
- package/package.json +15 -4
- package/dist/components/chatter/Chatter.d.ts +0 -161
- package/dist/components/chatter/Chatter.js +0 -232
- package/dist/components/chatter/index.d.ts +0 -3
- package/dist/components/chatter/index.js +0 -4
- package/dist/components/navigation/CommandPalette.d.ts +0 -40
- package/dist/components/navigation/CommandPalette.js +0 -114
- package/dist/components/navigation/commands.d.ts +0 -30
- package/dist/components/navigation/commands.js +0 -30
- /package/dist/components/{chatter → activity}/Timeline.d.ts +0 -0
- /package/dist/components/{chatter → activity}/Timeline.js +0 -0
|
@@ -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, onReorder, 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
|
-
|
|
60
|
+
/* The whole line opens either in this block's own dialog (`editor`) or wherever the app
|
|
61
|
+
opens records (`onOpenLine`, a modal of the workspace) — 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
|
|
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(() =>
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
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: () =>
|
|
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
|
-
|
|
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(
|
|
188
|
-
count:
|
|
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:
|
|
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
|
|
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 }), ...(onReorder === undefined || read ? {} : { onReorder }), ...(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
|
-
|
|
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: () => {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Tone } from "../../foundations/types.js";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
/** One stage of a board: its id (what `of` returns), its name, and its tone. */
|
|
4
|
+
export type BoardColumn = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
tone?: Tone;
|
|
8
|
+
};
|
|
9
|
+
/** How a board lays its cards in stages, and what moving one means. */
|
|
10
|
+
export type BoardStages<T> = {
|
|
11
|
+
/** The column a record goes in: its stage — or ANY field it is grouped by (the salesperson,
|
|
12
|
+
* the priority, the tag): a board is a grouping you can drag across. */
|
|
13
|
+
of: (item: T) => string;
|
|
14
|
+
/** The columns, in order. Without it they come from the records themselves, in the order
|
|
15
|
+
* they appear, named by `labelOf`. */
|
|
16
|
+
columns?: readonly BoardColumn[];
|
|
17
|
+
labelOf?: (id: string) => string;
|
|
18
|
+
/** A card dropped in another stage. Without it the board is read-only: nothing drags. */
|
|
19
|
+
onMove?: (item: T, to: string) => void;
|
|
20
|
+
/** How a record is named when a move is announced: «S00142 pasó a pedido». */
|
|
21
|
+
nameOf?: (item: T) => string;
|
|
22
|
+
/** The figure on a column's head: the expected revenue of the opportunities in it. */
|
|
23
|
+
total?: (items: readonly T[]) => ReactNode;
|
|
24
|
+
/** The bar under a column's head: how its records stand, by tone — Odoo's activity bar
|
|
25
|
+
* (overdue red, today amber, planned green). `undefined` for a record with nothing to say. */
|
|
26
|
+
progress?: (item: T) => Tone | undefined;
|
|
27
|
+
/** «+» on a column's head: a name typed there creates a record in that column. */
|
|
28
|
+
onQuickCreate?: (column: string, name: string) => void;
|
|
29
|
+
/** A column at the end: «Agregar etapa». */
|
|
30
|
+
onAddColumn?: (label: string) => void;
|
|
31
|
+
/** Columns that start folded into a narrow strip (the won, the lost). */
|
|
32
|
+
defaultFolded?: readonly string[];
|
|
33
|
+
};
|
|
34
|
+
export declare function BoardColumns<T>({ items, keyOf, stages, card, }: {
|
|
35
|
+
items: readonly T[];
|
|
36
|
+
keyOf: (item: T) => string;
|
|
37
|
+
stages: BoardStages<T>;
|
|
38
|
+
card: (item: T) => ReactNode;
|
|
39
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { fillCopy, useUiCopy } from "../../foundations/copy.js";
|
|
4
|
+
import { IconButton } from "../../primitives/button/Button.js";
|
|
5
|
+
import { StatusDot } from "../../primitives/tag/Tag.js";
|
|
6
|
+
import { ChevronsLeftRight, Plus } from "lucide-react";
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
/* The tones a progress bar draws, in the order they are read: what is wrong first. */
|
|
9
|
+
const PROGRESS_ORDER = ["bad", "warn", "good", "info", "neutral", "accent"];
|
|
10
|
+
const PROGRESS_FILL = {
|
|
11
|
+
bad: "bg-bad",
|
|
12
|
+
warn: "bg-warn",
|
|
13
|
+
good: "bg-good",
|
|
14
|
+
info: "bg-info",
|
|
15
|
+
neutral: "bg-muted",
|
|
16
|
+
accent: "bg-primary",
|
|
17
|
+
};
|
|
18
|
+
/** One-line input of a board: quick create, a new column. Enter keeps it, Escape leaves. */
|
|
19
|
+
function BoardInput({ label, placeholder, onCommit, onClose, }) {
|
|
20
|
+
const [text, setText] = useState("");
|
|
21
|
+
return (_jsx("input", { autoFocus: true, "aria-label": label, placeholder: placeholder, value: text, onChange: (event) => setText(event.target.value), onKeyDown: (event) => {
|
|
22
|
+
if (event.key === "Escape")
|
|
23
|
+
onClose();
|
|
24
|
+
if (event.key === "Enter" && text.trim() !== "") {
|
|
25
|
+
onCommit(text.trim());
|
|
26
|
+
setText("");
|
|
27
|
+
}
|
|
28
|
+
}, onBlur: () => {
|
|
29
|
+
if (text.trim() === "")
|
|
30
|
+
onClose();
|
|
31
|
+
}, className: "h-(--size-control-md) w-full min-w-0 rounded-control border border-primary/60 bg-surface px-2.5 text-meta text-ink outline-none placeholder:text-muted" }));
|
|
32
|
+
}
|
|
33
|
+
/** The column's bar: its records counted by tone, what is wrong first. Said in words too. */
|
|
34
|
+
function ProgressBar({ items, progress, }) {
|
|
35
|
+
const counts = new Map();
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
const tone = progress(item);
|
|
38
|
+
if (tone !== undefined)
|
|
39
|
+
counts.set(tone, (counts.get(tone) ?? 0) + 1);
|
|
40
|
+
}
|
|
41
|
+
const parts = PROGRESS_ORDER.filter((tone) => (counts.get(tone) ?? 0) > 0);
|
|
42
|
+
if (parts.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
return (_jsx("div", { role: "img", "aria-label": parts.map((tone) => `${tone}: ${counts.get(tone)}`).join(", "), className: "mx-1 flex h-1.5 overflow-hidden rounded-pill bg-ink/8", children: parts.map((tone) => (_jsx("span", { className: PROGRESS_FILL[tone], style: { width: `${((counts.get(tone) ?? 0) / items.length) * 100}%` } }, tone))) }));
|
|
45
|
+
}
|
|
46
|
+
/* ==================================================================== *
|
|
47
|
+
* THE BOARD BY STAGES — Odoo's kanban.
|
|
48
|
+
*
|
|
49
|
+
* Context: a pipeline (quotations → orders → invoiced, leads → won) is read across: how many
|
|
50
|
+
* in each stage, and which is stuck. One column per stage, the count on its head, and a card
|
|
51
|
+
* moved by dragging it to another column — the gesture IS the change of stage.
|
|
52
|
+
*
|
|
53
|
+
* Dragging is not the only way: a focused card moves with Alt + ← / →, and the move is said
|
|
54
|
+
* out loud. A board that only drags is a board a keyboard cannot use.
|
|
55
|
+
* ==================================================================== */
|
|
56
|
+
export function BoardColumns({ items, keyOf, stages, card, }) {
|
|
57
|
+
const copy = useUiCopy();
|
|
58
|
+
const { of, onMove, nameOf, total, progress, onQuickCreate, onAddColumn } = stages;
|
|
59
|
+
const columns = stages.columns ??
|
|
60
|
+
[...new Set(items.map(of))].map((id) => ({ id, label: stages.labelOf?.(id) ?? id }));
|
|
61
|
+
const [folded, setFolded] = useState(() => new Set(stages.defaultFolded ?? []));
|
|
62
|
+
const [creating, setCreating] = useState(null);
|
|
63
|
+
const [adding, setAdding] = useState(false);
|
|
64
|
+
const [dragging, setDragging] = useState(null);
|
|
65
|
+
const [over, setOver] = useState(null);
|
|
66
|
+
const [said, setSaid] = useState("");
|
|
67
|
+
const byId = new Map(items.map((item) => [keyOf(item), item]));
|
|
68
|
+
const move = (id, to) => {
|
|
69
|
+
const item = byId.get(id);
|
|
70
|
+
if (item === undefined || of(item) === to || onMove === undefined)
|
|
71
|
+
return;
|
|
72
|
+
onMove(item, to);
|
|
73
|
+
const column = columns.find((one) => one.id === to)?.label ?? to;
|
|
74
|
+
setSaid(fillCopy(copy.board.moved, { name: nameOf?.(item) ?? id, column }));
|
|
75
|
+
};
|
|
76
|
+
const toggleFold = (id) => setFolded((current) => {
|
|
77
|
+
const next = new Set(current);
|
|
78
|
+
if (next.has(id))
|
|
79
|
+
next.delete(id);
|
|
80
|
+
else
|
|
81
|
+
next.add(id);
|
|
82
|
+
return next;
|
|
83
|
+
});
|
|
84
|
+
const dropOn = (id) => onMove === undefined
|
|
85
|
+
? {}
|
|
86
|
+
: {
|
|
87
|
+
onDragOver: (event) => {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
setOver(id);
|
|
90
|
+
},
|
|
91
|
+
onDragLeave: () => setOver(null),
|
|
92
|
+
onDrop: (event) => {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
move(event.dataTransfer.getData("text/plain"), id);
|
|
95
|
+
setOver(null);
|
|
96
|
+
setDragging(null);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const byKeyboard = (event, item) => {
|
|
100
|
+
if (!event.altKey || (event.key !== "ArrowLeft" && event.key !== "ArrowRight"))
|
|
101
|
+
return;
|
|
102
|
+
const at = columns.findIndex((one) => one.id === of(item));
|
|
103
|
+
const next = columns[at + (event.key === "ArrowLeft" ? -1 : 1)];
|
|
104
|
+
if (next === undefined)
|
|
105
|
+
return;
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
move(keyOf(item), next.id);
|
|
108
|
+
};
|
|
109
|
+
return (_jsxs("div", { className: "flex min-w-0 items-start gap-3 overflow-x-auto p-3", children: [columns.map((column) => {
|
|
110
|
+
const inside = items.filter((item) => of(item) === column.id);
|
|
111
|
+
if (folded.has(column.id)) {
|
|
112
|
+
/* Folded: a strip with its count and its name down the spine. Still a drop target —
|
|
113
|
+
dropping on «perdido» is how a lost deal is marked. */
|
|
114
|
+
return (_jsxs("section", { "aria-label": column.label, ...dropOn(column.id), className: `flex w-12 shrink-0 flex-col items-center gap-2 rounded-box border py-2 ${over === column.id ? "border-primary/50 bg-primary/6" : "border-line bg-ink/3"}`, children: [_jsx(IconButton, { icon: ChevronsLeftRight, label: fillCopy(copy.board.unfold, { column: column.label }), onClick: () => toggleFold(column.id) }), _jsx("span", { className: "text-micro text-muted tabular-nums", children: inside.length }), _jsx("span", { className: "text-vertical text-meta font-semibold text-ink", children: column.label })] }, column.id));
|
|
115
|
+
}
|
|
116
|
+
return (_jsxs("section", { "aria-label": column.label, ...dropOn(column.id), className: `flex w-(--container-lane) shrink-0 flex-col gap-2 rounded-box border p-2 transition-colors duration-(--duration-instant) ${over === column.id ? "border-primary/50 bg-primary/6" : "border-line bg-ink/3"}`, children: [_jsxs("header", { className: "flex min-w-0 items-center gap-1.5 px-1 py-0.5", children: [_jsx("h3", { className: "min-w-0 flex-1 truncate text-meta font-semibold text-ink", children: column.tone === undefined ? (column.label) : (_jsx(StatusDot, { tone: column.tone, label: column.label })) }), _jsx("span", { className: "shrink-0 text-micro text-muted tabular-nums", children: inside.length }), onQuickCreate === undefined ? null : (_jsx(IconButton, { icon: Plus, label: fillCopy(copy.board.quickCreate, { column: column.label }), onClick: () => setCreating(column.id) })), _jsx(IconButton, { icon: ChevronsLeftRight, label: fillCopy(copy.board.fold, { column: column.label }), onClick: () => toggleFold(column.id) })] }), total === undefined ? null : (_jsx("p", { className: "px-1 text-meta font-semibold text-ink tabular-nums", children: total(inside) })), progress === undefined || inside.length === 0 ? null : (_jsx(ProgressBar, { items: inside, progress: progress })), creating === column.id && onQuickCreate !== undefined ? (_jsx(BoardInput, { label: fillCopy(copy.board.quickCreate, { column: column.label }), placeholder: copy.board.quickPlaceholder, onCommit: (name) => onQuickCreate(column.id, name), onClose: () => setCreating(null) })) : null, inside.length === 0 ? (_jsx("p", { className: "px-1 py-3 text-micro text-muted", children: copy.board.emptyLane })) : (_jsx("ul", { className: "flex min-w-0 flex-col gap-2", children: inside.map((item) => {
|
|
117
|
+
const id = keyOf(item);
|
|
118
|
+
return (_jsx("li", { ...(onMove === undefined
|
|
119
|
+
? {}
|
|
120
|
+
: {
|
|
121
|
+
draggable: true,
|
|
122
|
+
tabIndex: 0,
|
|
123
|
+
title: copy.board.moveHint,
|
|
124
|
+
"aria-keyshortcuts": "Alt+ArrowLeft Alt+ArrowRight",
|
|
125
|
+
onDragStart: (event) => {
|
|
126
|
+
event.dataTransfer.setData("text/plain", id);
|
|
127
|
+
event.dataTransfer.effectAllowed = "move";
|
|
128
|
+
setDragging(id);
|
|
129
|
+
},
|
|
130
|
+
onDragEnd: () => setDragging(null),
|
|
131
|
+
onKeyDown: (event) => byKeyboard(event, item),
|
|
132
|
+
}), className: `flex min-w-0 flex-col rounded-box focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary ${onMove === undefined ? "" : "cursor-grab active:cursor-grabbing"} ${dragging === id ? "opacity-50" : ""}`, children: card(item) }, id));
|
|
133
|
+
}) }))] }, column.id));
|
|
134
|
+
}), onAddColumn === undefined ? null : (_jsx("div", { className: "flex w-(--container-lane) shrink-0 flex-col gap-2 p-2", children: adding ? (_jsx(BoardInput, { label: copy.board.addColumn, placeholder: copy.board.addColumnPlaceholder, onCommit: (label) => {
|
|
135
|
+
onAddColumn(label);
|
|
136
|
+
setAdding(false);
|
|
137
|
+
}, onClose: () => setAdding(false) })) : (_jsxs("button", { type: "button", onClick: () => setAdding(true), className: "flex h-(--size-control-md) items-center gap-1.5 rounded-control px-2 text-meta text-muted transition-colors duration-(--duration-instant) hover:bg-ink/5 hover:text-ink focus-visible:outline-2 focus-visible:outline-primary", children: [_jsx(Plus, { "aria-hidden": true, className: "size-(--size-icon-sm)", strokeWidth: 2 }), copy.board.addColumn] })) })), _jsx("span", { role: "status", className: "sr-only", children: said })] }));
|
|
138
|
+
}
|
|
@@ -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
|
-
|
|
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;
|
|
@@ -51,6 +83,11 @@ export declare function ListingBar({ actions, title, description, counters, sele
|
|
|
51
83
|
count: number;
|
|
52
84
|
onClear?: () => void;
|
|
53
85
|
actions?: ReactNode;
|
|
86
|
+
/** More match than are chosen: offer to choose them all. */
|
|
87
|
+
matching?: {
|
|
88
|
+
total: number;
|
|
89
|
+
onSelect: () => void;
|
|
90
|
+
};
|
|
54
91
|
};
|
|
55
92
|
search?: ReactNode;
|
|
56
93
|
pagination?: ReactNode;
|
|
@@ -62,6 +99,13 @@ export declare function ListingBar({ actions, title, description, counters, sele
|
|
|
62
99
|
/** `chrome` paints the whole band navy and everything inside inherits it:
|
|
63
100
|
* the search box, the pagination and the switcher. See `system/surface.tsx`. */
|
|
64
101
|
surface?: Surface;
|
|
102
|
+
/** One line, for a collection inside a record: search, count, views, actions. The
|
|
103
|
+
* title, the description, the counters and the crumbs are not drawn — the record
|
|
104
|
+
* around it already says them. Always on the canvas. See the header. */
|
|
105
|
+
inline?: boolean;
|
|
106
|
+
/** How many records there are, said on the inline line when there is no
|
|
107
|
+
* `pagination` to say it. Ignored by the two-row bar: its identity has counters. */
|
|
108
|
+
count?: number;
|
|
65
109
|
}): import("react").JSX.Element;
|
|
66
110
|
export type ListIdentity = {
|
|
67
111
|
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
|
-
|
|
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
|
|
@@ -56,11 +88,21 @@ export function ListingBar({ actions, title, description, counters, selection, s
|
|
|
56
88
|
Views used to sit next to the pagination. On a narrow window the search
|
|
57
89
|
field and «1–80 / 211» painted over each other; columns and the switcher
|
|
58
90
|
were the extra weight on a line that only needed how MANY. */
|
|
59
|
-
const center = selecting ? (_jsxs("div", { className: "flex min-w-0 items-center gap-x-2", children: [_jsxs("span", { className: `inline-flex h-(--size-control-md) shrink-0 items-center gap-1.5 rounded-control pr-1 pl-2.5 text-meta font-medium ${isChrome ? "bg-chrome-ink/14 text-chrome-ink" : "bg-ink/8 text-ink"}`, children: [_jsx("span", { className: "tabular-nums", children: selection?.count.toLocaleString(copy.locale) }), copy.selection.chosen, _jsx("button", { type: "button", onClick: selection?.onClear, "aria-label": copy.selection.clear, title: copy.selection.clear, className: `inline-flex size-5 items-center justify-center rounded-pill transition-colors duration-(--duration-instant) ${isChrome ? "hover:bg-chrome-ink/20" : "hover:bg-primary/20"}`, children: _jsx(X, { className: "size-3", strokeWidth: 2.5 }) })] }), selection?.
|
|
91
|
+
const center = selecting ? (_jsxs("div", { className: "flex min-w-0 items-center gap-x-2", children: [_jsxs("span", { className: `inline-flex h-(--size-control-md) shrink-0 items-center gap-1.5 rounded-control pr-1 pl-2.5 text-meta font-medium ${isChrome ? "bg-chrome-ink/14 text-chrome-ink" : "bg-ink/8 text-ink"}`, children: [_jsx("span", { className: "tabular-nums", children: selection?.count.toLocaleString(copy.locale) }), copy.selection.chosen, _jsx("button", { type: "button", onClick: selection?.onClear, "aria-label": copy.selection.clear, title: copy.selection.clear, className: `inline-flex size-5 items-center justify-center rounded-pill transition-colors duration-(--duration-instant) ${isChrome ? "hover:bg-chrome-ink/20" : "hover:bg-primary/20"}`, children: _jsx(X, { className: "size-3", strokeWidth: 2.5 }) })] }), selection?.matching === undefined ||
|
|
92
|
+
selection.matching.total <= selection.count ? null : (_jsx("button", { type: "button", onClick: selection.matching.onSelect, className: `shrink-0 rounded-control px-1 text-meta font-medium underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${isChrome ? "text-chrome-link" : "text-primary"}`, children: fillCopy(copy.selection.allMatching, {
|
|
93
|
+
count: selection.matching.total.toLocaleString(copy.locale),
|
|
94
|
+
}) })), selection?.actions === undefined ? null : (_jsx("div", { className: "flex min-w-0 items-center gap-1.5", children: selection.actions }))] })) : search === undefined ? null : (
|
|
60
95
|
/* Selecting replaces the search box: what gets selected is over what is
|
|
61
96
|
ALREADY filtered, so searching there would change the set underneath the
|
|
62
97
|
selection. */
|
|
63
98
|
_jsx("div", { className: "w-full min-w-0", children: search }));
|
|
99
|
+
if (inline) {
|
|
100
|
+
const howMany = pagination ??
|
|
101
|
+
(count === undefined ? null : (_jsx("span", { role: "status", className: "shrink-0 text-meta text-muted tabular-nums", children: count === 1
|
|
102
|
+
? copy.list.countOne
|
|
103
|
+
: fillCopy(copy.list.count, { count: count.toLocaleString(copy.locale) }) })));
|
|
104
|
+
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: [title === undefined ? null : (_jsx("h3", { className: "min-w-0 shrink-0 truncate text-body font-semibold text-ink", children: title })), 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 }))] }) }));
|
|
105
|
+
}
|
|
64
106
|
/* El campo de búsqueda va en el CENTRO de la línea, y centrado contra la
|
|
65
107
|
línea: pide `--container-search` y los dos lados se reparten lo que sobra
|
|
66
108
|
en mitades iguales. Antes tomaba el resto, así que un rastro largo a la
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { GroupableField, GroupLevel, PileOf } from "../../foundations/grouping.js";
|
|
2
|
+
import { type PivotFold, type PivotKey } from "../../foundations/pivot.js";
|
|
3
|
+
export type PivotExplorerProps<T> = {
|
|
4
|
+
items: readonly T[];
|
|
5
|
+
/** Which pile a record falls in, at a level — the same contract that groups a list. */
|
|
6
|
+
of: PileOf<T>;
|
|
7
|
+
/** What can be split by: a field, and its grains when it is a date. */
|
|
8
|
+
fields: readonly GroupableField[];
|
|
9
|
+
/** Every measure that can be shown; `defaultMeasures` says which start on. */
|
|
10
|
+
measures: readonly PivotFold<T>[];
|
|
11
|
+
defaultMeasures?: readonly string[];
|
|
12
|
+
/** The rows' levels, CONTROLLED from outside — the search bar's grouping. While given, the
|
|
13
|
+
* rows follow it and are not edited here. */
|
|
14
|
+
rows?: readonly GroupLevel[];
|
|
15
|
+
defaultRows?: readonly GroupLevel[];
|
|
16
|
+
defaultColumns?: readonly GroupLevel[];
|
|
17
|
+
/** A cell was pressed: open the records that fell there. */
|
|
18
|
+
onCell?: (row: PivotKey, column: PivotKey) => void;
|
|
19
|
+
height?: number | string;
|
|
20
|
+
/** The file's name when downloaded. */
|
|
21
|
+
fileName?: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A pivot people actually pivot: what goes down the rows, what goes across the columns, which
|
|
25
|
+
* measures — chosen on the grid, not fixed in code.
|
|
26
|
+
*
|
|
27
|
+
* Context: the fold (`pivotFrom`) was already a real pivot — N levels each way, margins folded
|
|
28
|
+
* from the base — but its axes and measures were written once by whoever composed it. Here
|
|
29
|
+
* they are the person's: «+» adds a field (a date with its grain) to an axis, a level's ×
|
|
30
|
+
* drops it, ⇄ swaps the axes, the measures are ticked, and the grid downloads as it is seen.
|
|
31
|
+
* Inside a list the rows FOLLOW the search bar's grouping and the records are the ones the
|
|
32
|
+
* search left: the pivot answers the same question the list is asking.
|
|
33
|
+
*/
|
|
34
|
+
export declare function PivotExplorer<T>({ items, of, fields, measures, defaultMeasures, rows: outerRows, defaultRows, defaultColumns, onCell, height, fileName, }: PivotExplorerProps<T>): import("react").JSX.Element;
|