@sincpro/web-ui 0.2.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/components/activity/ActivityBadge.d.ts +16 -0
- package/dist/components/activity/ActivityBadge.js +29 -0
- package/dist/components/activity/index.d.ts +1 -0
- package/dist/components/activity/index.js +1 -0
- 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.js +5 -1
- 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 +3 -0
- package/dist/components/index.js +3 -0
- package/dist/components/lines/LineItems.d.ts +5 -2
- package/dist/components/lines/LineItems.js +3 -3
- package/dist/components/listing/BoardColumns.d.ts +39 -0
- package/dist/components/listing/BoardColumns.js +138 -0
- package/dist/components/listing/ListingBar.d.ts +5 -0
- package/dist/components/listing/ListingBar.js +5 -2
- 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 +21 -2
- package/dist/components/listing/Row.js +29 -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 +7 -1
- package/dist/components/record/RecordHeader.js +30 -18
- package/dist/components/record/RecordRibbon.d.ts +13 -0
- package/dist/components/record/RecordRibbon.js +22 -0
- 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 +15 -1
- package/dist/components/search/SearchBar.js +34 -16
- package/dist/components/search/query.d.ts +14 -11
- package/dist/components/search/query.js +36 -3
- 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 +110 -14
- package/dist/foundations/copy.es.js +85 -9
- package/dist/foundations/copy.js +85 -9
- package/dist/foundations/grouping.d.ts +3 -0
- 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.js +8 -4
- 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/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/dialog/Dialog.d.ts +9 -3
- package/dist/primitives/dialog/Dialog.js +41 -5
- package/dist/primitives/dialog/index.d.ts +0 -1
- package/dist/primitives/dialog/index.js +0 -2
- package/dist/primitives/notice/index.d.ts +1 -1
- package/dist/primitives/notice/index.js +1 -1
- package/dist/primitives/overlay/Preview.js +13 -4
- package/dist/primitives/picker/MatchedText.d.ts +10 -0
- package/dist/primitives/picker/MatchedText.js +12 -0
- package/dist/primitives/picker/Picker.d.ts +42 -7
- package/dist/primitives/picker/Picker.js +107 -29
- package/dist/primitives/table/EditableTable.d.ts +6 -1
- package/dist/primitives/table/EditableTable.js +70 -9
- package/dist/primitives/table/ReportTable.js +2 -1
- package/dist/primitives/table/RowActions.js +7 -2
- package/dist/primitives/table/Table.d.ts +5 -0
- package/dist/primitives/table/Table.js +17 -8
- package/dist/primitives/tag/Tags.d.ts +2 -0
- package/dist/primitives/tag/Tags.js +2 -1
- package/dist/primitives/tag/TagsField.d.ts +17 -0
- package/dist/primitives/tag/TagsField.js +46 -11
- package/dist/primitives/tag/choices.d.ts +6 -1
- package/dist/primitives/tag/choices.js +6 -2
- package/dist/primitives/value/Avatar.d.ts +16 -0
- package/dist/primitives/value/Avatar.js +91 -0
- package/dist/primitives/value/Person.d.ts +6 -51
- package/dist/primitives/value/Person.js +9 -148
- package/dist/primitives/value/Relation.d.ts +21 -9
- package/dist/primitives/value/Relation.js +35 -24
- package/dist/primitives/value/contact.d.ts +30 -0
- package/dist/primitives/value/contact.js +51 -0
- package/dist/primitives/value/index.d.ts +1 -1
- package/dist/primitives/value/index.js +1 -1
- package/dist/tokens.css +136 -1
- package/dist/views/list/List.d.ts +25 -2
- package/dist/views/list/List.js +48 -8
- package/dist/views/list/ListView.d.ts +1 -1
- package/dist/views/list/ListView.js +40 -9
- package/dist/views/list/columns.js +8 -1
- package/dist/views/list/presentations.d.ts +5 -2
- package/dist/views/list/presentations.js +32 -2
- package/dist/views/list/types.d.ts +44 -0
- package/dist/views/list/useNarrowList.d.ts +10 -0
- package/dist/views/list/useNarrowList.js +20 -6
- package/dist/views/record/Record.d.ts +14 -0
- package/dist/views/record/Record.js +22 -7
- package/package.json +11 -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/primitives/dialog/Drill.d.ts +0 -90
- package/dist/primitives/dialog/Drill.js +0 -172
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OpenTarget } from "../../foundations/types.js";
|
|
1
2
|
import { type FormMode } from "../field/mode.js";
|
|
2
3
|
import type { LucideIcon } from "lucide-react";
|
|
3
4
|
import { type ReactNode } from "react";
|
|
@@ -21,6 +22,13 @@ export interface PickerItem {
|
|
|
21
22
|
/** The header this item is listed under. Consecutive items with the same section share
|
|
22
23
|
* one header; an item without it is listed loose, outside any section. */
|
|
23
24
|
section?: string;
|
|
25
|
+
/** A person or a company: its face before the name — the photo, or its initials when
|
|
26
|
+
* `true`. Who is being chosen is recognised before it is read, in the list and in the field. */
|
|
27
|
+
avatar?: true | string;
|
|
28
|
+
/** What is read at a glance on the right, in tabular figures: the balance owed, the stock,
|
|
29
|
+
* a state badge, a priority — a widget, not text. Where `meta` describes, this COMPARES:
|
|
30
|
+
* lined up in one column, eight customers' debts are read top to bottom. */
|
|
31
|
+
aside?: ReactNode;
|
|
24
32
|
}
|
|
25
33
|
/**
|
|
26
34
|
* What the list shows and how many are left out of it. Capped at 8 with the footer count,
|
|
@@ -39,13 +47,38 @@ export type PickerProps = {
|
|
|
39
47
|
items: readonly PickerItem[];
|
|
40
48
|
value?: string;
|
|
41
49
|
onChange?: (id: string) => void;
|
|
50
|
+
/**
|
|
51
|
+
* The options are SEARCHED by whoever gives them — the server's literal search — and this
|
|
52
|
+
* hears what is typed (and `""` when the list starts over). With it the picker filters
|
|
53
|
+
* nothing itself and caps nothing: `items` is the answer, all of it drawn, with the typed
|
|
54
|
+
* text still marked inside it. Without it, the picker filters what it holds.
|
|
55
|
+
*
|
|
56
|
+
* Context: a picker over ten thousand customers holds the first ten. Filtering THOSE finds
|
|
57
|
+
* nothing past the tenth — the customer typed by its exact name was «not there».
|
|
58
|
+
*/
|
|
59
|
+
onSearch?: (query: string) => void;
|
|
60
|
+
/** The chosen record's name when it is not among `items` — a search narrowed them, or it
|
|
61
|
+
* lies past the first page. Without it, a chosen record that is not listed reads empty. */
|
|
62
|
+
valueLabel?: string;
|
|
42
63
|
placeholder?: string;
|
|
43
|
-
/**
|
|
44
|
-
*
|
|
45
|
-
|
|
64
|
+
/** «Buscar más…»: the last option of the list, always there when this is — the record
|
|
65
|
+
* may not be among the eight, or two of them may need their columns to tell apart. It
|
|
66
|
+
* gets what was typed, so the list that chooses (`List onChoose`, in a modal of the
|
|
67
|
+
* workspace) opens on the same search. */
|
|
68
|
+
onBrowseAll?: (query: string) => void;
|
|
69
|
+
/** «Crear «…»»: a record made from the typed text alone (Odoo's `name_create`); it returns
|
|
70
|
+
* the new id — or a promise of it — and the field chooses it. Offered while something is
|
|
71
|
+
* typed that no option is called exactly. The consumer appends the new item to `items`. */
|
|
72
|
+
onCreate?: (name: string) => string | Promise<string>;
|
|
73
|
+
/** «Crear y editar…»: the new record needs more than a name — open its form (in a workspace,
|
|
74
|
+
* a modal) with the typed text as the name. Choosing it there is the consumer's. */
|
|
75
|
+
onCreateAndEdit?: (name: string) => void;
|
|
46
76
|
/** Open the chosen record. Drawn as a trailing affordance, the way a many2one
|
|
47
77
|
* offers "open" next to "pick". Absent when there is nothing chosen. */
|
|
48
|
-
onOpen?: (id: string) => void;
|
|
78
|
+
onOpen?: (id: string, to: OpenTarget) => void;
|
|
79
|
+
/** Where the chosen record can open, the first being the button's (a modal, by default: the
|
|
80
|
+
* form stays behind); the rest go in «Abrir en…». `onOpen` is told which was picked. */
|
|
81
|
+
openTargets?: readonly OpenTarget[];
|
|
49
82
|
emptyText?: string;
|
|
50
83
|
/** Placeholder of the field while it is being typed into. It is NOT a second
|
|
51
84
|
* box — the field itself is the search. */
|
|
@@ -56,7 +89,7 @@ export type PickerProps = {
|
|
|
56
89
|
loadingText?: string;
|
|
57
90
|
/** The list grows from the backend: called when its end scrolls into view. The consumer
|
|
58
91
|
* appends the next page to `items`; with nothing more to fetch it does nothing. Turns off
|
|
59
|
-
* the cap of 8
|
|
92
|
+
* the cap of 8 and the footer count. */
|
|
60
93
|
onLoadMore?: () => void;
|
|
61
94
|
/** A page asked for by `onLoadMore` is on its way: a quiet row at the end says so. Unlike
|
|
62
95
|
* `loading`, the rows already there stay. */
|
|
@@ -72,15 +105,17 @@ export type PickerProps = {
|
|
|
72
105
|
};
|
|
73
106
|
export declare function Picker({ mode, ...props }: PickerProps): import("react").JSX.Element;
|
|
74
107
|
/**
|
|
75
|
-
* The list of a `Picker`: its rows, the section headers, the «
|
|
108
|
+
* The list of a `Picker`: its rows, the section headers, the «Buscar más…» row and the loading
|
|
76
109
|
* row at the end. Internal — not exported from the family — and apart from the combobox so it
|
|
77
110
|
* can be rendered on the server without a popover, which is how the tests see the rows.
|
|
78
111
|
*/
|
|
79
|
-
export declare function PickerOptions({ items, marks, hints, remaining, emptyText, onLoadMore, loadingMore, "aria-label": ariaLabel, }: {
|
|
112
|
+
export declare function PickerOptions({ items, marks, hints, remaining, query, emptyText, onLoadMore, loadingMore, "aria-label": ariaLabel, }: {
|
|
80
113
|
items: readonly PickerItem[];
|
|
81
114
|
marks: boolean;
|
|
82
115
|
hints: boolean;
|
|
83
116
|
remaining: number;
|
|
117
|
+
/** What was typed: it is marked in every row, so each row says why it is there. */
|
|
118
|
+
query?: string;
|
|
84
119
|
emptyText: string;
|
|
85
120
|
onLoadMore?: () => void;
|
|
86
121
|
loadingMore?: boolean;
|
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { AriaLocale } from "../../foundations/aria.js";
|
|
4
4
|
import { fillCopy, useUiCopy } from "../../foundations/copy.js";
|
|
5
|
+
import { matchedWord, matches } from "../../foundations/match.js";
|
|
5
6
|
import { cn, tv } from "../../foundations/variants.js";
|
|
6
7
|
import { IconButton } from "../button/Button.js";
|
|
7
8
|
import { FieldButton, FieldGroup } from "../field/aria.js";
|
|
8
9
|
import { useFieldIds } from "../field/context.js";
|
|
9
10
|
import { ReadValue } from "../field/Field.js";
|
|
10
11
|
import { useFormMode } from "../field/mode.js";
|
|
12
|
+
import { Menu } from "../menu/Menu.js";
|
|
11
13
|
import { Popover } from "../overlay/Popover.js";
|
|
12
|
-
import {
|
|
14
|
+
import { MatchedText } from "./MatchedText.js";
|
|
15
|
+
import { Avatar } from "../value/Person.js";
|
|
16
|
+
import { AppWindow, Check, ChevronDown, Ellipsis, Eye, PanelRight, PictureInPicture2, SquareArrowOutUpRight, } from "lucide-react";
|
|
13
17
|
import { useContext, useMemo, useRef, useState } from "react";
|
|
14
18
|
import { Collection, ComboBox as AriaComboBox, ComboBoxStateContext, Header, Input, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, Text, useFilter, } from "react-aria-components";
|
|
15
|
-
/** The
|
|
19
|
+
/** The icon of each place a record opens — the same in every field and list. */
|
|
20
|
+
const OPEN_ICON = {
|
|
21
|
+
modal: PictureInPicture2,
|
|
22
|
+
peek: Eye,
|
|
23
|
+
page: SquareArrowOutUpRight,
|
|
24
|
+
side: PanelRight,
|
|
25
|
+
window: AppWindow,
|
|
26
|
+
};
|
|
27
|
+
/** The id of the «Buscar más…» row. Not a record id: it cannot collide with one. */
|
|
16
28
|
const BROWSE_ALL = "\u0000browse-all";
|
|
29
|
+
/** The rows that create from what was typed — quick, or with the full form. */
|
|
30
|
+
const CREATE = "\u0000create";
|
|
31
|
+
const CREATE_EDIT = "\u0000create-edit";
|
|
32
|
+
/** The rows that act instead of choosing: after the records, in this order. */
|
|
33
|
+
const ACTIONS = new Set([CREATE, CREATE_EDIT, BROWSE_ALL]);
|
|
17
34
|
const CAP = 8;
|
|
18
35
|
/**
|
|
19
36
|
* What the list shows and how many are left out of it. Capped at 8 with the footer count,
|
|
@@ -29,7 +46,7 @@ export function shownRows(filtered, { loading, incremental }) {
|
|
|
29
46
|
return { visible, remaining: filtered.length - visible.length };
|
|
30
47
|
}
|
|
31
48
|
/** Folds consecutive items of the same `section` into one run under a header. The ids of
|
|
32
|
-
* the runs cannot collide with a record id, like the «
|
|
49
|
+
* the runs cannot collide with a record id, like the «Buscar más…» one. */
|
|
33
50
|
function sectioned(items) {
|
|
34
51
|
const out = [];
|
|
35
52
|
for (const item of items) {
|
|
@@ -74,13 +91,14 @@ const option = tv({
|
|
|
74
91
|
});
|
|
75
92
|
export function Picker({ mode, ...props }) {
|
|
76
93
|
if (useFormMode(mode) === "read") {
|
|
77
|
-
|
|
94
|
+
const name = props.items.find((i) => i.id === props.value)?.name ?? props.valueLabel;
|
|
95
|
+
return _jsx(ReadValue, { value: props.value === undefined ? null : (name ?? null) });
|
|
78
96
|
}
|
|
79
97
|
/* The filter reads the locale from React Aria's context, so the box that provides it has
|
|
80
98
|
to be ABOVE the component that asks for it. */
|
|
81
99
|
return (_jsx(AriaLocale, { children: _jsx(EditablePicker, { ...props }) }));
|
|
82
100
|
}
|
|
83
|
-
function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrowseAll, onOpen, emptyText, searchPlaceholder, loading = false, loadingText, onLoadMore, loadingMore = false, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, "aria-label": ariaLabel, }) {
|
|
101
|
+
function EditablePicker({ id: ownId, items, value, onChange, onSearch, valueLabel, placeholder, onBrowseAll, onCreate, onCreateAndEdit, onOpen, openTargets = ["modal", "peek", "side", "page"], emptyText, searchPlaceholder, loading = false, loadingText, onLoadMore, loadingMore = false, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, "aria-label": ariaLabel, }) {
|
|
84
102
|
const { id, labelledBy, describedBy, invalid } = useFieldIds({
|
|
85
103
|
id: ownId,
|
|
86
104
|
describedBy: ownDescribedBy,
|
|
@@ -93,50 +111,86 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
|
|
|
93
111
|
const shownSearch = searchPlaceholder ?? copy.picker.search;
|
|
94
112
|
const shownLoading = loadingText ?? copy.picker.loading;
|
|
95
113
|
const chosen = items.find((i) => i.id === value);
|
|
96
|
-
const chosenName = chosen?.name ?? "";
|
|
114
|
+
const chosenName = chosen?.name ?? (value === undefined ? "" : (valueLabel ?? ""));
|
|
97
115
|
/* Both the text and the choice are controlled, so React Aria never rewrites the field
|
|
98
116
|
from its own collection — which is capped and filtered, and may not hold the chosen
|
|
99
117
|
record at all. `query` is what filters: empty when the list opens (the whole list,
|
|
100
118
|
not just the one already chosen), the typed text after that. */
|
|
101
119
|
const [open, setOpen] = useState(false);
|
|
102
120
|
const [text, setText] = useState(chosenName);
|
|
103
|
-
const [query,
|
|
121
|
+
const [query, setQueryOnly] = useState("");
|
|
122
|
+
/* What the consumer last heard: a list that opens, closes or chooses starts the search over,
|
|
123
|
+
and telling it `""` three times would ask the server three times for the same page. */
|
|
124
|
+
const heard = useRef("");
|
|
125
|
+
const setQuery = (next) => {
|
|
126
|
+
setQueryOnly(next);
|
|
127
|
+
if (onSearch !== undefined && next !== heard.current) {
|
|
128
|
+
heard.current = next;
|
|
129
|
+
onSearch(next);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
104
132
|
const [shownFor, setShownFor] = useState(chosenName);
|
|
105
133
|
if (!open && shownFor !== chosenName) {
|
|
106
134
|
setShownFor(chosenName);
|
|
107
135
|
setText(chosenName);
|
|
108
136
|
}
|
|
137
|
+
const searched = onSearch !== undefined;
|
|
109
138
|
const filtered = useMemo(() => {
|
|
110
139
|
const q = query.trim();
|
|
111
|
-
if (q === "")
|
|
140
|
+
if (q === "" || searched)
|
|
112
141
|
return items;
|
|
113
142
|
return items.filter((i) => contains(`${i.name} ${i.meta ?? ""} ${i.haystack ?? ""}`, q));
|
|
114
|
-
}, [items, query, contains]);
|
|
143
|
+
}, [items, query, contains, searched]);
|
|
144
|
+
/* Searched by the consumer, the items ARE the page it chose to show: all of them. */
|
|
115
145
|
const { visible, remaining } = shownRows(filtered, {
|
|
116
146
|
loading,
|
|
117
|
-
incremental: onLoadMore !== undefined,
|
|
147
|
+
incremental: onLoadMore !== undefined || searched,
|
|
118
148
|
});
|
|
119
149
|
const hasMarks = items.some((i) => i.icon !== undefined);
|
|
120
150
|
const hasHints = visible.some((i) => i.meta !== undefined);
|
|
121
151
|
/* «See all…» is an OPTION at the end of the list and not a button in a footer: the
|
|
122
152
|
arrows reach it like any other row. As a footer button it was out of the keyboard's
|
|
123
153
|
path — Tab leaves the field and closes the list before getting there. */
|
|
124
|
-
const browsable =
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
154
|
+
const browsable = onBrowseAll !== undefined;
|
|
155
|
+
const typed = query.trim();
|
|
156
|
+
/* Exactly the name of an option already: creating it again would make a twin. */
|
|
157
|
+
const named = typed !== "" &&
|
|
158
|
+
items.some((i) => i.name.localeCompare(typed, copy.locale, { sensitivity: "base" }) === 0);
|
|
159
|
+
const actions = loading
|
|
160
|
+
? []
|
|
161
|
+
: [
|
|
162
|
+
...(onCreate !== undefined && typed !== "" && !named
|
|
163
|
+
? [{ id: CREATE, name: fillCopy(copy.picker.create, { name: typed }) }]
|
|
164
|
+
: []),
|
|
165
|
+
...(onCreateAndEdit === undefined
|
|
166
|
+
? []
|
|
167
|
+
: [{ id: CREATE_EDIT, name: copy.picker.createAndEdit }]),
|
|
168
|
+
...(browsable ? [{ id: BROWSE_ALL, name: copy.picker.browseAll }] : []),
|
|
169
|
+
];
|
|
170
|
+
const listed = [...visible, ...actions];
|
|
128
171
|
const closeList = useRef(null);
|
|
129
172
|
const take = (key) => {
|
|
130
173
|
if (key === null)
|
|
131
174
|
return;
|
|
132
175
|
const next = String(key);
|
|
133
|
-
if (next
|
|
134
|
-
/* The full list opens somewhere else (a dialog, a panel):
|
|
135
|
-
field keeps the chosen name.
|
|
176
|
+
if (ACTIONS.has(next)) {
|
|
177
|
+
/* The full list, or the new record's form, opens somewhere else (a dialog, a panel):
|
|
178
|
+
this one closes and the field keeps the chosen name. A quick create chooses the new
|
|
179
|
+
record once it exists. */
|
|
180
|
+
const asked = query;
|
|
136
181
|
closeList.current?.();
|
|
137
182
|
setText(chosenName);
|
|
138
183
|
setQuery("");
|
|
139
|
-
|
|
184
|
+
if (next === BROWSE_ALL)
|
|
185
|
+
onBrowseAll?.(asked);
|
|
186
|
+
if (next === CREATE_EDIT)
|
|
187
|
+
onCreateAndEdit?.(asked.trim());
|
|
188
|
+
if (next === CREATE && onCreate !== undefined) {
|
|
189
|
+
/* The field keeps the new name from now on, not the old one until the record exists:
|
|
190
|
+
a controlled text that changes while the input has focus reopens the list. */
|
|
191
|
+
setText(asked.trim());
|
|
192
|
+
void Promise.resolve(onCreate(asked.trim())).then((created) => onChange?.(created));
|
|
193
|
+
}
|
|
140
194
|
return;
|
|
141
195
|
}
|
|
142
196
|
setText(items.find((i) => i.id === next)?.name ?? "");
|
|
@@ -160,43 +214,67 @@ function EditablePicker({ id: ownId, items, value, onChange, placeholder, onBrow
|
|
|
160
214
|
the options are the answer before the first letter. */
|
|
161
215
|
menuTrigger: "focus",
|
|
162
216
|
/* An empty list stays open to SAY it is empty (or loading) instead of vanishing. */
|
|
163
|
-
allowsEmptyCollection: true, isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: [_jsx(CloseBridge, { into: closeList }), _jsxs(FieldGroup, { children: [_jsx(Input, { placeholder: loading ? shownLoading : open ? shownSearch : shownPlaceholder, title: shownSearch,
|
|
217
|
+
allowsEmptyCollection: true, isInvalid: invalid, isDisabled: disabled, className: "w-full min-w-0", children: [_jsx(CloseBridge, { into: closeList }), _jsxs(FieldGroup, { children: [chosen?.avatar === undefined || open ? null : (_jsx("span", { className: "flex shrink-0 items-center pl-2", children: _jsx(Avatar, { name: chosen.name, size: "sm", ...(typeof chosen.avatar === "string" ? { src: chosen.avatar } : {}) }) })), _jsx(Input, { placeholder: loading ? shownLoading : open ? shownSearch : shownPlaceholder, title: shownSearch,
|
|
164
218
|
/* Arriving selects the name, so the first letter typed replaces it. Only when
|
|
165
219
|
ARRIVING: React Aria hands the focus back to the input while the list is open,
|
|
166
220
|
and selecting again then would swallow every second letter. */
|
|
167
221
|
onFocus: (event) => {
|
|
168
222
|
if (!open)
|
|
169
223
|
event.currentTarget.select();
|
|
170
|
-
}, className: "h-full min-w-0 flex-1 bg-transparent px-2.5 text-meta text-ink outline-0 placeholder:text-muted disabled:cursor-not-allowed" }), value !== undefined &&
|
|
224
|
+
}, className: "h-full min-w-0 flex-1 bg-transparent px-2.5 text-meta text-ink outline-0 placeholder:text-muted disabled:cursor-not-allowed" }), value !== undefined &&
|
|
225
|
+
value !== "" &&
|
|
226
|
+
onOpen !== undefined &&
|
|
227
|
+
openTargets[0] !== undefined ? (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: OPEN_ICON[openTargets[0]], label: copy.picker.openIn[openTargets[0]], size: "sm", disabled: disabled, onClick: () => onOpen(value, openTargets[0]) }), openTargets.length < 2 || disabled ? null : (_jsx(Menu, { label: copy.picker.openMore, icon: Ellipsis, iconOnly: true, chevron: false, variant: "text", align: "end", groups: [
|
|
228
|
+
{
|
|
229
|
+
options: openTargets.slice(1).map((to) => ({
|
|
230
|
+
id: to,
|
|
231
|
+
label: copy.picker.openIn[to],
|
|
232
|
+
icon: OPEN_ICON[to],
|
|
233
|
+
})),
|
|
234
|
+
},
|
|
235
|
+
], onSelect: (to) => onOpen(value, to) }))] })) : null, _jsx(FieldButton, { className: "mr-1", children: _jsx(ChevronDown, { "aria-hidden": true, className: "size-(--size-icon-md)", strokeWidth: 2 }) })] }), _jsxs(Popover, { placement: "bottom start", className: "flex w-(--trigger-width) flex-col overflow-hidden", children: [_jsx(PickerOptions, { items: listed, marks: hasMarks, hints: hasHints, remaining: remaining, query: query, emptyText: loading ? shownLoading : shownEmpty,
|
|
171
236
|
/* Not while the first page loads: the sentinel of an empty list is in view and
|
|
172
237
|
would ask for page two before page one arrived. */
|
|
173
238
|
onLoadMore: loading ? undefined : onLoadMore, loadingMore: loadingMore }), remaining > 0 && !browsable ? (_jsxs("div", { className: "flex shrink-0 items-center gap-2 border-t border-line bg-ground px-2.5 py-1.5", children: [_jsx("span", { className: "text-micro tabular-nums text-muted", children: fillCopy(copy.picker.more, { count: remaining.toLocaleString(copy.locale) }) }), _jsx("span", { className: "text-micro text-muted", children: copy.picker.refine })] })) : null] })] }));
|
|
174
239
|
}
|
|
175
240
|
/**
|
|
176
|
-
* The list of a `Picker`: its rows, the section headers, the «
|
|
241
|
+
* The list of a `Picker`: its rows, the section headers, the «Buscar más…» row and the loading
|
|
177
242
|
* row at the end. Internal — not exported from the family — and apart from the combobox so it
|
|
178
243
|
* can be rendered on the server without a popover, which is how the tests see the rows.
|
|
179
244
|
*/
|
|
180
|
-
export function PickerOptions({ items, marks, hints, remaining, emptyText, onLoadMore, loadingMore = false, "aria-label": ariaLabel, }) {
|
|
245
|
+
export function PickerOptions({ items, marks, hints, remaining, query = "", emptyText, onLoadMore, loadingMore = false, "aria-label": ariaLabel, }) {
|
|
181
246
|
const copy = useUiCopy();
|
|
182
247
|
const entries = useMemo(() => sectioned(items), [items]);
|
|
183
|
-
|
|
248
|
+
/* The actions are one block under the records: one hairline above the first of them. */
|
|
249
|
+
const firstAction = items.find((i) => ACTIONS.has(i.id))?.id;
|
|
250
|
+
const renderItem = (item) => ACTIONS.has(item.id) ? (_jsx(ListBoxItem, { id: item.id, textValue: item.name, className: (state) => cn(option({ ...state, hints: false }), "text-primary", item.id === firstAction && items[0]?.id !== item.id
|
|
251
|
+
? "mt-1 border-t border-line"
|
|
252
|
+
: ""), children: _jsxs("span", { className: "flex w-full items-center justify-between gap-2", children: [_jsx("span", { className: "min-w-0 truncate font-medium", children: item.name }), item.id !== BROWSE_ALL || remaining === 0 ? null : (_jsx("span", { className: "text-micro tabular-nums text-muted", children: fillCopy(copy.picker.more, {
|
|
184
253
|
count: remaining.toLocaleString(copy.locale),
|
|
185
|
-
}) })] }) })) : (_jsx(ListBoxItem, { id: item.id, textValue: item.name, isDisabled: item.disabled, className: (state) => option({ ...state, hints }), children: ({ isSelected }) => (_jsx(Row, { item: item, selected: isSelected, marks: marks, hints: hints })) }));
|
|
254
|
+
}) }))] }) })) : (_jsx(ListBoxItem, { id: item.id, textValue: item.name, isDisabled: item.disabled, className: (state) => option({ ...state, hints }), children: ({ isSelected }) => (_jsx(Row, { item: item, selected: isSelected, marks: marks, hints: hints, query: query, locale: copy.locale })) }));
|
|
186
255
|
return (_jsxs(ListBox, { "aria-label": ariaLabel,
|
|
187
256
|
/* Nothing matched YET while the next page is coming: «nothing matches» above «loading
|
|
188
257
|
more…» says two contradicting things, so the empty list says only the second. */
|
|
189
258
|
renderEmptyState: () => (_jsx("p", { className: "px-3 py-3 text-center text-meta text-muted", children: loadingMore ? copy.picker.loadingMore : emptyText })),
|
|
190
259
|
/* Incremental, the list is not capped: it scrolls inside its own height and the
|
|
191
260
|
sentinel at its end is what asks for more. */
|
|
192
|
-
className: cn("min-h-0 flex-1 overflow-y-auto p-1 outline-0", onLoadMore === undefined ? "" : "max-h-(--size-dropdown)"), children: [_jsx(Collection, { items: entries, children: (entry) => "items" in entry ? (_jsxs(ListBoxSection, { id: entry.id, className: "not-first:mt-1 not-first:border-t not-first:border-line not-first:pt-1", children: [_jsxs(Header, { className: "flex items-center gap-2 px-2 pt-1 pb-0.5 text-micro font-medium text-muted", children: [marks ? (_jsx("span", { className: "size-(--size-icon-sm) shrink-0", "aria-hidden": true })) : null, entry.section] }), _jsx(Collection, { items: entry.items, children: renderItem })] })) : (renderItem(entry)) }), onLoadMore === undefined ? null : (_jsx(ListBoxLoadMoreItem, { onLoadMore: onLoadMore, isLoading: loadingMore && items.length > 0, className: "px-3 py-1.5 text-center text-micro text-muted", children: copy.picker.loadingMore }))] }));
|
|
261
|
+
className: cn("min-h-0 flex-1 overflow-y-auto p-1 outline-0", onLoadMore === undefined ? "" : "max-h-(--size-dropdown)"), children: [_jsx(Collection, { items: entries, dependencies: [query], children: (entry) => "items" in entry ? (_jsxs(ListBoxSection, { id: entry.id, className: "not-first:mt-1 not-first:border-t not-first:border-line not-first:pt-1", children: [_jsxs(Header, { className: "flex items-center gap-2 px-2 pt-1 pb-0.5 text-micro font-medium text-muted", children: [marks ? (_jsx("span", { className: "size-(--size-icon-sm) shrink-0", "aria-hidden": true })) : null, entry.section] }), _jsx(Collection, { items: entry.items, dependencies: [query], children: renderItem })] })) : (renderItem(entry)) }), onLoadMore === undefined ? null : (_jsx(ListBoxLoadMoreItem, { onLoadMore: onLoadMore, isLoading: loadingMore && items.length > 0, className: "px-3 py-1.5 text-center text-micro text-muted", children: copy.picker.loadingMore }))] }));
|
|
193
262
|
}
|
|
194
|
-
function Row({ item, selected, marks, hints, }) {
|
|
263
|
+
function Row({ item, selected, marks, hints, query, locale, }) {
|
|
195
264
|
const Icon = item.icon;
|
|
196
|
-
|
|
265
|
+
const copy = useUiCopy();
|
|
266
|
+
/* Listed for a code that is not drawn — the barcode, the NIT —: say which, or the row reads
|
|
267
|
+
as a wrong match. */
|
|
268
|
+
const hidden = query.trim() === "" ||
|
|
269
|
+
matches(item.name, query, locale) ||
|
|
270
|
+
(item.meta !== undefined && matches(item.meta, query, locale)) ||
|
|
271
|
+
item.haystack === undefined
|
|
272
|
+
? null
|
|
273
|
+
: matchedWord(item.haystack, query, locale);
|
|
274
|
+
return (_jsxs(_Fragment, { children: [marks ? (_jsx("span", { className: cn(hints ? "mt-0.5" : "", "flex size-(--size-icon-sm) shrink-0 items-center justify-center"), children: selected ? (_jsx(Check, { "aria-hidden": true, className: "size-(--size-icon-sm) text-primary forced-colors:text-[HighlightText]", strokeWidth: 2.5 })) : Icon !== undefined ? (_jsx(Icon, { "aria-hidden": true, className: "size-(--size-icon-sm) text-muted", strokeWidth: 1.75 })) : null })) : null, item.avatar === undefined ? null : (_jsx(Avatar, { name: item.name, size: "sm", ...(typeof item.avatar === "string" ? { src: item.avatar } : {}) })), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx(Text, { slot: "label", className: cn("min-w-0 truncate text-meta text-ink", selected ? "font-medium" : ""), children: _jsx(MatchedText, { text: item.name, query: query, locale: locale }) }), item.tags] }), item.meta === undefined ? null : (_jsx(Text, { slot: "description", className: "truncate text-micro tabular-nums text-muted", children: _jsx(MatchedText, { text: item.meta, query: query, locale: locale }) })), hidden === null ? null : (_jsxs("span", { className: "truncate text-micro text-muted", children: [copy.picker.matched, " ", _jsx("span", { className: "font-mono", children: _jsx(MatchedText, { text: hidden, query: query, locale: locale }) })] }))] }), item.aside === undefined ? null : (_jsx("span", { className: "flex shrink-0 items-center gap-1.5 self-center text-meta text-ink tabular-nums", children: item.aside })), !marks && selected ? (_jsx(Check, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0 text-primary forced-colors:text-[HighlightText]", strokeWidth: 2.5 })) : null] }));
|
|
197
275
|
}
|
|
198
276
|
/** Hands the combobox's `close` to the code outside it: React Aria owns the open state and
|
|
199
|
-
* offers no controlled `isOpen`, and the «
|
|
277
|
+
* offers no controlled `isOpen`, and the «Buscar más…» row has to close the list it lives in. */
|
|
200
278
|
function CloseBridge({ into }) {
|
|
201
279
|
const state = useContext(ComboBoxStateContext);
|
|
202
280
|
into.current = state === null ? null : () => state.close();
|
|
@@ -135,7 +135,7 @@ totals, empty, grouping,
|
|
|
135
135
|
/** El total de una columna DENTRO de un montón, ya formateado. Lo calcula quien
|
|
136
136
|
* compone, por el mismo motivo que `totals`: sumar significa cosas distintas
|
|
137
137
|
* según el dominio, y un porcentaje se promedia, no se suma. */
|
|
138
|
-
groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, }: {
|
|
138
|
+
groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, onReorder, }: {
|
|
139
139
|
rows: readonly T[];
|
|
140
140
|
columns: readonly EditColumn<T>[];
|
|
141
141
|
getRowId: (row: T) => string;
|
|
@@ -188,4 +188,9 @@ groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, }: {
|
|
|
188
188
|
detail?: ReactNode;
|
|
189
189
|
owner?: string;
|
|
190
190
|
}) | null;
|
|
191
|
+
/** The rows have an order of their own — the lines of an order, Odoo's `sequence` — and it
|
|
192
|
+
* is changed by dragging the grip at the start of a row, or with Alt + ↑ / ↓ on it. `before`
|
|
193
|
+
* is the row it lands in front of; `null`, the end. Off while grouped (the piles decide the
|
|
194
|
+
* order) and in the stacked layout. */
|
|
195
|
+
onReorder?: (id: string, before: string | null) => void;
|
|
191
196
|
}): import("react").JSX.Element;
|
|
@@ -8,7 +8,8 @@ import { stickyTop, useScrollBox } from "../../foundations/sticky.js";
|
|
|
8
8
|
import { IconButton } from "../button/Button.js";
|
|
9
9
|
import { NoteCard } from "../field/note.js";
|
|
10
10
|
import { Flag } from "../notice/Flag.js";
|
|
11
|
-
import {
|
|
11
|
+
import { HEADER_TEXT } from "./Table.js";
|
|
12
|
+
import { GripVertical, Plus, Trash2 } from "lucide-react";
|
|
12
13
|
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
13
14
|
import { useKeyboard } from "react-aria";
|
|
14
15
|
/** The lines each section heads: the rows after it up to the next section. Notes do not
|
|
@@ -239,7 +240,7 @@ totals, empty, grouping,
|
|
|
239
240
|
/** El total de una columna DENTRO de un montón, ya formateado. Lo calcula quien
|
|
240
241
|
* compone, por el mismo motivo que `totals`: sumar significa cosas distintas
|
|
241
242
|
* según el dominio, y un porcentaje se promedia, no se suma. */
|
|
242
|
-
groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
|
|
243
|
+
groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReorder, }) {
|
|
243
244
|
const copy = useUiCopy();
|
|
244
245
|
const bandOf = (row) => bands?.kind(row) ?? null;
|
|
245
246
|
/* The row's mark, before its first value — and, in a record's margin, its card. */
|
|
@@ -338,6 +339,51 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
|
|
|
338
339
|
/* The rows in the order they are drawn: what the arrows walk. */
|
|
339
340
|
const order = drawn.flatMap((entry) => entry.row === undefined ? [] : [getRowId(entry.row)]);
|
|
340
341
|
const stop = active !== null && order.includes(active) ? active : (order[0] ?? null);
|
|
342
|
+
/* REORDER — a grip at the start of each row. Dragged, the row shows where it would land (a
|
|
343
|
+
line over the row it goes in front of); Alt + ↑ / ↓ on the grip does the same by keyboard. */
|
|
344
|
+
const reorderable = onReorder !== undefined && grouping === undefined && !stacked;
|
|
345
|
+
/* A ref and not state: `dragover` can arrive before React re-renders after `dragstart`. */
|
|
346
|
+
const dragged = useRef(null);
|
|
347
|
+
const [landing, setLanding] = useState(undefined);
|
|
348
|
+
const after = (id) => order[order.indexOf(id) + 1] ?? null;
|
|
349
|
+
const land = (id, before) => {
|
|
350
|
+
if (before === id || before === after(id))
|
|
351
|
+
return;
|
|
352
|
+
onReorder?.(id, before);
|
|
353
|
+
};
|
|
354
|
+
const overRow = (event) => {
|
|
355
|
+
if (dragged.current === null)
|
|
356
|
+
return;
|
|
357
|
+
const tr = event.target.closest("tr[data-row-id]");
|
|
358
|
+
if (tr === null)
|
|
359
|
+
return;
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
const id = tr.dataset["rowId"] ?? "";
|
|
362
|
+
const box = tr.getBoundingClientRect();
|
|
363
|
+
setLanding(event.clientY < box.top + box.height / 2 ? id : after(id));
|
|
364
|
+
};
|
|
365
|
+
const lineOver = (id) => (landing === id ? "border-t-2 border-t-primary" : "");
|
|
366
|
+
const grip = (id, mark) => (_jsx("td", { className: `w-0 py-1 pr-0 pl-1 align-middle ${mark} ${lineOver(id)}`, children: _jsx("button", { type: "button", draggable: true, "aria-label": copy.editable.reorder, title: copy.editable.reorderHint, "aria-keyshortcuts": "Alt+ArrowUp Alt+ArrowDown", ...(editable && stop !== id ? { tabIndex: -1 } : {}), onClick: (event) => event.stopPropagation(), onKeyDown: (event) => {
|
|
367
|
+
if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown"))
|
|
368
|
+
return;
|
|
369
|
+
event.preventDefault();
|
|
370
|
+
event.stopPropagation();
|
|
371
|
+
const at = order.indexOf(id);
|
|
372
|
+
if (event.key === "ArrowUp" && at > 0)
|
|
373
|
+
land(id, order[at - 1] ?? null);
|
|
374
|
+
if (event.key === "ArrowDown" && at < order.length - 1)
|
|
375
|
+
land(id, order[at + 2] ?? null);
|
|
376
|
+
}, onDragStart: (event) => {
|
|
377
|
+
event.dataTransfer.setData("text/plain", id);
|
|
378
|
+
event.dataTransfer.effectAllowed = "move";
|
|
379
|
+
const tr = event.currentTarget.closest("tr");
|
|
380
|
+
if (tr !== null)
|
|
381
|
+
event.dataTransfer.setDragImage(tr, 16, 16);
|
|
382
|
+
dragged.current = id;
|
|
383
|
+
}, onDragEnd: () => {
|
|
384
|
+
dragged.current = null;
|
|
385
|
+
setLanding(undefined);
|
|
386
|
+
}, className: "flex cursor-grab items-center rounded-control p-0.5 text-muted opacity-40 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 hover:text-ink focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-primary active:cursor-grabbing", children: _jsx(GripVertical, { "aria-hidden": true, className: "size-(--size-icon-sm)", strokeWidth: 2 }) }) }));
|
|
341
387
|
useEffect(() => {
|
|
342
388
|
const target = focusOnOpen.current;
|
|
343
389
|
if (target === null || target.id !== editing)
|
|
@@ -556,7 +602,20 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
|
|
|
556
602
|
return null;
|
|
557
603
|
return (_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-meta", children: [_jsx("dt", { className: "text-muted", children: c.header }), _jsx("dd", { className: "font-semibold text-ink tabular-nums", children: total })] }, c.id));
|
|
558
604
|
}) }))] }));
|
|
559
|
-
return (_jsxs("div", { ref: frame, className: "min-w-0", children: [_jsx("div", { ref: box.ref, className: `min-w-0 ${box.className}`, style: box.style, children: stacked ? (list) : (_jsxs("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: [_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsxs("tr", { children: [columns.map((c) => (_jsx("th", { scope: "col", style: c.width === undefined ? undefined : { width: c.width }, className: `border-b border-line-strong
|
|
605
|
+
return (_jsxs("div", { ref: frame, className: "min-w-0", children: [_jsx("div", { ref: box.ref, className: `min-w-0 ${box.className}`, style: box.style, children: stacked ? (list) : (_jsxs("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: [_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsxs("tr", { children: [reorderable ? _jsx("th", { className: "w-0 border-b border-line-strong" }) : null, columns.map((c) => (_jsx("th", { scope: "col", style: c.width === undefined ? undefined : { width: c.width }, className: `border-b border-line-strong ${HEADER_TEXT} ${cell} ${c.numeric === true ? "text-right" : "text-left"}`, children: c.header }, c.id))), onRemove === undefined ? null : (_jsx("th", { className: "w-0 border-b border-line-strong" }))] }) }), _jsx("tbody", { ...(reorderable
|
|
606
|
+
? {
|
|
607
|
+
onDragOver: overRow,
|
|
608
|
+
onDrop: (event) => {
|
|
609
|
+
event.preventDefault();
|
|
610
|
+
if (dragged.current !== null && landing !== undefined)
|
|
611
|
+
land(dragged.current, landing);
|
|
612
|
+
dragged.current = null;
|
|
613
|
+
setLanding(undefined);
|
|
614
|
+
},
|
|
615
|
+
}
|
|
616
|
+
: {}), children: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columns.length +
|
|
617
|
+
(onRemove === undefined ? 0 : 1) +
|
|
618
|
+
(reorderable ? 1 : 0), className: `text-meta text-muted italic ${cell}`, children: emptyText }) })) : (drawn.map((entry) => {
|
|
560
619
|
if (entry.pile !== undefined) {
|
|
561
620
|
const pile = entry.pile;
|
|
562
621
|
const inside = pile.piles.length === 0 ? pile.items : pileItems(pile.piles);
|
|
@@ -565,7 +624,7 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
|
|
|
565
624
|
columna y cada total bajo la suya. Un total en un rincón
|
|
566
625
|
obliga a buscar de qué columna es; alineado no hay nada que
|
|
567
626
|
buscar. */
|
|
568
|
-
_jsxs("tr", { children: [columns.map((c, ci) => (_jsx("td", { className: `bg-ground text-meta ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, style: ci === 0 && pile.depth > 0
|
|
627
|
+
_jsxs("tr", { children: [reorderable ? _jsx("td", { className: "w-0 bg-ground" }) : null, columns.map((c, ci) => (_jsx("td", { className: `bg-ground text-meta ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, style: ci === 0 && pile.depth > 0
|
|
569
628
|
? {
|
|
570
629
|
paddingLeft: `calc(var(--spacing-indent) * ${pile.depth} + 0.625rem)`,
|
|
571
630
|
}
|
|
@@ -586,7 +645,7 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
|
|
|
586
645
|
: "group-hover/row:bg-ink/4";
|
|
587
646
|
const bandMark = `border-b border-line first:rounded-l-control last:rounded-r-control ${bandWash}`;
|
|
588
647
|
const control = isEditing && editable && bands?.edit !== undefined;
|
|
589
|
-
return (_jsxs(EditRow, { as: "tr", ...rowWiring(id, row), className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [_jsx("td", { colSpan: span, ...(control ? { "data-edit": "" } : {}), className: `text-meta ${bandMark} ${cell} ${band === "section"
|
|
648
|
+
return (_jsxs(EditRow, { as: "tr", ...rowWiring(id, row), className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [reorderable ? grip(id, bandMark) : null, _jsx("td", { colSpan: span, ...(control ? { "data-edit": "" } : {}), className: `text-meta ${bandMark} ${lineOver(id)} ${cell} ${band === "section"
|
|
590
649
|
? "font-semibold text-ink"
|
|
591
650
|
: "text-muted italic"}`, children: control ? bands?.edit?.(row, setter(id)) : bands?.render(row) }), columns.slice(span).map((c, i) => (_jsx("td", { className: `text-meta text-ink ${bandMark} ${cell} ${c.numeric === true
|
|
592
651
|
? "text-right font-semibold tabular-nums"
|
|
@@ -600,12 +659,14 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, }) {
|
|
|
600
659
|
return (_jsxs(EditRow, { as: "tr", ...rowWiring(id, row),
|
|
601
660
|
/* The focus ring is drawn on the row, inset, so it is not cut by
|
|
602
661
|
the box and does not move the cells. */
|
|
603
|
-
className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [columns.map((c, ci) => {
|
|
662
|
+
className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [reorderable ? grip(id, mark) : null, columns.map((c, ci) => {
|
|
604
663
|
const control = isEditing && c.edit !== undefined && editable;
|
|
605
|
-
|
|
606
|
-
|
|
664
|
+
/* Not `mark`: that name is the row's border and wash, above. Shadowed,
|
|
665
|
+
the cells were drawn with the class «null» — no hairline, no hover. */
|
|
666
|
+
const flagged = ci === 0 ? flagOf(row, true) : null;
|
|
667
|
+
return (_jsx("td", { ...(control ? { "data-edit": "" } : {}), className: `text-meta text-ink ${mark} ${lineOver(id)} ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: flagged === null ? (control ? (c.edit?.(row, set)) : (c.render(row))) : (_jsxs("span", { className: "flex min-w-0 items-start gap-1.5", children: [flagged, _jsx("span", { className: "min-w-0 flex-1", children: control ? c.edit?.(row, set) : c.render(row) })] })) }, c.id));
|
|
607
668
|
}), onRemove === undefined ? null : (_jsx("td", { className: `w-0 py-1 pr-2 pl-1 align-middle ${mark}`, children: _jsx("span", { className: "flex opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 group-focus-visible/row:opacity-100 focus-within:opacity-100", onClick: (e) => e.stopPropagation(), children: _jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", ...(editable && stop !== id ? { tabIndex: -1 } : {}), onClick: () => onRemove(id) }) }) }))] }, id));
|
|
608
|
-
})) }), totals === undefined ? null : (_jsx("tfoot", { children: _jsxs("tr", { children: [columns.map((c, i) => (_jsx("td", { className: `border-t border-line-strong text-meta font-semibold text-ink ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: totals[i] ?? null }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "border-t border-line-strong" }))] }) }))] })) }), onAdd === undefined && (adds === undefined || adds.length === 0) ? null : (
|
|
669
|
+
})) }), totals === undefined ? null : (_jsx("tfoot", { children: _jsxs("tr", { children: [reorderable ? _jsx("td", { className: "border-t border-line-strong" }) : null, columns.map((c, i) => (_jsx("td", { className: `border-t border-line-strong text-meta font-semibold text-ink ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: totals[i] ?? null }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "border-t border-line-strong" }))] }) }))] })) }), onAdd === undefined && (adds === undefined || adds.length === 0) ? null : (
|
|
609
670
|
/* The «add» goes under the grid, as text, not as a button away from it:
|
|
610
671
|
what is about to happen is that one more row shows up right here.
|
|
611
672
|
The hover is the same inset mark as a row: the shell lines the label
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { currentCopyLocale, fillCopy, useUiCopy } from "../../foundations/copy.js";
|
|
4
4
|
import { byDensity, useDensity } from "../../foundations/density.js";
|
|
5
5
|
import { stickyTop, useScrollBox } from "../../foundations/sticky.js";
|
|
6
|
+
import { HEADER_TEXT } from "./Table.js";
|
|
6
7
|
import { ChevronRight, Info } from "lucide-react";
|
|
7
8
|
import { useState } from "react";
|
|
8
9
|
/* `useGrouping: "always"` and not the default: without it, four-digit numbers
|
|
@@ -102,5 +103,5 @@ defaultOpen, caption, height, }) {
|
|
|
102
103
|
desplazamiento de la barra y quedaba empujado 68 px hacia adentro de su
|
|
103
104
|
propia caja — banda en blanco arriba y títulos de columna cayendo entre
|
|
104
105
|
las filas. */
|
|
105
|
-
_jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsxs("table", { className: "w-full border-collapse px-1", children: [caption === undefined ? null : (_jsx("caption", { className: `text-left text-meta text-muted ${byDensity(d, "px-3 pb-2", "px-2 pb-1.5")}`, children: caption })), _jsx("thead", { className: "sticky z-20 bg-ground", style: stickyTop, children: _jsxs("tr", { className: "border-b border-line-strong", children: [_jsx("th", { className: `sticky left-0 z-10 bg-ground text-left ${compact ? "px-2 py-1.5" : "px-3 py-2"}`, children: _jsx("span", { className: "sr-only", children: copy.report.concept }) }), columns.map((c) => (_jsx("th", { scope: "col", className: `text-right
|
|
106
|
+
_jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsxs("table", { className: "w-full border-collapse px-1", children: [caption === undefined ? null : (_jsx("caption", { className: `text-left text-meta text-muted ${byDensity(d, "px-3 pb-2", "px-2 pb-1.5")}`, children: caption })), _jsx("thead", { className: "sticky z-20 bg-ground", style: stickyTop, children: _jsxs("tr", { className: "border-b border-line-strong", children: [_jsx("th", { className: `sticky left-0 z-10 bg-ground text-left ${compact ? "px-2 py-1.5" : "px-3 py-2"}`, children: _jsx("span", { className: "sr-only", children: copy.report.concept }) }), columns.map((c) => (_jsx("th", { scope: "col", className: `text-right ${HEADER_TEXT} ${compact ? "px-2 py-1.5" : "px-3 py-2"}`, children: c.header }, c.id))), _jsx("th", { className: "w-0" })] }) }), _jsx("tbody", { children: rows.map((r) => (_jsx(ReportLine, { row: r, columns: columns, open: open, onToggle: toggle, compact: compact }, r.id))) })] }) }));
|
|
106
107
|
}
|
|
@@ -3,19 +3,24 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useUiCopy } from "../../foundations/copy.js";
|
|
4
4
|
import { IconButton } from "../button/Button.js";
|
|
5
5
|
import { Menu } from "../menu/Menu.js";
|
|
6
|
+
import { useRowWash } from "./Table.js";
|
|
6
7
|
import { MoreHorizontal } from "lucide-react";
|
|
7
8
|
export function RowActions({ actions,
|
|
8
9
|
/** How many stay visible before the rest fall into the `⋯`. */
|
|
9
10
|
inline = 2, }) {
|
|
10
11
|
const copy = useUiCopy();
|
|
12
|
+
const wash = useRowWash();
|
|
11
13
|
if (actions.length === 0)
|
|
12
|
-
return _jsx("td", { className:
|
|
14
|
+
return _jsx("td", { className: `w-0 border-b border-line ${wash}` });
|
|
13
15
|
/* With only one left over there is no `⋯`: a menu with a single item is one
|
|
14
16
|
extra click to reach the same button. */
|
|
15
17
|
const inlineActions = actions.length <= inline + 1 ? actions : actions.slice(0, inline);
|
|
16
18
|
const overflow = actions.slice(inlineActions.length);
|
|
17
19
|
const byId = new Map(actions.map((a) => [a.id, a]));
|
|
18
|
-
return (
|
|
20
|
+
return (
|
|
21
|
+
/* The row's hairline runs under its actions too: without it the separators stopped one
|
|
22
|
+
column short and the actions read as outside the table. */
|
|
23
|
+
_jsx("td", { className: `w-0 border-b border-line py-1 pr-2 pl-1 align-middle whitespace-nowrap last:rounded-r-control ${wash}`, children: _jsxs("div", { onClick: (e) => e.stopPropagation(), className: "flex items-center justify-end gap-0.5 opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100", children: [inlineActions.map((a) => (_jsx(IconButton, { icon: a.icon, label: a.label, size: "sm", ...(a.tone === undefined ? {} : { tone: a.tone }), ...(a.disabled === undefined ? {} : { disabled: a.disabled }), onClick: a.onSelect }, a.id))), overflow.length === 0 ? null : (_jsx(Menu, { label: copy.selection.more, icon: MoreHorizontal, iconOnly: true, chevron: false, align: "end", groups: [
|
|
19
24
|
{
|
|
20
25
|
options: overflow.map((a) => ({
|
|
21
26
|
id: a.id,
|
|
@@ -14,6 +14,9 @@ height, }: {
|
|
|
14
14
|
* system scale instead of pinning a size in pixels. */
|
|
15
15
|
height?: number | string;
|
|
16
16
|
}): import("react").JSX.Element;
|
|
17
|
+
/** The text of a column header, shared by every table of the family. Ink and semibold, not
|
|
18
|
+
* muted: in grey at the cells' weight the names of the columns read as one more row. */
|
|
19
|
+
export declare const HEADER_TEXT = "text-meta font-semibold whitespace-nowrap text-ink";
|
|
17
20
|
export declare function THead({ children }: {
|
|
18
21
|
children: ReactNode;
|
|
19
22
|
}): import("react").JSX.Element;
|
|
@@ -71,6 +74,8 @@ export declare function TrDetail({ children, detail, open, onToggle, columns, la
|
|
|
71
74
|
* a hovered row and a painted cell are two signals, and stacking them reads
|
|
72
75
|
* as a third color. */
|
|
73
76
|
export declare const CELL_TONE: Record<Tone, string>;
|
|
77
|
+
/** The wash of the row a cell sits in: for cells drawn outside `Td`, like the row's actions. */
|
|
78
|
+
export declare function useRowWash(): string;
|
|
74
79
|
export declare function Td({ children, numeric, align,
|
|
75
80
|
/** A CODE — an id, a hash, a key — set in the monospaced face. Off by default: a
|
|
76
81
|
* number does not need it (a numeric cell gets tabular figures on its own), and a
|