nexus-shared 2.0.0 → 3.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/CHANGELOG.md +262 -0
- package/README.md +122 -25
- package/dist/Client.Index.d.ts +11 -0
- package/dist/Client.Index.js +11 -0
- package/dist/Components/Chats/Chat.d.ts +28 -0
- package/dist/Components/Chats/Chat.js +19 -0
- package/dist/Components/Chats/ChatButton.d.ts +26 -0
- package/dist/Components/Chats/ChatButton.js +41 -0
- package/dist/Components/Chats/ChatComposer.d.ts +41 -0
- package/dist/Components/Chats/ChatComposer.js +177 -0
- package/dist/Components/Chats/ChatConversations.d.ts +35 -0
- package/dist/Components/Chats/ChatConversations.js +38 -0
- package/dist/Components/Chats/ChatPanel.d.ts +66 -0
- package/dist/Components/Chats/ChatPanel.js +94 -0
- package/dist/Components/Chats/ChatParts.d.ts +87 -0
- package/dist/Components/Chats/ChatParts.js +100 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Menu.js +24 -20
- package/dist/Components/Documents/SplitButton.js +5 -3
- package/dist/Components/Documents/TabButtons.d.ts +24 -6
- package/dist/Components/Documents/TabButtons.js +23 -4
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +202 -50
- package/dist/Components/Forms/ExcelImport.d.ts +42 -0
- package/dist/Components/Forms/ExcelImport.js +190 -0
- package/dist/Components/Forms/Form.js +5 -1
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- package/dist/Components/Inputs/GroupForm.js +4 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
- package/dist/Components/Inputs/InputRenderer.js +1 -1
- package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +53 -4
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +85 -20
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +2 -2
- package/dist/Components/Layouts/ThemeSwitcher.js +35 -15
- package/dist/Components/Viewers/DataTable.js +88 -34
- package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
- package/dist/Components/Viewers/DataTableColumns.js +243 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +6 -2
- package/dist/Components/Viewers/DataTableParts.js +21 -9
- package/dist/Helpers/ApiClient.d.ts +22 -0
- package/dist/Helpers/ApiClient.js +4 -1
- package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
- package/dist/Helpers/ApiFormHelpers.js +73 -9
- package/dist/Helpers/ApiModules.d.ts +0 -4
- package/dist/Helpers/ApiModules.js +1 -0
- package/dist/Helpers/ApiResponses.d.ts +14 -4
- package/dist/Helpers/ApiResponses.js +116 -25
- package/dist/Helpers/ApiRoutes.d.ts +0 -10
- package/dist/Helpers/ApiRoutes.js +2 -10
- package/dist/Helpers/ChatBackend.d.ts +134 -0
- package/dist/Helpers/ChatBackend.js +332 -0
- package/dist/Helpers/ChatHelpers.d.ts +189 -0
- package/dist/Helpers/ChatHelpers.js +486 -0
- package/dist/Helpers/ChatHooks.d.ts +81 -0
- package/dist/Helpers/ChatHooks.js +175 -0
- package/dist/Helpers/ChatStore.d.ts +132 -0
- package/dist/Helpers/ChatStore.js +394 -0
- package/dist/Helpers/ChatSummaries.d.ts +39 -0
- package/dist/Helpers/ChatSummaries.js +118 -0
- package/dist/Helpers/CrudBackend.d.ts +47 -5
- package/dist/Helpers/CrudBackend.js +99 -4
- package/dist/Helpers/CrudHelpers.d.ts +39 -4
- package/dist/Helpers/CrudHelpers.js +76 -11
- package/dist/Helpers/ExcelBackend.d.ts +106 -0
- package/dist/Helpers/ExcelBackend.js +290 -0
- package/dist/Helpers/ExcelHelpers.d.ts +98 -0
- package/dist/Helpers/ExcelHelpers.js +302 -0
- package/dist/Helpers/FormStore.d.ts +2 -0
- package/dist/Helpers/FormStore.js +19 -1
- package/dist/Helpers/MessageBuilder.js +0 -2
- package/dist/Helpers/PopoverHelpers.d.ts +31 -6
- package/dist/Helpers/PopoverHelpers.js +116 -12
- package/dist/Helpers/RowsStore.d.ts +7 -1
- package/dist/Helpers/RowsStore.js +22 -0
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableHelpers.d.ts +9 -1
- package/dist/Helpers/TableHelpers.js +20 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
- package/dist/Interfaces/ApiInterfaces.js +16 -10
- package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
- package/dist/Interfaces/ChatInterfaces.js +1 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +73 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +52 -2
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/TableInterfaces.d.ts +62 -2
- package/dist/Services/BrowserApi.d.ts +18 -1
- package/dist/Services/BrowserApi.js +18 -0
- package/dist/Services/ChatApi.d.ts +37 -0
- package/dist/Services/ChatApi.js +98 -0
- package/dist/Services/ExcelApi.d.ts +46 -0
- package/dist/Services/ExcelApi.js +196 -0
- package/dist/Services/ServerApi.d.ts +6 -1
- package/dist/Services/ServerApi.js +3 -0
- package/dist/Shared.Index.d.ts +8 -0
- package/dist/Shared.Index.js +8 -0
- package/package.json +6 -3
- package/src/Styles/Nexus.Button.css +2 -0
- package/src/Styles/Nexus.Chat.css +1489 -0
- package/src/Styles/Nexus.Crud.css +77 -0
- package/src/Styles/Nexus.Excel.css +370 -0
- package/src/Styles/Nexus.Form.css +23 -0
- package/src/Styles/Nexus.Index.css +2 -0
- package/src/Styles/Nexus.Menu.css +12 -2
- package/src/Styles/Nexus.Rows.css +78 -48
- package/src/Styles/Nexus.Tab.Buttons.css +55 -0
- package/src/Styles/Nexus.Table.css +286 -4
- package/src/Styles/Nexus.Theme.Picker.css +75 -2
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { ExcelDataType, ExcelRowKind } from "../Interfaces/ExcelInterfaces.js";
|
|
2
|
+
/*
|
|
3
|
+
* Turning a sheet's columns into inputs, a verify's rows into editable rows and back, and handing the person a file.
|
|
4
|
+
* Nothing here calls anything: the calls are in Services/ExcelApi.ts.
|
|
5
|
+
*
|
|
6
|
+
* Two rules decide every reading below, and both come from the backend:
|
|
7
|
+
* - every value travels as **text**, the way it reads in the sheet, because one wire shape serves a verify and an
|
|
8
|
+
* import and the import reads it with the same parser that read the file;
|
|
9
|
+
* - a choice travels as its **key** and never as an id, so a reference cell is shown as the words the person picked
|
|
10
|
+
* and the key beside it is carried through untouched.
|
|
11
|
+
*/
|
|
12
|
+
/** What a spreadsheet file is called in a file picker. */
|
|
13
|
+
export const EXCEL_FILE_ACCEPT = ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
14
|
+
/** The field of an editor row that holds the sheet row it came from. Not a column, so no input is drawn for it. */
|
|
15
|
+
export const EXCEL_ROW_NUMBER = "__excelRow";
|
|
16
|
+
/** The field of an editor row that holds each reference column's key, which the person never sees or types. */
|
|
17
|
+
export const EXCEL_ROW_KEYS = "__excelKeys";
|
|
18
|
+
/** The field of an editor row that holds what the verify answered for it, so an edit can be told from an untouched row. */
|
|
19
|
+
export const EXCEL_ROW_SENT = "__excelSent";
|
|
20
|
+
/* Texts of the spreadsheet side. One table, so a word is changed or translated in one place. */
|
|
21
|
+
const DEFAULT_TEXT = {
|
|
22
|
+
template: "Download template",
|
|
23
|
+
templateHint: "The file to fill in: every list is a dropdown, and the ids beside a choice fill themselves.",
|
|
24
|
+
choose: "Choose file",
|
|
25
|
+
change: "Choose another file",
|
|
26
|
+
checking: "Checking",
|
|
27
|
+
nothingPicked: "No file picked yet.",
|
|
28
|
+
verifyFailed: "The file could not be read.",
|
|
29
|
+
newRows: "New rows",
|
|
30
|
+
duplicates: "Already there",
|
|
31
|
+
duplicateHint: "Nothing here is updated. Edit a row to add it as a new record instead.",
|
|
32
|
+
toFix: "What needs fixing",
|
|
33
|
+
whyDuplicate: "Why these are already there",
|
|
34
|
+
referenceLocked: "Picked in the spreadsheet: this list belongs to the server.",
|
|
35
|
+
generated: "The file fills this in.",
|
|
36
|
+
importLabel: "Import",
|
|
37
|
+
importNothing: "There is no row to import.",
|
|
38
|
+
importedNone: "Nothing was written.",
|
|
39
|
+
refused: "Not written",
|
|
40
|
+
exportWarning: "Export is cut short",
|
|
41
|
+
close: "Close",
|
|
42
|
+
startOver: "Start over",
|
|
43
|
+
};
|
|
44
|
+
let texts = { ...DEFAULT_TEXT };
|
|
45
|
+
/** Rewords or translates the spreadsheet side's own texts. Names only what changes. */
|
|
46
|
+
export function configureExcelText(changes) {
|
|
47
|
+
texts = { ...texts, ...changes };
|
|
48
|
+
}
|
|
49
|
+
/** One of the spreadsheet side's texts. */
|
|
50
|
+
export function excelText(key) {
|
|
51
|
+
return texts[key];
|
|
52
|
+
}
|
|
53
|
+
/* Columns as inputs */
|
|
54
|
+
/** How wide a column is drawn, by what it holds. The template sizes its own columns the same way. */
|
|
55
|
+
const COLUMN_WIDTH = {
|
|
56
|
+
[ExcelDataType.Text]: "12rem",
|
|
57
|
+
[ExcelDataType.Note]: "20rem",
|
|
58
|
+
[ExcelDataType.WholeNumber]: "8rem",
|
|
59
|
+
[ExcelDataType.Decimal]: "8rem",
|
|
60
|
+
[ExcelDataType.Amount]: "9rem",
|
|
61
|
+
[ExcelDataType.Date]: "9.5rem",
|
|
62
|
+
[ExcelDataType.DateTime]: "12rem",
|
|
63
|
+
[ExcelDataType.Time]: "7.5rem",
|
|
64
|
+
[ExcelDataType.YesNo]: "7rem",
|
|
65
|
+
[ExcelDataType.List]: "16rem",
|
|
66
|
+
};
|
|
67
|
+
/** The shapes a zone-less moment may be written in, as the backend reads them. */
|
|
68
|
+
const MOMENT_PATTERN = {
|
|
69
|
+
source: "[0-9]{4}[-/][0-9]{2}[-/][0-9]{2}([ T][0-9]{2}:[0-9]{2}(:[0-9]{2})?)?",
|
|
70
|
+
message: "Write a date and time as 2026-09-29 14:30.",
|
|
71
|
+
};
|
|
72
|
+
const YES_NO = [
|
|
73
|
+
{ value: "Yes", label: "Yes" },
|
|
74
|
+
{ value: "No", label: "No" },
|
|
75
|
+
];
|
|
76
|
+
/**
|
|
77
|
+
* One column of a sheet as an input of the review editors. The rules are the ones the sheet told the person about and
|
|
78
|
+
* no others, so a fix made here is checked the way the file was.
|
|
79
|
+
*
|
|
80
|
+
* Three columns are read-only on purpose:
|
|
81
|
+
* - a **reference**, because its list is the backend's and is picked in the spreadsheet; the cell shows the words the
|
|
82
|
+
* person picked and the key travels back untouched (a key read out of an answer is never typed, and never an id);
|
|
83
|
+
* - a column the **file** fills;
|
|
84
|
+
* - and a **date and time** is a text box rather than a picker, because the sheet's moment is a zone-less wall clock
|
|
85
|
+
* while the picker's value is a UTC moment: a picker here would shift every value by the person's own offset.
|
|
86
|
+
*/
|
|
87
|
+
export function excelColumnInput(column) {
|
|
88
|
+
const shared = {
|
|
89
|
+
name: column.field,
|
|
90
|
+
label: column.header,
|
|
91
|
+
width: COLUMN_WIDTH[column.dataType] ?? "12rem",
|
|
92
|
+
...(column.note ? { hint: column.note } : {}),
|
|
93
|
+
};
|
|
94
|
+
const required = column.isRequired === true;
|
|
95
|
+
const lengths = {
|
|
96
|
+
...(column.minLength && column.minLength > 0 ? { minLength: column.minLength } : {}),
|
|
97
|
+
...(column.maxLength && column.maxLength > 0 ? { maxLength: column.maxLength } : {}),
|
|
98
|
+
};
|
|
99
|
+
const limits = {
|
|
100
|
+
...(column.minimum === null || column.minimum === undefined ? {} : { min: column.minimum }),
|
|
101
|
+
...(column.maximum === null || column.maximum === undefined ? {} : { max: column.maximum }),
|
|
102
|
+
};
|
|
103
|
+
if (column.isReference)
|
|
104
|
+
return { ...shared, type: "text", readOnly: true, readOnlyMessage: excelText("referenceLocked") };
|
|
105
|
+
if (column.isReadOnly)
|
|
106
|
+
return { ...shared, type: "text", readOnly: true, readOnlyMessage: excelText("generated") };
|
|
107
|
+
switch (column.dataType) {
|
|
108
|
+
case ExcelDataType.Note:
|
|
109
|
+
return { ...shared, type: "textarea", rows: 2, required, ...lengths };
|
|
110
|
+
case ExcelDataType.WholeNumber:
|
|
111
|
+
return { ...shared, type: "number", required, decimals: 0, ...limits };
|
|
112
|
+
case ExcelDataType.Decimal:
|
|
113
|
+
return { ...shared, type: "number", required, ...limits };
|
|
114
|
+
case ExcelDataType.Amount:
|
|
115
|
+
return { ...shared, type: "number", required, decimals: 2, fixedDecimals: true, grouping: true, ...limits };
|
|
116
|
+
case ExcelDataType.Date:
|
|
117
|
+
return { ...shared, type: "date", required };
|
|
118
|
+
case ExcelDataType.DateTime:
|
|
119
|
+
return { ...shared, type: "text", required, pattern: MOMENT_PATTERN, placeholder: "2026-09-29 14:30" };
|
|
120
|
+
case ExcelDataType.Time:
|
|
121
|
+
return { ...shared, type: "time", required };
|
|
122
|
+
case ExcelDataType.YesNo:
|
|
123
|
+
return { ...shared, type: "dropdown", required, options: YES_NO, searchable: false };
|
|
124
|
+
default:
|
|
125
|
+
return { ...shared, type: "text", required, ...lengths };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Every column of a sheet as the inputs of one editor row, in the order they read. */
|
|
129
|
+
export function excelColumnInputs(columns) {
|
|
130
|
+
return columns.map(excelColumnInput);
|
|
131
|
+
}
|
|
132
|
+
/* Rows, both ways */
|
|
133
|
+
/** Whether the person edits this column's cell at all, or only reads it. */
|
|
134
|
+
export function isExcelColumnEditable(column) {
|
|
135
|
+
return column.isReference !== true && column.isReadOnly !== true;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A verify's rows as the editors' rows: a value per column, the sheet's row number, and - held beside them, never
|
|
139
|
+
* drawn - each reference column's key and the values the verify answered with.
|
|
140
|
+
*
|
|
141
|
+
* A reference cell shows the **words** the person picked, because its key is not something anybody reads or types.
|
|
142
|
+
*/
|
|
143
|
+
export function excelEditorRows(rows, columns) {
|
|
144
|
+
return rows.map(row => {
|
|
145
|
+
const keys = {};
|
|
146
|
+
const editor = { [EXCEL_ROW_NUMBER]: row.number, [EXCEL_ROW_KEYS]: keys, [EXCEL_ROW_SENT]: { ...row.values } };
|
|
147
|
+
for (const column of columns) {
|
|
148
|
+
const value = row.values[column.field] ?? null;
|
|
149
|
+
if (column.isReference) {
|
|
150
|
+
keys[column.field] = value;
|
|
151
|
+
editor[column.field] = row.labels?.[column.field] ?? "";
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
editor[column.field] = toInputValue(column, value);
|
|
155
|
+
}
|
|
156
|
+
return editor;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Which types are drawn by a number box, whose empty value is `null`. */
|
|
160
|
+
const NUMBER_TYPES = new Set([ExcelDataType.WholeNumber, ExcelDataType.Decimal, ExcelDataType.Amount]);
|
|
161
|
+
/**
|
|
162
|
+
* A cell's text as the input that draws it wants it: a number as a number, a choice unset rather than empty, and
|
|
163
|
+
* everything else as it reads.
|
|
164
|
+
*
|
|
165
|
+
* The three empties are not interchangeable. A number box is empty at `null`; a **dropdown** given `""` treats it as a
|
|
166
|
+
* value and reports it as one its list no longer offers ("Not found"), so an empty choice has to be left unset; a text
|
|
167
|
+
* or date box is empty at `""`.
|
|
168
|
+
*/
|
|
169
|
+
function toInputValue(column, value) {
|
|
170
|
+
if (value === null || value === "")
|
|
171
|
+
return NUMBER_TYPES.has(column.dataType) ? null : column.dataType === ExcelDataType.YesNo ? undefined : "";
|
|
172
|
+
switch (column.dataType) {
|
|
173
|
+
case ExcelDataType.WholeNumber:
|
|
174
|
+
case ExcelDataType.Decimal:
|
|
175
|
+
case ExcelDataType.Amount: {
|
|
176
|
+
const number = Number(value.replace(/,/g, ""));
|
|
177
|
+
return Number.isFinite(number) ? number : null;
|
|
178
|
+
}
|
|
179
|
+
case ExcelDataType.YesNo: {
|
|
180
|
+
const upper = value.trim().toUpperCase();
|
|
181
|
+
if (upper === "YES" || upper === "Y" || upper === "TRUE" || upper === "1")
|
|
182
|
+
return "Yes";
|
|
183
|
+
if (upper === "NO" || upper === "N" || upper === "FALSE" || upper === "0")
|
|
184
|
+
return "No";
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
default:
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** A cell as it travels: text, the way it reads in the sheet. A value the editors could not hold is sent back as it came. */
|
|
192
|
+
function toSheetValue(value) {
|
|
193
|
+
if (value === undefined || value === null)
|
|
194
|
+
return null;
|
|
195
|
+
if (typeof value === "boolean")
|
|
196
|
+
return value ? "Yes" : "No";
|
|
197
|
+
const written = String(value).trim();
|
|
198
|
+
return written.length ? written : null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether a cell still holds what the verify answered for it.
|
|
202
|
+
*
|
|
203
|
+
* It is not a comparison of texts, because a cell goes through an input on the way: a sheet that read `38450.50` comes
|
|
204
|
+
* back from a number box as `38450.5`, and `TRUE` comes back from the Yes/No choice as `Yes`. Both are the same value
|
|
205
|
+
* and neither is an edit, so the two are compared the way the input holds them.
|
|
206
|
+
*/
|
|
207
|
+
function isSameCell(column, current, sent) {
|
|
208
|
+
const asSheet = toSheetValue(current);
|
|
209
|
+
if (asSheet === (sent ?? null))
|
|
210
|
+
return true;
|
|
211
|
+
return toInputValue(column, asSheet) === toInputValue(column, sent ?? null);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The editors' rows as rows to send: every value as text, every reference as the key it came with, and the sheet's own
|
|
215
|
+
* row number kept so a problem can still name where it is.
|
|
216
|
+
*
|
|
217
|
+
* A cell the person did not touch goes back **exactly** as it came, which is what makes the round trip faithful: the
|
|
218
|
+
* backend reads its own text again, not a number an input rewrote on the way through.
|
|
219
|
+
*/
|
|
220
|
+
export function excelSheetRows(rows, columns) {
|
|
221
|
+
return rows.map((row, index) => {
|
|
222
|
+
const keys = (row[EXCEL_ROW_KEYS] ?? {});
|
|
223
|
+
const sent = (row[EXCEL_ROW_SENT] ?? {});
|
|
224
|
+
const values = {};
|
|
225
|
+
const labels = {};
|
|
226
|
+
for (const column of columns) {
|
|
227
|
+
if (column.isReference) {
|
|
228
|
+
values[column.field] = keys[column.field] ?? null;
|
|
229
|
+
labels[column.field] = toSheetValue(row[column.field]);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
values[column.field] = isSameCell(column, row[column.field], sent[column.field]) ? (sent[column.field] ?? null) : toSheetValue(row[column.field]);
|
|
233
|
+
}
|
|
234
|
+
const number = Number(row[EXCEL_ROW_NUMBER]);
|
|
235
|
+
return { number: Number.isFinite(number) && number > 0 ? number : index + 1, values, labels };
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/** Whether the person changed anything in a row since the verify answered it. */
|
|
239
|
+
export function isExcelRowEdited(row, columns) {
|
|
240
|
+
const sent = (row[EXCEL_ROW_SENT] ?? {});
|
|
241
|
+
for (const column of columns) {
|
|
242
|
+
if (!isExcelColumnEditable(column))
|
|
243
|
+
continue;
|
|
244
|
+
if (!isSameCell(column, row[column.field], sent[column.field]))
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
/* What is wrong, in the person's words */
|
|
250
|
+
/** Where a problem is, as the person reads it: the cell, a middle dot, and the column's heading ("B7 - City name"). */
|
|
251
|
+
export function excelProblemPlace(problem) {
|
|
252
|
+
return problem.cell ? `${problem.cell} ${String.fromCharCode(183)} ${problem.header}` : problem.header;
|
|
253
|
+
}
|
|
254
|
+
/** Every problem of these rows, in the order the rows read. */
|
|
255
|
+
export function excelProblems(rows) {
|
|
256
|
+
return rows.flatMap(row => row.problems ?? []);
|
|
257
|
+
}
|
|
258
|
+
/** The rows of one kind. */
|
|
259
|
+
export function excelRowsOfKind(rows, ...kinds) {
|
|
260
|
+
return rows.filter(row => kinds.includes(row.kind));
|
|
261
|
+
}
|
|
262
|
+
/** The rows an import would try to write: the new ones, and the ones still waiting on a fix. */
|
|
263
|
+
export function excelWritableRows(rows) {
|
|
264
|
+
return excelRowsOfKind(rows, ExcelRowKind.New, ExcelRowKind.Invalid);
|
|
265
|
+
}
|
|
266
|
+
/** More than one of the subject: "cities", "boxes", "rows". An English ending, which a text change can replace. */
|
|
267
|
+
export function excelPlural(subject, plural) {
|
|
268
|
+
if (plural)
|
|
269
|
+
return plural;
|
|
270
|
+
if (/(s|x|z|ch|sh)$/i.test(subject))
|
|
271
|
+
return `${subject}es`;
|
|
272
|
+
if (/[^aeiou]y$/i.test(subject))
|
|
273
|
+
return `${subject.slice(0, -1)}ies`;
|
|
274
|
+
return `${subject}s`;
|
|
275
|
+
}
|
|
276
|
+
/** A count said in the subject's own words: "1 city", "12 cities". */
|
|
277
|
+
export function excelCount(rows, subject, plural) {
|
|
278
|
+
if (rows === 1)
|
|
279
|
+
return `1 ${subject}`;
|
|
280
|
+
return `${rows.toLocaleString("en-US")} ${excelPlural(subject, plural)}`;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* What to tell the person before an export that will be cut short, as the owner asked: what is on screen, what will be
|
|
284
|
+
* written, and that filtering is how they get the rest.
|
|
285
|
+
*/
|
|
286
|
+
export function excelExportWarning(rows, limit, subject) {
|
|
287
|
+
return `The search on screen leaves ${excelCount(rows, subject)}, and one export writes at most ${limit.toLocaleString("en-US")}. The first ${limit.toLocaleString("en-US")} are written and the rest are left out. Search or filter for the rows you need to get them all.`;
|
|
288
|
+
}
|
|
289
|
+
/* The file itself */
|
|
290
|
+
/** Hands the person a file to save, under the name the answer gave it. */
|
|
291
|
+
export function saveExcelFile(download) {
|
|
292
|
+
const url = URL.createObjectURL(download.blob);
|
|
293
|
+
const link = document.createElement("a");
|
|
294
|
+
link.href = url;
|
|
295
|
+
link.download = download.fileName;
|
|
296
|
+
link.rel = "noopener";
|
|
297
|
+
document.body.append(link);
|
|
298
|
+
link.click();
|
|
299
|
+
link.remove();
|
|
300
|
+
// Firefox needs the object URL a moment longer than the click.
|
|
301
|
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
302
|
+
}
|
|
@@ -209,6 +209,8 @@ export declare class FormStore<TValues extends FormValues = FormValues> {
|
|
|
209
209
|
/** Inputs that were not there, or were hidden, get their value in the input's type, as a load gives it. */
|
|
210
210
|
private adoptNewInputs;
|
|
211
211
|
private change;
|
|
212
|
+
/** The inputs whose `dependsOn` names any of `names`. */
|
|
213
|
+
private followersOf;
|
|
212
214
|
private checkRule;
|
|
213
215
|
private checkAllRules;
|
|
214
216
|
/** Runs the form's own rule; returns the inputs whose message from it changed. */
|
|
@@ -572,12 +572,19 @@ export class FormStore {
|
|
|
572
572
|
write(field, value, "code");
|
|
573
573
|
}
|
|
574
574
|
}
|
|
575
|
+
// Inputs that follow a changed one (`dependsOn`) draw again with their params read afresh, and their rule is asked
|
|
576
|
+
// again: a getter may have just made one required, read-only or hidden.
|
|
577
|
+
const followers = this.followersOf(written);
|
|
578
|
+
for (const name of followers) {
|
|
579
|
+
remember(name);
|
|
580
|
+
this.checkRule(name);
|
|
581
|
+
}
|
|
575
582
|
// The form's rule runs again: an input it now names, or no longer names, shows the change even if nobody typed in it.
|
|
576
583
|
for (const name of this.checkCross())
|
|
577
584
|
if (!shown.has(name))
|
|
578
585
|
shown.set(name, this.shownWith(name, crossBefore));
|
|
579
586
|
for (const [name, before] of shown)
|
|
580
|
-
if (remounted.has(name) || this.shownError(name) !== before)
|
|
587
|
+
if (remounted.has(name) || followers.has(name) || this.shownError(name) !== before)
|
|
581
588
|
this.bump(name);
|
|
582
589
|
this.patchStatus({ changed: this.changedNames.size > 0, message: this.formMessage(), problems: this.problemList() });
|
|
583
590
|
this.emit(source === "input" ? written[0] : written.length === 1 ? written[0] : null);
|
|
@@ -586,6 +593,17 @@ export class FormStore {
|
|
|
586
593
|
this.draftDropped = false;
|
|
587
594
|
this.scheduleDraft();
|
|
588
595
|
}
|
|
596
|
+
/** The inputs whose `dependsOn` names any of `names`. */
|
|
597
|
+
followersOf(names) {
|
|
598
|
+
const found = new Set();
|
|
599
|
+
if (!names.length)
|
|
600
|
+
return found;
|
|
601
|
+
const changed = new Set(names);
|
|
602
|
+
for (const params of this.options.inputs)
|
|
603
|
+
if (params.dependsOn?.some(name => changed.has(name)))
|
|
604
|
+
found.add(params.name);
|
|
605
|
+
return found;
|
|
606
|
+
}
|
|
589
607
|
checkRule(name) {
|
|
590
608
|
const params = this.inputByName.get(name);
|
|
591
609
|
if (params && isRowsInput(params)) {
|
|
@@ -11,7 +11,6 @@ const DEFAULT_WORDS = {
|
|
|
11
11
|
update: { verb: "update", done: "updated", doing: "Updating", object: "the record", sendsValues: true },
|
|
12
12
|
delete: { verb: "delete", done: "deleted", doing: "Deleting", object: "the record" },
|
|
13
13
|
trash: { verb: "move {object} to the trash", done: "moved to the trash", doing: "Moving {object} to the trash", object: "the record" },
|
|
14
|
-
archive: { verb: "archive", done: "archived", doing: "Archiving", object: "the record" },
|
|
15
14
|
restore: { verb: "restore", done: "restored", doing: "Restoring", object: "the record" },
|
|
16
15
|
recover: { verb: "recover", done: "recovered", doing: "Recovering", object: "the record" },
|
|
17
16
|
flag: { verb: "flag", done: "flagged", doing: "Flagging", object: "the record" },
|
|
@@ -44,7 +43,6 @@ const DEFAULT_TEXTS = {
|
|
|
44
43
|
confirmDetails: {
|
|
45
44
|
delete: "This cannot be undone.",
|
|
46
45
|
trash: "It can be restored from the trash.",
|
|
47
|
-
archive: "It can be restored from the archive.",
|
|
48
46
|
},
|
|
49
47
|
reasons: {
|
|
50
48
|
network: "The server cannot be reached. Check your internet connection and try again.",
|
|
@@ -1,19 +1,29 @@
|
|
|
1
|
+
/** Which side of its anchor a panel opened on: `below` carries its arrow on its top edge, `above` on its bottom edge. */
|
|
2
|
+
export type PanelSide = "below" | "above";
|
|
1
3
|
export type PopoverAlign = "start" | "end";
|
|
2
4
|
export interface PlaceUnderAnchorOptions {
|
|
3
5
|
/** Which edge of the anchor the panel lines up with. Default `start` (the left edge). */
|
|
4
6
|
align?: PopoverAlign;
|
|
5
|
-
/** Space between the anchor and the panel, in pixels. Default 4. */
|
|
7
|
+
/** Space between the anchor and the panel, in pixels. Default 4, or the arrow's reach while it is drawn. */
|
|
6
8
|
gap?: number;
|
|
7
9
|
/** Space kept from the window's edges, in pixels. Default 8. */
|
|
8
10
|
margin?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Draws an arrow on the panel's edge, aimed at the middle of the anchor. Default true: a panel opened from a button
|
|
13
|
+
* says which button. Pass false for a panel that opens flush under a field and is read as part of it.
|
|
14
|
+
*/
|
|
15
|
+
arrow?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* What the arrow points at, when the panel lines up with more than that: the half of a split button that opened it.
|
|
18
|
+
* Default: the anchor itself.
|
|
19
|
+
*/
|
|
20
|
+
arrowAt?: DOMRect;
|
|
9
21
|
}
|
|
10
22
|
/**
|
|
11
23
|
* Places a panel under an anchor, or above it when there is no room below, aligned to the anchor's start or end
|
|
12
|
-
* edge and kept inside the window. Returns whether the panel went above.
|
|
24
|
+
* edge and kept inside the window, with an arrow aimed at the anchor. Returns whether the panel went above.
|
|
13
25
|
*/
|
|
14
26
|
export declare function placeUnderAnchor(panel: HTMLElement, anchor: DOMRect, options?: PlaceUnderAnchorOptions): boolean;
|
|
15
|
-
/** Which side of its anchor a list panel opened on. */
|
|
16
|
-
export type PanelSide = "below" | "above";
|
|
17
27
|
export interface PlaceListPanelOptions {
|
|
18
28
|
/** The side of an earlier call, kept while it has `minHeight` of room, so a list that loads more items does not jump across its anchor. */
|
|
19
29
|
side?: PanelSide;
|
|
@@ -30,14 +40,29 @@ export interface PlaceListPanelOptions {
|
|
|
30
40
|
* Places a list panel under its anchor, at least as wide as the anchor, or above it when the room below is short and
|
|
31
41
|
* there is more above. The panel's height is capped to the room on its side, so a long list scrolls inside the window
|
|
32
42
|
* instead of running past it. Returns the side, to pass back while the panel stays open.
|
|
43
|
+
*
|
|
44
|
+
* It draws no arrow: the panel is as wide as the field and sits flush under it, so it is already read as that field's
|
|
45
|
+
* list, and an arrow over a field's own border would look like a fault.
|
|
33
46
|
*/
|
|
34
47
|
export declare function placeListPanel(panel: HTMLElement, anchor: DOMRect, options?: PlaceListPanelOptions): PanelSide;
|
|
48
|
+
export interface PlaceAtPointOptions {
|
|
49
|
+
/** Space kept from the window's edges, in pixels. Default 8. */
|
|
50
|
+
margin?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Draws an arrow with its tip on the point, so the menu says where it was opened. Default true. The panel then
|
|
53
|
+
* stands the arrow's reach away from the point, and its corner no longer sits on it.
|
|
54
|
+
*/
|
|
55
|
+
arrow?: boolean;
|
|
56
|
+
}
|
|
35
57
|
/**
|
|
36
58
|
* Places a panel at a point, such as where the user right-clicked: to the right of and below the point when there
|
|
37
|
-
* is room, otherwise to the left of or above it, and never past the window's edges.
|
|
59
|
+
* is room, otherwise to the left of or above it, and never past the window's edges. With an arrow, the panel is set
|
|
60
|
+
* back far enough along both axes for the arrow's tip to land on the point.
|
|
38
61
|
*/
|
|
39
|
-
export declare function placeAtPoint(panel: HTMLElement, x: number, y: number,
|
|
62
|
+
export declare function placeAtPoint(panel: HTMLElement, x: number, y: number, options?: PlaceAtPointOptions): void;
|
|
40
63
|
/** Runs `place` again, once per frame at most, while the page scrolls or the window changes size. Returns the stop function. */
|
|
41
64
|
export declare function followAnchor(place: () => void): () => void;
|
|
42
65
|
/** Whether the popover is shown. Safe on browsers without popover support, where it is never "open". */
|
|
43
66
|
export declare function isPopoverOpen(panel: HTMLElement | null): boolean;
|
|
67
|
+
/** How far the arrow reaches past the panel's edge: its depth, less the panel's border it is drawn over. */
|
|
68
|
+
export declare const ARROW_REACH: number;
|
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
* Placing native popovers. A panel is a fixed [popover] element; these helpers set its top and left once it is shown,
|
|
3
3
|
* keep it inside the window, and move it again while the page scrolls. They measure offsetWidth and offsetHeight,
|
|
4
4
|
* which ignore the panel's opening transform.
|
|
5
|
+
*
|
|
6
|
+
* A panel that belongs to a button or to a place also gets an arrow, so the panel is read as that button's panel and
|
|
7
|
+
* not as a dialog from nowhere. These helpers work out where the arrow goes, because they are the only code that
|
|
8
|
+
* knows both rectangles; `.nx-popover` draws it from what they write (see "The arrow" below).
|
|
5
9
|
*/
|
|
6
10
|
/**
|
|
7
11
|
* Places a panel under an anchor, or above it when there is no room below, aligned to the anchor's start or end
|
|
8
|
-
* edge and kept inside the window. Returns whether the panel went above.
|
|
12
|
+
* edge and kept inside the window, with an arrow aimed at the anchor. Returns whether the panel went above.
|
|
9
13
|
*/
|
|
10
14
|
export function placeUnderAnchor(panel, anchor, options = {}) {
|
|
11
|
-
const { align = "start",
|
|
15
|
+
const { align = "start", margin = 8, arrow = true } = options;
|
|
16
|
+
// The arrow needs its reach between the panel and the anchor, or it would be drawn over the anchor.
|
|
17
|
+
const gap = Math.max(options.gap ?? 4, arrow ? ARROW_REACH : 0);
|
|
12
18
|
const width = panel.offsetWidth;
|
|
13
19
|
const height = panel.offsetHeight;
|
|
14
20
|
const below = anchor.bottom + gap;
|
|
@@ -16,15 +22,26 @@ export function placeUnderAnchor(panel, anchor, options = {}) {
|
|
|
16
22
|
const fitsBelow = below + height <= window.innerHeight - margin;
|
|
17
23
|
const top = fitsBelow || above < margin ? below : above;
|
|
18
24
|
const wanted = align === "end" ? anchor.right - width : anchor.left;
|
|
19
|
-
|
|
25
|
+
// The arrow points at the middle of what it belongs to. The panel's own style is read once: this runs again on
|
|
26
|
+
// every animation frame while the page scrolls under an open panel.
|
|
27
|
+
const target = options.arrowAt ?? anchor;
|
|
28
|
+
const middle = (target.left + target.right) / 2;
|
|
29
|
+
const style = getComputedStyle(panel);
|
|
30
|
+
const left = Math.max(margin, Math.min(arrow ? centred(panel, target, wanted, width, middle, style) : wanted, window.innerWidth - width - margin));
|
|
20
31
|
panel.style.top = `${Math.round(top)}px`;
|
|
21
32
|
panel.style.left = `${Math.round(left)}px`;
|
|
22
|
-
|
|
33
|
+
const side = top === below ? "below" : "above";
|
|
34
|
+
const box = { left: Math.round(left), top: Math.round(top), width, height };
|
|
35
|
+
aimArrow(panel, side, box, arrow ? { at: middle, facing: side === "below" ? target.bottom : target.top, room: gap } : null, style);
|
|
36
|
+
return side === "above";
|
|
23
37
|
}
|
|
24
38
|
/**
|
|
25
39
|
* Places a list panel under its anchor, at least as wide as the anchor, or above it when the room below is short and
|
|
26
40
|
* there is more above. The panel's height is capped to the room on its side, so a long list scrolls inside the window
|
|
27
41
|
* instead of running past it. Returns the side, to pass back while the panel stays open.
|
|
42
|
+
*
|
|
43
|
+
* It draws no arrow: the panel is as wide as the field and sits flush under it, so it is already read as that field's
|
|
44
|
+
* list, and an arrow over a field's own border would look like a fault.
|
|
28
45
|
*/
|
|
29
46
|
export function placeListPanel(panel, anchor, options = {}) {
|
|
30
47
|
const { maxHeight = 360, minHeight = 180, gap = 4, margin = 8 } = options;
|
|
@@ -56,19 +73,32 @@ function setStyle(element, property, value) {
|
|
|
56
73
|
}
|
|
57
74
|
/**
|
|
58
75
|
* Places a panel at a point, such as where the user right-clicked: to the right of and below the point when there
|
|
59
|
-
* is room, otherwise to the left of or above it, and never past the window's edges.
|
|
76
|
+
* is room, otherwise to the left of or above it, and never past the window's edges. With an arrow, the panel is set
|
|
77
|
+
* back far enough along both axes for the arrow's tip to land on the point.
|
|
60
78
|
*/
|
|
61
|
-
export function placeAtPoint(panel, x, y,
|
|
79
|
+
export function placeAtPoint(panel, x, y, options = {}) {
|
|
80
|
+
const { margin = 8, arrow = true } = options;
|
|
62
81
|
const width = panel.offsetWidth;
|
|
63
82
|
const height = panel.offsetHeight;
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
// The tip sits `lead` along the panel's edge and `ARROW_REACH` out from it, so the panel starts that much back.
|
|
84
|
+
const lead = arrow ? arrowLead(panel) : 0;
|
|
85
|
+
const reach = arrow ? ARROW_REACH : 0;
|
|
86
|
+
let left = x - lead;
|
|
87
|
+
let top = y + reach;
|
|
66
88
|
if (left + width > window.innerWidth - margin)
|
|
67
|
-
left = x - width;
|
|
89
|
+
left = x + lead - width;
|
|
68
90
|
if (top + height > window.innerHeight - margin)
|
|
69
|
-
top = y - height;
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
top = y - reach - height;
|
|
92
|
+
const placed = {
|
|
93
|
+
left: Math.round(Math.max(margin, Math.min(left, window.innerWidth - width - margin))),
|
|
94
|
+
top: Math.round(Math.max(margin, Math.min(top, window.innerHeight - height - margin))),
|
|
95
|
+
width,
|
|
96
|
+
height,
|
|
97
|
+
};
|
|
98
|
+
panel.style.left = `${placed.left}px`;
|
|
99
|
+
panel.style.top = `${placed.top}px`;
|
|
100
|
+
const side = placed.top >= y ? "below" : "above";
|
|
101
|
+
aimArrow(panel, side, placed, arrow ? { at: x, facing: y, room: reach } : null);
|
|
72
102
|
}
|
|
73
103
|
/** Runs `place` again, once per frame at most, while the page scrolls or the window changes size. Returns the stop function. */
|
|
74
104
|
export function followAnchor(place) {
|
|
@@ -89,3 +119,77 @@ export function followAnchor(place) {
|
|
|
89
119
|
export function isPopoverOpen(panel) {
|
|
90
120
|
return Boolean(panel && typeof panel.matches === "function" && panel.matches(":popover-open"));
|
|
91
121
|
}
|
|
122
|
+
/* ---------------------------------------------------------------------------------------------------------------
|
|
123
|
+
* The arrow
|
|
124
|
+
*
|
|
125
|
+
* An isosceles tail on the panel's edge, drawn in the panel's own border and background, so the two read as one
|
|
126
|
+
* shape in every theme: `.nx-popover[data-nx-arrow]::before` and `::after` in Nexus.Theme.Picker.css. These
|
|
127
|
+
* functions write the two things only the placement knows: which edge carries the tail (`data-nx-arrow`) and where
|
|
128
|
+
* along that edge its tip sits (`--nx-popover-arrow-x`). Both are cleared when it would point at nothing, and no
|
|
129
|
+
* component has to take part.
|
|
130
|
+
* ------------------------------------------------------------------------------------------------------------- */
|
|
131
|
+
/** The arrow's base, across the panel's edge. Keep in step with `--nx-popover-arrow` in the `.nx-popover` rule. */
|
|
132
|
+
const ARROW_BASE = 26;
|
|
133
|
+
/** The arrow's depth, out from the panel's edge. Keep in step with `--nx-popover-arrow-depth`. */
|
|
134
|
+
const ARROW_DEPTH = 11;
|
|
135
|
+
/** Half the arrow's base: how far from its tip each foot stands. */
|
|
136
|
+
const ARROW_HALF = ARROW_BASE / 2;
|
|
137
|
+
/** How far the arrow reaches past the panel's edge: its depth, less the panel's border it is drawn over. */
|
|
138
|
+
export const ARROW_REACH = ARROW_DEPTH - 1;
|
|
139
|
+
/**
|
|
140
|
+
* How far along the panel's edge the arrow's tip has to stay: its own half base, the panel's rounded corner, and two
|
|
141
|
+
* pixels more, so a foot never stands on the curve and the tail is never read as a torn-off corner.
|
|
142
|
+
*/
|
|
143
|
+
function arrowLead(panel) {
|
|
144
|
+
const style = panel instanceof HTMLElement ? getComputedStyle(panel) : panel;
|
|
145
|
+
return (parseFloat(style.borderTopLeftRadius) || 0) + ARROW_HALF + 2;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Where a panel starts along its edge so that its arrow stands on the middle of the anchor.
|
|
149
|
+
*
|
|
150
|
+
* The tip may not stand on a rounded corner, so it keeps `lead` from each end of the panel. An **icon button** is
|
|
151
|
+
* narrower than two leads, so a panel lined up with it would hold the tip short of its middle: the panel slides
|
|
152
|
+
* along its edge instead, past the end of the button, by half the button's width at most.
|
|
153
|
+
*
|
|
154
|
+
* A **wide anchor keeps its alignment**, whatever the arrow can reach. A menu belongs at the start of the row,
|
|
155
|
+
* the cell, or the field it opened from - sliding it to the middle of a 900px row would move it right across the
|
|
156
|
+
* page - so the arrow aims as far as it can there and is left out when that is more than half a base off, which is
|
|
157
|
+
* what it did before any of this. The window's edges still win over both.
|
|
158
|
+
*/
|
|
159
|
+
function centred(panel, anchor, wanted, width, at, style) {
|
|
160
|
+
const lead = arrowLead(style);
|
|
161
|
+
// Only a button too small to be aimed at while the panel lines up with it is worth moving the panel for.
|
|
162
|
+
if (anchor.width >= lead * 2 || width < lead * 2)
|
|
163
|
+
return wanted;
|
|
164
|
+
// The far end carries a pixel more than the lead: `width` is offsetWidth, a whole number, while the panel itself
|
|
165
|
+
// is fractionally wider, which would otherwise leave the foot that much nearer the curve than the lead promises.
|
|
166
|
+
return Math.min(Math.max(wanted, at - width + lead + 1), at - lead);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Aims the panel's arrow, or leaves it out. The tip is kept clear of the panel's rounded corners, and the arrow is
|
|
170
|
+
* left out when it would point at nothing: the target is off the window, the panel is too narrow to carry an arrow
|
|
171
|
+
* clear of its corners, the panel was pushed along its edge until the tip is more than half a base from the target,
|
|
172
|
+
* or it was pushed away from the target across the edge.
|
|
173
|
+
*/
|
|
174
|
+
function aimArrow(panel, side, box, aim, drawn = getComputedStyle(panel)) {
|
|
175
|
+
if (aim) {
|
|
176
|
+
const lead = arrowLead(drawn);
|
|
177
|
+
const x = Math.min(Math.max(aim.at - box.left, lead), box.width - lead);
|
|
178
|
+
const edge = side === "below" ? box.top : box.top + box.height;
|
|
179
|
+
const tip = box.left + x;
|
|
180
|
+
const points = box.width >= lead * 2 && Math.abs(tip - aim.at) <= ARROW_HALF && Math.abs(edge - aim.facing) <= aim.room + 1;
|
|
181
|
+
if (points && onScreen(aim.facing, aim.at)) {
|
|
182
|
+
panel.dataset.nxArrow = side;
|
|
183
|
+
// An absolutely placed arrow starts at the panel's padding box, so the border it is drawn over comes off here.
|
|
184
|
+
const inside = x - (parseFloat(drawn.borderLeftWidth) || 0);
|
|
185
|
+
panel.style.setProperty("--nx-popover-arrow-x", `${Math.round(inside * 10) / 10}px`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
delete panel.dataset.nxArrow;
|
|
190
|
+
panel.style.removeProperty("--nx-popover-arrow-x");
|
|
191
|
+
}
|
|
192
|
+
/** Whether what the arrow points at is still in the window: a button scrolled out of sight gets no arrow. */
|
|
193
|
+
function onScreen(across, along) {
|
|
194
|
+
return across >= 0 && across <= window.innerHeight && along >= 0 && along <= window.innerWidth;
|
|
195
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { InputParams, InputValidationContext, RowsInputProps, RowValue } from "../Interfaces/FormInterfaces.ts";
|
|
2
|
-
export type RowsStoreOptions<TRow extends RowValue = RowValue> = Pick<RowsInputProps<TRow>, "inputs" | "required" | "min" | "max" | "unique" | "validate" | "itemLabel" | "itemLabelPlural" | "rowKey" | "newRow" | "onRowChange"> & {
|
|
2
|
+
export type RowsStoreOptions<TRow extends RowValue = RowValue> = Pick<RowsInputProps<TRow>, "inputs" | "required" | "min" | "max" | "unique" | "validate" | "itemLabel" | "itemLabelPlural" | "rowKey" | "newRow" | "onRowChange" | "deriveRows"> & {
|
|
3
3
|
/** The row's name when there is no `itemLabel`: "Row" in a table, "Item" in sections. */
|
|
4
4
|
fallbackItem?: string;
|
|
5
5
|
/** The user's date settings, so messages write dates the way the fields show them. */
|
|
@@ -151,6 +151,12 @@ export declare class RowsStore<TRow extends RowValue = RowValue> {
|
|
|
151
151
|
private checkEverything;
|
|
152
152
|
/** After a change: repeated values, the rows' own message, the rows to render again, and value listeners. */
|
|
153
153
|
private settle;
|
|
154
|
+
/**
|
|
155
|
+
* Values the rows' positions decide rather than the user. Every change funnels through `settle`, so this is the one
|
|
156
|
+
* place a move, an add, a remove and a keystroke all pass. The patches are written as code writes, which draws only
|
|
157
|
+
* the cells whose value really changed: a move renumbers its column and leaves focus on the row's own menu.
|
|
158
|
+
*/
|
|
159
|
+
private derive;
|
|
154
160
|
private bump;
|
|
155
161
|
private touchAllRows;
|
|
156
162
|
private emit;
|
|
@@ -526,6 +526,7 @@ export class RowsStore {
|
|
|
526
526
|
}
|
|
527
527
|
/** After a change: repeated values, the rows' own message, the rows to render again, and value listeners. */
|
|
528
528
|
settle(changed) {
|
|
529
|
+
this.derive(changed);
|
|
529
530
|
for (const name of this.dirtyUnique)
|
|
530
531
|
this.checkUnique(name, changed);
|
|
531
532
|
this.dirtyUnique.clear();
|
|
@@ -535,6 +536,27 @@ export class RowsStore {
|
|
|
535
536
|
this.refresh();
|
|
536
537
|
this.emit();
|
|
537
538
|
}
|
|
539
|
+
/**
|
|
540
|
+
* Values the rows' positions decide rather than the user. Every change funnels through `settle`, so this is the one
|
|
541
|
+
* place a move, an add, a remove and a keystroke all pass. The patches are written as code writes, which draws only
|
|
542
|
+
* the cells whose value really changed: a move renumbers its column and leaves focus on the row's own menu.
|
|
543
|
+
*/
|
|
544
|
+
derive(changed) {
|
|
545
|
+
const derive = this.options.deriveRows;
|
|
546
|
+
if (!derive)
|
|
547
|
+
return;
|
|
548
|
+
const patches = derive(this.getValue());
|
|
549
|
+
if (!patches)
|
|
550
|
+
return;
|
|
551
|
+
this.entries.forEach((entry, index) => {
|
|
552
|
+
const patch = patches[index];
|
|
553
|
+
if (!patch)
|
|
554
|
+
return;
|
|
555
|
+
// Depth 1, so a derived value never sets `onRowChange` off: these are the store's own writes, not the user's.
|
|
556
|
+
for (const [name, value] of Object.entries(patch))
|
|
557
|
+
this.write(entry.key, name, value, "code", changed, 1);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
538
560
|
bump(key) {
|
|
539
561
|
this.versions.set(key, (this.versions.get(key) ?? 0) + 1);
|
|
540
562
|
const listeners = this.rowListeners.get(key);
|