nexus-shared 2.0.0 → 3.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/CHANGELOG.md +446 -0
- package/LICENSE +21 -21
- package/README.md +1084 -903
- package/dist/Client.Index.d.ts +14 -0
- package/dist/Client.Index.js +14 -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 +96 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/Menu.js +44 -28
- package/dist/Components/Documents/SplitButton.d.ts +3 -1
- package/dist/Components/Documents/SplitButton.js +12 -8
- package/dist/Components/Documents/TabButtons.d.ts +29 -8
- package/dist/Components/Documents/TabButtons.js +24 -5
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +342 -71
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +37 -5
- package/dist/Components/Forms/ExcelImport.d.ts +58 -0
- package/dist/Components/Forms/ExcelImport.js +319 -0
- package/dist/Components/Forms/Form.js +9 -13
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- 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/NumberBox.js +24 -2
- package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +55 -6
- package/dist/Components/Inputs/Slider.js +4 -1
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +117 -20
- package/dist/Components/Inputs/TimePanel.js +4 -3
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/Popup.js +1 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
- package/dist/Components/Layouts/ThemePicker.js +77 -6
- package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
- package/dist/Components/Layouts/ThemeScript.js +4 -2
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
- package/dist/Components/Layouts/ThemeSwitcher.js +39 -18
- package/dist/Components/Viewers/Avatar.d.ts +21 -0
- package/dist/Components/Viewers/Avatar.js +21 -0
- package/dist/Components/Viewers/DataTable.js +262 -60
- package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
- package/dist/Components/Viewers/DataTableColumns.js +200 -0
- package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
- package/dist/Components/Viewers/DataTableFilters.js +68 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
- package/dist/Components/Viewers/DataTableParts.js +24 -12
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- 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 +54 -9
- package/dist/Helpers/CrudBackend.js +103 -6
- 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 +296 -0
- package/dist/Helpers/ExcelHelpers.d.ts +126 -0
- package/dist/Helpers/ExcelHelpers.js +344 -0
- package/dist/Helpers/FormStore.d.ts +11 -5
- package/dist/Helpers/FormStore.js +44 -6
- package/dist/Helpers/MessageBuilder.js +1 -2
- package/dist/Helpers/NumberHelpers.d.ts +7 -1
- package/dist/Helpers/NumberHelpers.js +41 -3
- package/dist/Helpers/PopoverHelpers.d.ts +46 -9
- package/dist/Helpers/PopoverHelpers.js +163 -24
- package/dist/Helpers/PopoverHooks.d.ts +24 -0
- package/dist/Helpers/PopoverHooks.js +62 -0
- package/dist/Helpers/RowsStore.d.ts +14 -3
- package/dist/Helpers/RowsStore.js +37 -3
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableExport.js +12 -12
- package/dist/Helpers/TableFilters.d.ts +25 -0
- package/dist/Helpers/TableFilters.js +162 -0
- package/dist/Helpers/TableHelpers.d.ts +15 -4
- package/dist/Helpers/TableHelpers.js +28 -4
- package/dist/Helpers/TableStore.d.ts +11 -3
- package/dist/Helpers/TableStore.js +16 -5
- package/dist/Helpers/ThemeHelpers.d.ts +21 -4
- package/dist/Helpers/ThemeHelpers.js +46 -9
- package/dist/Helpers/UserPreferences.d.ts +14 -0
- package/dist/Helpers/UserPreferences.js +99 -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 +92 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +85 -7
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
- package/dist/Interfaces/PreferenceInterfaces.js +28 -0
- package/dist/Interfaces/TableInterfaces.d.ts +116 -9
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- 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 +48 -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/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +10 -0
- package/dist/Shared.Index.js +10 -0
- package/package.json +85 -82
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +117 -70
- package/src/Styles/Nexus.Button.css +180 -151
- package/src/Styles/Nexus.Calendar.css +273 -275
- package/src/Styles/Nexus.Chat.css +1443 -0
- package/src/Styles/Nexus.Choice.css +343 -345
- package/src/Styles/Nexus.Crud.css +168 -84
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +725 -774
- package/src/Styles/Nexus.Editor.css +295 -297
- package/src/Styles/Nexus.Excel.css +389 -0
- package/src/Styles/Nexus.Form.css +279 -298
- package/src/Styles/Nexus.Index.css +37 -34
- package/src/Styles/Nexus.Input.css +602 -637
- package/src/Styles/Nexus.Markdown.css +237 -265
- package/src/Styles/Nexus.Menu.css +237 -249
- package/src/Styles/Nexus.Popup.css +724 -731
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +637 -566
- package/src/Styles/Nexus.Slider.css +276 -278
- package/src/Styles/Nexus.Tab.Buttons.css +432 -364
- package/src/Styles/Nexus.Table.css +1386 -1014
- package/src/Styles/Nexus.Theme.Picker.css +286 -159
- package/src/Styles/Nexus.Themes.css +194 -194
- package/src/Styles/Nexus.Time.css +144 -147
- package/src/Styles/Nexus.Toast.css +263 -265
- package/src/Styles/Nexus.Tokens.css +89 -72
- package/src/Styles/Nexus.Tree.css +431 -435
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { InputParams } from "../Interfaces/FormInterfaces.ts";
|
|
2
|
+
import type { TableColumn, TableFilters, TableRow } from "../Interfaces/TableInterfaces.ts";
|
|
3
|
+
/** The params as a filter: the same object without `required`, `defaultValue`, `defaultChecked`. */
|
|
4
|
+
export declare function asFilterInput(params: InputParams): InputParams;
|
|
5
|
+
/**
|
|
6
|
+
* The values that filter, in input order, or `undefined` when none does. Drops a value that `isEmptyInputValue` calls
|
|
7
|
+
* empty or that equals `emptyInputValue(params)` (an unticked box, a slider at its min, a range slider at [min, max]),
|
|
8
|
+
* and any name no input declares.
|
|
9
|
+
*/
|
|
10
|
+
export declare function activeFilters(inputs: readonly InputParams[], values: Readonly<Record<string, unknown>> | undefined): TableFilters | undefined;
|
|
11
|
+
/** The labels of the active filters, for a menu line or an export note: ["Status", "Check-in"]. */
|
|
12
|
+
export declare function filterNames(inputs: readonly InputParams[], filters: TableFilters | undefined): string[];
|
|
13
|
+
/**
|
|
14
|
+
* A client table's test of a row for the active filters, or `null` when nothing filters. Each input reads the column
|
|
15
|
+
* whose `key` is its name; a `number` input named `{key}From` / `{key}To` is a bound of column `{key}`; any other name
|
|
16
|
+
* is a path into the row. A blank cell never matches an active filter.
|
|
17
|
+
*/
|
|
18
|
+
export declare function filterMatcher<TRow extends TableRow>(columns: readonly TableColumn<TRow>[], inputs: readonly InputParams[], filters: TableFilters | undefined): ((row: TRow) => boolean) | null;
|
|
19
|
+
/**
|
|
20
|
+
* The active filters in the flat names a server takes: scalars and lists as they are (never an empty list), a date
|
|
21
|
+
* range as `{name}From` / `{name}Before` (the day after its last day), a date on or a date range over a `date-time`
|
|
22
|
+
* column as the UTC moments of those local days, a range slider as `{name}From` / `{name}To`.
|
|
23
|
+
* `timeZone` defaults to the zone the table shows date-times in.
|
|
24
|
+
*/
|
|
25
|
+
export declare function filterRequest<TRow extends TableRow>(filters: TableFilters | undefined, inputs: readonly InputParams[], columns: readonly TableColumn<TRow>[], timeZone?: string): Record<string, unknown>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { addDays } from "./DateHelpers.js";
|
|
2
|
+
import { fromLocalDateTime, toLocalDateTime, toUtcDateTime } from "./DateTimeHelpers.js";
|
|
3
|
+
import { foldSearchText, searchWords } from "./DropdownHelpers.js";
|
|
4
|
+
import { emptyInputValue, inputFieldName, inputValuesEqual, isEmptyInputValue } from "./InputParamsHelpers.js";
|
|
5
|
+
import { columnValue, readPath, searchTextOf } from "./TableHelpers.js";
|
|
6
|
+
/*
|
|
7
|
+
* A data table's advanced filter, with no React: input params as filters, the values that filter, a client table's
|
|
8
|
+
* test of a row, and the names a server takes.
|
|
9
|
+
*/
|
|
10
|
+
// Inputs whose value cannot filter a row, and names a Nexus page request already uses for its paging.
|
|
11
|
+
const UNSUPPORTED = new Set(["markdown", "rich-text", "tree", "tabular", "group", "password"]);
|
|
12
|
+
const RESERVED = new Set(["searchKeyword", "skipCount", "maxCount", "sorting", "sortType"]);
|
|
13
|
+
const warned = new Set();
|
|
14
|
+
function filterable(params) {
|
|
15
|
+
const reserved = RESERVED.has(params.name);
|
|
16
|
+
if (!reserved && !UNSUPPORTED.has(params.type))
|
|
17
|
+
return true;
|
|
18
|
+
if (!warned.has(params.name)) {
|
|
19
|
+
warned.add(params.name);
|
|
20
|
+
console.warn(`DataTable: the filter "${params.name}" is ignored: ${reserved ? "a page request already sends that name" : `a ${params.type} input cannot filter rows`}.`);
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
/** The params as a filter: the same object without `required`, `defaultValue`, `defaultChecked`. */
|
|
25
|
+
export function asFilterInput(params) {
|
|
26
|
+
// Descriptors rather than a spread, so a getter (a `hidden` that follows `dependsOn`) is still read as the form draws.
|
|
27
|
+
const { required: _required, defaultValue: _value, defaultChecked: _checked, ...rest } = Object.getOwnPropertyDescriptors(params);
|
|
28
|
+
// An input that cannot filter stays out of the panel, as its value stays out of the filters.
|
|
29
|
+
if (!filterable(params))
|
|
30
|
+
rest.hidden = { value: true, enumerable: true };
|
|
31
|
+
return Object.defineProperties({}, rest);
|
|
32
|
+
}
|
|
33
|
+
/** Each input that filters, with its value: in input order, text trimmed, empty values left out. */
|
|
34
|
+
function activeInputs(inputs, values) {
|
|
35
|
+
const active = [];
|
|
36
|
+
if (!values)
|
|
37
|
+
return active;
|
|
38
|
+
for (const params of inputs) {
|
|
39
|
+
const given = values[params.name];
|
|
40
|
+
const value = typeof given === "string" ? given.trim() : given;
|
|
41
|
+
if (!isEmptyInputValue(value) && !inputValuesEqual(value, emptyInputValue(params)) && filterable(params))
|
|
42
|
+
active.push([params, value]);
|
|
43
|
+
}
|
|
44
|
+
return active;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The values that filter, in input order, or `undefined` when none does. Drops a value that `isEmptyInputValue` calls
|
|
48
|
+
* empty or that equals `emptyInputValue(params)` (an unticked box, a slider at its min, a range slider at [min, max]),
|
|
49
|
+
* and any name no input declares.
|
|
50
|
+
*/
|
|
51
|
+
export function activeFilters(inputs, values) {
|
|
52
|
+
const active = activeInputs(inputs, values);
|
|
53
|
+
return active.length ? Object.fromEntries(active.map(([params, value]) => [params.name, value])) : undefined;
|
|
54
|
+
}
|
|
55
|
+
/** The labels of the active filters, for a menu line or an export note: ["Status", "Check-in"]. */
|
|
56
|
+
export function filterNames(inputs, filters) {
|
|
57
|
+
return activeInputs(inputs, filters).map(([params]) => inputFieldName(params) ?? (typeof params.label === "string" ? params.label : params.name));
|
|
58
|
+
}
|
|
59
|
+
const isBlank = (cell) => cell === undefined || cell === null || cell === "";
|
|
60
|
+
/** One filter's test of a row's cell (never blank here). */
|
|
61
|
+
function cellTest(params, value, column, bound) {
|
|
62
|
+
// A moment's day is the one the table shows it on; any other value is read as an ISO date.
|
|
63
|
+
const dayOf = (cell) => (column?.format === "date-time" ? toLocalDateTime(cell)?.date : String(cell).slice(0, 10));
|
|
64
|
+
switch (params.type) {
|
|
65
|
+
case "text":
|
|
66
|
+
case "email":
|
|
67
|
+
case "search":
|
|
68
|
+
case "tel":
|
|
69
|
+
case "url":
|
|
70
|
+
case "textarea": {
|
|
71
|
+
// Every word, folded as the search folds it, in the text the cell shows (an option's label, Yes or No).
|
|
72
|
+
const words = searchWords(String(value));
|
|
73
|
+
return (cell, row) => {
|
|
74
|
+
const text = foldSearchText(column ? searchTextOf(column, row) : String(cell));
|
|
75
|
+
return words.every(word => text.includes(word));
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
case "checkbox":
|
|
79
|
+
case "switch":
|
|
80
|
+
case "radio":
|
|
81
|
+
return cell => cell === true || cell === "true" || cell === 1;
|
|
82
|
+
case "number":
|
|
83
|
+
case "slider": {
|
|
84
|
+
const number = Number(value);
|
|
85
|
+
return cell => (bound === "From" ? Number(cell) >= number : bound === "To" ? Number(cell) <= number : Number(cell) === number);
|
|
86
|
+
}
|
|
87
|
+
case "range-slider": {
|
|
88
|
+
const [low, high] = value;
|
|
89
|
+
return cell => Number(cell) >= low && Number(cell) <= high;
|
|
90
|
+
}
|
|
91
|
+
case "date":
|
|
92
|
+
return cell => dayOf(cell) === value;
|
|
93
|
+
case "date-range": {
|
|
94
|
+
const [from, to] = value;
|
|
95
|
+
return cell => {
|
|
96
|
+
const day = dayOf(cell);
|
|
97
|
+
return day !== undefined && (!from || day >= from) && (!to || day <= to);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
case "time":
|
|
101
|
+
return cell => String(cell).slice(0, 5) === String(value).slice(0, 5);
|
|
102
|
+
case "date-time": {
|
|
103
|
+
// The same minute, however each side writes the moment ("…Z", "+05:45", or no zone for UTC).
|
|
104
|
+
const minute = (moment) => toUtcDateTime(moment)?.slice(0, 16);
|
|
105
|
+
const wanted = minute(value);
|
|
106
|
+
return cell => minute(cell) === wanted;
|
|
107
|
+
}
|
|
108
|
+
default: {
|
|
109
|
+
// A choice, or several: compared as text, so 2 matches "2"; a cell holding a list matches when any item does.
|
|
110
|
+
const wanted = new Set((Array.isArray(value) ? value : [value]).map(String));
|
|
111
|
+
return cell => (Array.isArray(cell) ? cell : [cell]).some(item => wanted.has(String(item)));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A client table's test of a row for the active filters, or `null` when nothing filters. Each input reads the column
|
|
117
|
+
* whose `key` is its name; a `number` input named `{key}From` / `{key}To` is a bound of column `{key}`; any other name
|
|
118
|
+
* is a path into the row. A blank cell never matches an active filter.
|
|
119
|
+
*/
|
|
120
|
+
export function filterMatcher(columns, inputs, filters) {
|
|
121
|
+
const tests = activeInputs(inputs, filters).map(([params, value]) => {
|
|
122
|
+
const { name } = params;
|
|
123
|
+
let column = columns.find(candidate => candidate.key === name);
|
|
124
|
+
const [, key, bound] = (!column && params.type === "number" && /^(.+)(From|To)$/.exec(name)) || [];
|
|
125
|
+
column ??= columns.find(candidate => candidate.key === key);
|
|
126
|
+
const test = cellTest(params, value, column, column ? bound : undefined);
|
|
127
|
+
return (row) => {
|
|
128
|
+
const cell = column ? columnValue(column, row) : readPath(row, name);
|
|
129
|
+
return !isBlank(cell) && test(cell, row);
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
return tests.length ? row => tests.every(test => test(row)) : null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The active filters in the flat names a server takes: scalars and lists as they are (never an empty list), a date
|
|
136
|
+
* range as `{name}From` / `{name}Before` (the day after its last day), a date on or a date range over a `date-time`
|
|
137
|
+
* column as the UTC moments of those local days, a range slider as `{name}From` / `{name}To`.
|
|
138
|
+
* `timeZone` defaults to the zone the table shows date-times in.
|
|
139
|
+
*/
|
|
140
|
+
export function filterRequest(filters, inputs, columns, timeZone) {
|
|
141
|
+
const request = {};
|
|
142
|
+
for (const [params, value] of activeInputs(inputs, filters)) {
|
|
143
|
+
const { name } = params;
|
|
144
|
+
const moments = columns.find(column => column.key === name)?.format === "date-time";
|
|
145
|
+
// Days are half-open, [From, Before), so the last day is never lost to a time of day.
|
|
146
|
+
const days = (from, to) => {
|
|
147
|
+
if (from)
|
|
148
|
+
request[`${name}From`] = moments ? fromLocalDateTime(from, "00:00", timeZone) : from;
|
|
149
|
+
if (to)
|
|
150
|
+
request[`${name}Before`] = moments ? fromLocalDateTime(addDays(to, 1), "00:00", timeZone) : addDays(to, 1);
|
|
151
|
+
};
|
|
152
|
+
if (params.type === "date-range")
|
|
153
|
+
days(...value);
|
|
154
|
+
else if (params.type === "date" && moments)
|
|
155
|
+
days(value, value);
|
|
156
|
+
else if (params.type === "range-slider")
|
|
157
|
+
[request[`${name}From`], request[`${name}To`]] = value;
|
|
158
|
+
else
|
|
159
|
+
request[name] = value;
|
|
160
|
+
}
|
|
161
|
+
return request;
|
|
162
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DatePreferences } from "../Interfaces/DateInterfaces.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { InputParams } from "../Interfaces/FormInterfaces.ts";
|
|
3
|
+
import type { TableColumn, TableColumnOption, TableHeadBand, TableKey, TablePage, TablePinSide, TableQuery, TableRow, TableSort, TableViewColumn } from "../Interfaces/TableInterfaces.ts";
|
|
3
4
|
/** Rows per page when nothing says otherwise. */
|
|
4
5
|
export declare const DEFAULT_PAGE_SIZE = 10;
|
|
5
6
|
/** Choices of rows per page. */
|
|
@@ -23,6 +24,8 @@ export interface CellTextOptions {
|
|
|
23
24
|
preferences?: Partial<DatePreferences>;
|
|
24
25
|
/** IANA zone for `date-time` values. Default: the browser's. */
|
|
25
26
|
timeZone?: string;
|
|
27
|
+
/** How numbers are written: a locale, or the person's settings (`numberLocale`, `useNumberLocale`). Default `en-US`. */
|
|
28
|
+
locale?: string;
|
|
26
29
|
}
|
|
27
30
|
/**
|
|
28
31
|
* A value as the cell writes it, in the column's format: "1,250.00", "Rs. 4,500.00", "12.5%", the date and time in the
|
|
@@ -54,10 +57,10 @@ export declare function readTablePage<TRow extends TableRow>(answer: TablePage<T
|
|
|
54
57
|
matches: number;
|
|
55
58
|
};
|
|
56
59
|
/**
|
|
57
|
-
* Searches, sorts, and pages rows the way the table shows them. `texts` caches each row's folded search text,
|
|
58
|
-
* search of 10,000 rows looks at each row once per keystroke.
|
|
60
|
+
* Searches, filters, sorts, and pages rows the way the table shows them. `texts` caches each row's folded search text,
|
|
61
|
+
* so a search of 10,000 rows looks at each row once per keystroke. `query.filters` are matched by `filterInputs`.
|
|
59
62
|
*/
|
|
60
|
-
export declare function queryRows<TRow extends TableRow>(rows: readonly TRow[], columns: readonly TableColumn<TRow>[], query: TableQuery, texts?: WeakMap<TRow, string
|
|
63
|
+
export declare function queryRows<TRow extends TableRow>(rows: readonly TRow[], columns: readonly TableColumn<TRow>[], query: TableQuery, texts?: WeakMap<TRow, string>, filterInputs?: readonly InputParams[]): {
|
|
61
64
|
rows: TRow[];
|
|
62
65
|
matches: number;
|
|
63
66
|
page: number;
|
|
@@ -100,3 +103,11 @@ export interface ViewColumnsOptions extends ColumnRights {
|
|
|
100
103
|
export declare function viewColumns<TRow extends TableRow>(columns: readonly TableColumn<TRow>[], options?: ViewColumnsOptions): TableViewColumn<TRow>[];
|
|
101
104
|
/** Whether the user may pin this column to a side. */
|
|
102
105
|
export declare function canPinColumn(column: Pick<TableColumn, "pinnable">, rights: ColumnRights): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* The columns cut into bands: each run of columns next to each other that name the same group, and one band of its own
|
|
108
|
+
* for every column that names none. The runs come from the order on screen, so a column moved out of its run keeps its
|
|
109
|
+
* group's heading where it landed and the run it left draws that heading again.
|
|
110
|
+
*
|
|
111
|
+
* A run also ends where the pinned side changes: a heading cannot stay on the left and on the right at once.
|
|
112
|
+
*/
|
|
113
|
+
export declare function headBands<TRow extends TableRow>(columns: readonly TableViewColumn<TRow>[]): TableHeadBand<TRow>[];
|
|
@@ -2,6 +2,7 @@ import { formatDate } from "./DateHelpers.js";
|
|
|
2
2
|
import { formatDateTime } from "./DateTimeHelpers.js";
|
|
3
3
|
import { foldSearchText, searchWords } from "./DropdownHelpers.js";
|
|
4
4
|
import { formatNumber } from "./NumberHelpers.js";
|
|
5
|
+
import { filterMatcher } from "./TableFilters.js";
|
|
5
6
|
import { formatTime } from "./TimeHelpers.js";
|
|
6
7
|
/*
|
|
7
8
|
* Data table helpers, with no React: reading a column's value, its text, searching, sorting, and paging rows, and the
|
|
@@ -83,7 +84,7 @@ export function formatCellText(column, value, options = {}) {
|
|
|
83
84
|
if (number === null)
|
|
84
85
|
return String(value);
|
|
85
86
|
const decimals = column.decimals ?? (column.format === "money" ? 2 : undefined);
|
|
86
|
-
const text = formatNumber(number, { decimals, fixedDecimals: column.format === "money" || column.decimals !== undefined, grouping: true });
|
|
87
|
+
const text = formatNumber(number, { decimals, fixedDecimals: column.format === "money" || column.decimals !== undefined, grouping: true, locale: options.locale });
|
|
87
88
|
return `${prefix}${text}${column.format === "percent" && !suffix ? "%" : suffix}`;
|
|
88
89
|
}
|
|
89
90
|
case "date":
|
|
@@ -176,10 +177,10 @@ export function readTablePage(answer) {
|
|
|
176
177
|
return { rows, total, matches };
|
|
177
178
|
}
|
|
178
179
|
/**
|
|
179
|
-
* Searches, sorts, and pages rows the way the table shows them. `texts` caches each row's folded search text,
|
|
180
|
-
* search of 10,000 rows looks at each row once per keystroke.
|
|
180
|
+
* Searches, filters, sorts, and pages rows the way the table shows them. `texts` caches each row's folded search text,
|
|
181
|
+
* so a search of 10,000 rows looks at each row once per keystroke. `query.filters` are matched by `filterInputs`.
|
|
181
182
|
*/
|
|
182
|
-
export function queryRows(rows, columns, query, texts) {
|
|
183
|
+
export function queryRows(rows, columns, query, texts, filterInputs = []) {
|
|
183
184
|
let list = rows;
|
|
184
185
|
const words = searchWords(query.search);
|
|
185
186
|
if (words.length) {
|
|
@@ -193,6 +194,9 @@ export function queryRows(rows, columns, query, texts) {
|
|
|
193
194
|
return words.every(word => text.includes(word));
|
|
194
195
|
});
|
|
195
196
|
}
|
|
197
|
+
const test = filterMatcher(columns, filterInputs, query.filters);
|
|
198
|
+
if (test)
|
|
199
|
+
list = list.filter(test);
|
|
196
200
|
const sortColumn = query.sort ? columns.find(column => column.key === query.sort.key) : undefined;
|
|
197
201
|
if (sortColumn && query.sort)
|
|
198
202
|
list = sortRows(list, sortColumn, query.sort.direction);
|
|
@@ -279,3 +283,23 @@ export function viewColumns(columns, options = {}) {
|
|
|
279
283
|
export function canPinColumn(column, rights) {
|
|
280
284
|
return (rights.pinnable ?? true) && column.pinnable !== false;
|
|
281
285
|
}
|
|
286
|
+
/* The bands above the headings: a group's heading over the columns next to each other that name it */
|
|
287
|
+
/**
|
|
288
|
+
* The columns cut into bands: each run of columns next to each other that name the same group, and one band of its own
|
|
289
|
+
* for every column that names none. The runs come from the order on screen, so a column moved out of its run keeps its
|
|
290
|
+
* group's heading where it landed and the run it left draws that heading again.
|
|
291
|
+
*
|
|
292
|
+
* A run also ends where the pinned side changes: a heading cannot stay on the left and on the right at once.
|
|
293
|
+
*/
|
|
294
|
+
export function headBands(columns) {
|
|
295
|
+
const bands = [];
|
|
296
|
+
for (const view of columns) {
|
|
297
|
+
const group = view.column.group?.trim() || null;
|
|
298
|
+
const last = bands[bands.length - 1];
|
|
299
|
+
if (group && last && last.group === group && last.columns[0].pin === view.pin)
|
|
300
|
+
last.columns.push(view);
|
|
301
|
+
else
|
|
302
|
+
bands.push({ group, columns: [view] });
|
|
303
|
+
}
|
|
304
|
+
return bands;
|
|
305
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InputParams } from "../Interfaces/FormInterfaces.ts";
|
|
1
2
|
import type { TableColumn, TableKey, TableLoader, TablePaging, TablePinSide, TableRow, TableSort, TableState } from "../Interfaces/TableInterfaces.ts";
|
|
2
3
|
/** What the table component tells its store on every render. */
|
|
3
4
|
export interface TableStoreOptions<TRow extends TableRow = TableRow> {
|
|
@@ -9,6 +10,8 @@ export interface TableStoreOptions<TRow extends TableRow = TableRow> {
|
|
|
9
10
|
paging: TablePaging;
|
|
10
11
|
maxSelection?: number;
|
|
11
12
|
onSelectionChange?: (rows: TRow[]) => void;
|
|
13
|
+
/** The advanced filter's inputs, as the table prepared them (`asFilterInput`). */
|
|
14
|
+
filterInputs?: readonly InputParams[];
|
|
12
15
|
}
|
|
13
16
|
/** How long a changed or added row stays highlighted. */
|
|
14
17
|
export declare const TABLE_FLASH_MS = 1600;
|
|
@@ -52,8 +55,8 @@ export declare class TableStore<TRow extends TableRow = TableRow> {
|
|
|
52
55
|
getRow(key: TableKey): TRow | undefined;
|
|
53
56
|
getSelected(): TRow[];
|
|
54
57
|
/**
|
|
55
|
-
* Every row the table would show for its query, in order: the whole searched and sorted list for a client
|
|
56
|
-
* page a server sent for a server table. An export and a print write these.
|
|
58
|
+
* Every row the table would show for its query, in order: the whole searched, filtered and sorted list for a client
|
|
59
|
+
* table, the page a server sent for a server table. An export and a print write these.
|
|
57
60
|
*/
|
|
58
61
|
getViewRows(): TRow[];
|
|
59
62
|
isSelected(row: TRow): boolean;
|
|
@@ -78,11 +81,16 @@ export declare class TableStore<TRow extends TableRow = TableRow> {
|
|
|
78
81
|
reload({ quiet, reset }?: TableReloadOptions): Promise<void>;
|
|
79
82
|
private take;
|
|
80
83
|
private fail;
|
|
81
|
-
/** Searches, sorts, and pages every row (client tables), then tells the table. */
|
|
84
|
+
/** Searches, filters, sorts, and pages every row (client tables), then tells the table. */
|
|
82
85
|
private view;
|
|
83
86
|
/** Changes the query: a client table shows it at once, a server table loads it. */
|
|
84
87
|
private query;
|
|
85
88
|
setSearch(search: string): void;
|
|
89
|
+
/**
|
|
90
|
+
* Filters the rows by these values, from the first page; `{}` clears. Values are normalized with `activeFilters`;
|
|
91
|
+
* the same filters change nothing. A client table shows the result at once; a server table loads it.
|
|
92
|
+
*/
|
|
93
|
+
setFilters(values: Readonly<Record<string, unknown>>): void;
|
|
86
94
|
setSort(sort: TableSort | null): void;
|
|
87
95
|
/** Sorts by a column: ascending, then descending, then back to no sort. */
|
|
88
96
|
toggleSort(key: string): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { errorMessage, isAbortError } from "./ApiResponses.js";
|
|
2
|
+
import { activeFilters } from "./TableFilters.js";
|
|
2
3
|
import { DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, MIN_COLUMN_WIDTH, clampPage, moveColumnKey, nextSort, queryRows, readTablePage, rowKeyText } from "./TableHelpers.js";
|
|
3
4
|
/** How long a changed or added row stays highlighted. */
|
|
4
5
|
export const TABLE_FLASH_MS = 1600;
|
|
@@ -76,13 +77,13 @@ export class TableStore {
|
|
|
76
77
|
return [...this.state.selected.values()];
|
|
77
78
|
}
|
|
78
79
|
/**
|
|
79
|
-
* Every row the table would show for its query, in order: the whole searched and sorted list for a client
|
|
80
|
-
* page a server sent for a server table. An export and a print write these.
|
|
80
|
+
* Every row the table would show for its query, in order: the whole searched, filtered and sorted list for a client
|
|
81
|
+
* table, the page a server sent for a server table. An export and a print write these.
|
|
81
82
|
*/
|
|
82
83
|
getViewRows() {
|
|
83
84
|
if (this.options.paging === "server")
|
|
84
85
|
return [...this.state.rows];
|
|
85
|
-
return queryRows(this.all, this.options.columns, { ...this.state.query, page: 1, pageSize: Number.MAX_SAFE_INTEGER }, this.texts).rows;
|
|
86
|
+
return queryRows(this.all, this.options.columns, { ...this.state.query, page: 1, pageSize: Number.MAX_SAFE_INTEGER }, this.texts, this.options.filterInputs).rows;
|
|
86
87
|
}
|
|
87
88
|
isSelected(row) {
|
|
88
89
|
return this.state.selected.has(this.keyOf(row));
|
|
@@ -220,14 +221,14 @@ export class TableStore {
|
|
|
220
221
|
this.controller = null;
|
|
221
222
|
this.set({ status: "error", refreshing: false, error: errorMessage(error, "The rows could not be loaded.") });
|
|
222
223
|
}
|
|
223
|
-
/** Searches, sorts, and pages every row (client tables), then tells the table. */
|
|
224
|
+
/** Searches, filters, sorts, and pages every row (client tables), then tells the table. */
|
|
224
225
|
view(extra = {}) {
|
|
225
226
|
if (this.options.paging === "server") {
|
|
226
227
|
this.set(extra);
|
|
227
228
|
return;
|
|
228
229
|
}
|
|
229
230
|
const query = { ...this.state.query, ...extra.query };
|
|
230
|
-
const result = queryRows(this.all, this.options.columns, query, this.texts);
|
|
231
|
+
const result = queryRows(this.all, this.options.columns, query, this.texts, this.options.filterInputs);
|
|
231
232
|
const page = result.page;
|
|
232
233
|
this.set({
|
|
233
234
|
...extra,
|
|
@@ -254,6 +255,16 @@ export class TableStore {
|
|
|
254
255
|
return;
|
|
255
256
|
this.query({ search: text, page: 1 });
|
|
256
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Filters the rows by these values, from the first page; `{}` clears. Values are normalized with `activeFilters`;
|
|
260
|
+
* the same filters change nothing. A client table shows the result at once; a server table loads it.
|
|
261
|
+
*/
|
|
262
|
+
setFilters(values) {
|
|
263
|
+
const filters = activeFilters(this.options.filterInputs ?? [], values);
|
|
264
|
+
if (JSON.stringify(filters) === JSON.stringify(this.state.query.filters))
|
|
265
|
+
return;
|
|
266
|
+
this.query({ filters, page: 1 });
|
|
267
|
+
}
|
|
257
268
|
setSort(sort) {
|
|
258
269
|
this.query({ sort, page: 1 });
|
|
259
270
|
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import { type ThemeMode, type ThemeOption } from "../Interfaces/ThemeInterfaces.ts";
|
|
1
|
+
import { type AnimationMode, type Density, type ThemeMode, type ThemeOption } from "../Interfaces/ThemeInterfaces.ts";
|
|
2
2
|
export declare const THEME_ID_PATTERN: RegExp;
|
|
3
|
+
/** The operating system's "less motion" setting, which `system` animations follow. */
|
|
4
|
+
export declare const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
|
5
|
+
/** What `data-nx-motion` says for an animations choice, given whether the operating system asks for less motion. */
|
|
6
|
+
export declare function motionFor(animations: AnimationMode, systemReduces: boolean): "reduce" | "full";
|
|
7
|
+
/**
|
|
8
|
+
* Whether this is a development build. Bundlers (Next.js, Vite, webpack) write the build's mode in place of
|
|
9
|
+
* `process.env.NODE_ENV`, and a server reads its own; a page with neither counts as production.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isDevelopmentBuild(): boolean;
|
|
3
12
|
export interface ThemeScriptOptions {
|
|
4
13
|
/** Themes a saved choice may name. Default: every built-in theme. */
|
|
5
14
|
themes?: readonly ThemeOption[];
|
|
@@ -7,9 +16,17 @@ export interface ThemeScriptOptions {
|
|
|
7
16
|
defaultTheme?: string;
|
|
8
17
|
/** Mode before the visitor chooses one. Default `system`. */
|
|
9
18
|
defaultMode?: ThemeMode;
|
|
19
|
+
/** Density before the visitor chooses one. Default `compact`. */
|
|
20
|
+
defaultDensity?: Density;
|
|
21
|
+
/** Animations before the visitor chooses. Default `system`, which follows `prefers-reduced-motion`. */
|
|
22
|
+
defaultAnimations?: AnimationMode;
|
|
23
|
+
/** Shows the saved scale grid. Default: in a development build only, so a grid left on never reaches production. */
|
|
24
|
+
grid?: boolean;
|
|
10
25
|
}
|
|
11
26
|
/**
|
|
12
|
-
* JavaScript that sets the saved theme and
|
|
13
|
-
* the
|
|
27
|
+
* JavaScript that sets the saved theme, mode, density, animations and scale grid on `<html>` before the page paints,
|
|
28
|
+
* so a page never flashes the defaults first. It also works out `data-nx-motion` from the animations choice and the
|
|
29
|
+
* system setting, and keeps it right when the system setting changes. `ThemeScript` renders it; use this directly
|
|
30
|
+
* only outside React.
|
|
14
31
|
*/
|
|
15
|
-
export declare function themeScript({ themes, defaultTheme, defaultMode }?: ThemeScriptOptions): string;
|
|
32
|
+
export declare function themeScript({ themes, defaultTheme, defaultMode, defaultDensity, defaultAnimations, grid, }?: ThemeScriptOptions): string;
|
|
@@ -1,16 +1,53 @@
|
|
|
1
|
-
import { DEFAULT_THEME, NEXUS_THEMES, THEME_ATTRIBUTE, THEME_MODE_ATTRIBUTE, THEME_MODE_STORAGE_KEY, THEME_STORAGE_KEY, } from "../Interfaces/ThemeInterfaces.js";
|
|
1
|
+
import { ANIMATIONS_ATTRIBUTE, ANIMATIONS_STORAGE_KEY, DEFAULT_THEME, DENSITY_ATTRIBUTE, DENSITY_STORAGE_KEY, GRID_ATTRIBUTE, GRID_STORAGE_KEY, MOTION_ATTRIBUTE, NEXUS_THEMES, THEME_ATTRIBUTE, THEME_MODE_ATTRIBUTE, THEME_MODE_STORAGE_KEY, THEME_STORAGE_KEY, } from "../Interfaces/ThemeInterfaces.js";
|
|
2
2
|
export const THEME_ID_PATTERN = /^[a-z0-9-]+$/;
|
|
3
|
+
/** The operating system's "less motion" setting, which `system` animations follow. */
|
|
4
|
+
export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
|
5
|
+
/** What `data-nx-motion` says for an animations choice, given whether the operating system asks for less motion. */
|
|
6
|
+
export function motionFor(animations, systemReduces) {
|
|
7
|
+
return animations === "off" || (animations !== "on" && systemReduces) ? "reduce" : "full";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Whether this is a development build. Bundlers (Next.js, Vite, webpack) write the build's mode in place of
|
|
11
|
+
* `process.env.NODE_ENV`, and a server reads its own; a page with neither counts as production.
|
|
12
|
+
*/
|
|
13
|
+
export function isDevelopmentBuild() {
|
|
14
|
+
try {
|
|
15
|
+
return process.env.NODE_ENV !== "production";
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
3
21
|
/**
|
|
4
|
-
* JavaScript that sets the saved theme and
|
|
5
|
-
* the
|
|
22
|
+
* JavaScript that sets the saved theme, mode, density, animations and scale grid on `<html>` before the page paints,
|
|
23
|
+
* so a page never flashes the defaults first. It also works out `data-nx-motion` from the animations choice and the
|
|
24
|
+
* system setting, and keeps it right when the system setting changes. `ThemeScript` renders it; use this directly
|
|
25
|
+
* only outside React.
|
|
6
26
|
*/
|
|
7
|
-
export function themeScript({ themes = NEXUS_THEMES, defaultTheme = DEFAULT_THEME, defaultMode = "system" } = {}) {
|
|
8
|
-
const
|
|
9
|
-
|
|
27
|
+
export function themeScript({ themes = NEXUS_THEMES, defaultTheme = DEFAULT_THEME, defaultMode = "system", defaultDensity = "compact", defaultAnimations = "system", grid = isDevelopmentBuild(), } = {}) {
|
|
28
|
+
const config = {
|
|
29
|
+
ids: themes.map(theme => theme.id).filter(id => THEME_ID_PATTERN.test(id)),
|
|
30
|
+
theme: defaultTheme,
|
|
31
|
+
mode: defaultMode,
|
|
32
|
+
density: defaultDensity,
|
|
33
|
+
animations: defaultAnimations,
|
|
34
|
+
grid,
|
|
35
|
+
keys: [THEME_STORAGE_KEY, THEME_MODE_STORAGE_KEY, DENSITY_STORAGE_KEY, ANIMATIONS_STORAGE_KEY, GRID_STORAGE_KEY],
|
|
36
|
+
attributes: [THEME_ATTRIBUTE, THEME_MODE_ATTRIBUTE, DENSITY_ATTRIBUTE, ANIMATIONS_ATTRIBUTE, GRID_ATTRIBUTE, MOTION_ATTRIBUTE],
|
|
37
|
+
reduce: REDUCED_MOTION_QUERY,
|
|
38
|
+
};
|
|
10
39
|
// "<" is escaped so no value can close the <script> element.
|
|
11
40
|
const json = JSON.stringify(config).replace(/</g, "\\u003c");
|
|
12
|
-
return ("(function(c){var d=document.documentElement,t=c
|
|
13
|
-
"try{
|
|
14
|
-
"if(c
|
|
41
|
+
return ("(function(c){var d=document.documentElement,k=c.keys,a=c.attributes,t=c.theme,m=c.mode,n=c.density,o=c.animations,g='off',v;" +
|
|
42
|
+
"function r(i){try{return localStorage.getItem(k[i])}catch(e){return null}}" +
|
|
43
|
+
"v=r(0);if(c.ids.indexOf(v)>-1)t=v;v=r(1);if(v==='light'||v==='dark'||v==='system')m=v;" +
|
|
44
|
+
"v=r(2);if(v==='compact'||v==='cozy')n=v;v=r(3);if(v==='on'||v==='off'||v==='system')o=v;" +
|
|
45
|
+
"if(c.grid){v=r(4);if(v==='px'||v==='cm')g=v}" +
|
|
46
|
+
"if(c.ids.indexOf(t)>-1)d.setAttribute(a[0],t);if(m==='light'||m==='dark')d.setAttribute(a[1],m);" +
|
|
47
|
+
"d.setAttribute(a[2],n==='cozy'?n:'compact');if(o==='on'||o==='off')d.setAttribute(a[3],o);if(g!=='off')d.setAttribute(a[4],g);" +
|
|
48
|
+
// Motion follows the system setting live, read against whatever the animations choice is by then.
|
|
49
|
+
"var q=window.matchMedia?matchMedia(c.reduce):null;" +
|
|
50
|
+
"function f(){var x=d.getAttribute(a[3]);d.setAttribute(a[5],x==='off'||(x!=='on'&&q&&q.matches)?'reduce':'full')}" +
|
|
51
|
+
"f();if(q&&q.addEventListener)q.addEventListener('change',f)" +
|
|
15
52
|
`})(${json})`);
|
|
16
53
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type UserPreferences } from "../Interfaces/PreferenceInterfaces.ts";
|
|
2
|
+
/** The saved settings, with the defaults for anything missing or invalid. Safe to call on the server. */
|
|
3
|
+
export declare function readUserPreferences(): UserPreferences;
|
|
4
|
+
/** Saves settings and updates every component that shows them. Settings left out keep their value. */
|
|
5
|
+
export declare function setUserPreferences(changes: Partial<UserPreferences>): void;
|
|
6
|
+
/** Calls back when the settings change here or in another tab. Returns the function that stops listening. */
|
|
7
|
+
export declare function subscribeUserPreferences(listener: () => void): () => void;
|
|
8
|
+
/** The current settings; the component renders again when they change. */
|
|
9
|
+
export declare function useUserPreferences(): UserPreferences;
|
|
10
|
+
/**
|
|
11
|
+
* The locale for the person's number settings (`numberLocale`): what a number box, a slider and a table's number cells
|
|
12
|
+
* use when they are given no `locale` of their own.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useNumberLocale(): string;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSyncExternalStore } from "react";
|
|
3
|
+
import { DEFAULT_USER_PREFERENCES, ENumberScales, PREFERENCE_STORAGE_KEYS } from "../Interfaces/PreferenceInterfaces.js";
|
|
4
|
+
import { numberLocale } from "./NumberHelpers.js";
|
|
5
|
+
/*
|
|
6
|
+
* The person's settings for the interface language, numbers and tables, saved in local storage under the
|
|
7
|
+
* PREFERENCE_STORAGE_KEYS and read by the components that show them: a change here redraws every number and table on
|
|
8
|
+
* the page at once, and in other open tabs. The same pattern as the date settings (DatePreferences.ts). On the server
|
|
9
|
+
* there is no storage, so pages render with the defaults and take the saved settings right after hydration.
|
|
10
|
+
*/
|
|
11
|
+
const listeners = new Set();
|
|
12
|
+
let snapshot = DEFAULT_USER_PREFERENCES;
|
|
13
|
+
const SEPARATORS = ["comma", "period", "space", "none"];
|
|
14
|
+
function read(key) {
|
|
15
|
+
try {
|
|
16
|
+
return localStorage.getItem(key);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** The saved settings, with the defaults for anything missing or invalid. Safe to call on the server. */
|
|
23
|
+
export function readUserPreferences() {
|
|
24
|
+
if (typeof localStorage === "undefined")
|
|
25
|
+
return DEFAULT_USER_PREFERENCES;
|
|
26
|
+
const language = read(PREFERENCE_STORAGE_KEYS.language);
|
|
27
|
+
const scale = Number(read(PREFERENCE_STORAGE_KEYS.numberScale));
|
|
28
|
+
const thousands = read(PREFERENCE_STORAGE_KEYS.thousands);
|
|
29
|
+
const pageSize = Number(read(PREFERENCE_STORAGE_KEYS.pageSize));
|
|
30
|
+
const rowClick = read(PREFERENCE_STORAGE_KEYS.rowClick);
|
|
31
|
+
const next = {
|
|
32
|
+
language: language === "ne" ? "ne" : DEFAULT_USER_PREFERENCES.language,
|
|
33
|
+
numberScale: scale === ENumberScales.SouthAsian ? ENumberScales.SouthAsian : DEFAULT_USER_PREFERENCES.numberScale,
|
|
34
|
+
thousands: thousands && SEPARATORS.includes(thousands) ? thousands : DEFAULT_USER_PREFERENCES.thousands,
|
|
35
|
+
pageSize: Number.isInteger(pageSize) && pageSize > 0 ? pageSize : DEFAULT_USER_PREFERENCES.pageSize,
|
|
36
|
+
rowClick: rowClick === "1" ? true : rowClick === "0" ? false : DEFAULT_USER_PREFERENCES.rowClick,
|
|
37
|
+
};
|
|
38
|
+
// The same object while nothing changed, so React sees a stable snapshot.
|
|
39
|
+
const keys = Object.keys(next);
|
|
40
|
+
if (keys.some(key => next[key] !== snapshot[key]))
|
|
41
|
+
snapshot = next;
|
|
42
|
+
return snapshot;
|
|
43
|
+
}
|
|
44
|
+
/** Saves settings and updates every component that shows them. Settings left out keep their value. */
|
|
45
|
+
export function setUserPreferences(changes) {
|
|
46
|
+
try {
|
|
47
|
+
if (changes.language !== undefined)
|
|
48
|
+
localStorage.setItem(PREFERENCE_STORAGE_KEYS.language, changes.language);
|
|
49
|
+
if (changes.numberScale !== undefined)
|
|
50
|
+
localStorage.setItem(PREFERENCE_STORAGE_KEYS.numberScale, String(changes.numberScale));
|
|
51
|
+
if (changes.thousands !== undefined)
|
|
52
|
+
localStorage.setItem(PREFERENCE_STORAGE_KEYS.thousands, changes.thousands);
|
|
53
|
+
// `null` goes back to each table's own.
|
|
54
|
+
if (changes.pageSize === null)
|
|
55
|
+
localStorage.removeItem(PREFERENCE_STORAGE_KEYS.pageSize);
|
|
56
|
+
else if (changes.pageSize !== undefined)
|
|
57
|
+
localStorage.setItem(PREFERENCE_STORAGE_KEYS.pageSize, String(changes.pageSize));
|
|
58
|
+
if (changes.rowClick === null)
|
|
59
|
+
localStorage.removeItem(PREFERENCE_STORAGE_KEYS.rowClick);
|
|
60
|
+
else if (changes.rowClick !== undefined)
|
|
61
|
+
localStorage.setItem(PREFERENCE_STORAGE_KEYS.rowClick, changes.rowClick ? "1" : "0");
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Storage blocked (private mode, for example): the components keep what they have.
|
|
65
|
+
}
|
|
66
|
+
for (const listener of listeners)
|
|
67
|
+
listener();
|
|
68
|
+
}
|
|
69
|
+
// One storage listener for the page, however many components are mounted.
|
|
70
|
+
const STORAGE_KEY_LIST = Object.values(PREFERENCE_STORAGE_KEYS);
|
|
71
|
+
const onStorage = (event) => {
|
|
72
|
+
if (event.key === null || STORAGE_KEY_LIST.includes(event.key))
|
|
73
|
+
for (const listener of listeners)
|
|
74
|
+
listener();
|
|
75
|
+
};
|
|
76
|
+
/** Calls back when the settings change here or in another tab. Returns the function that stops listening. */
|
|
77
|
+
export function subscribeUserPreferences(listener) {
|
|
78
|
+
if (listeners.size === 0)
|
|
79
|
+
window.addEventListener("storage", onStorage);
|
|
80
|
+
listeners.add(listener);
|
|
81
|
+
return () => {
|
|
82
|
+
listeners.delete(listener);
|
|
83
|
+
if (listeners.size === 0)
|
|
84
|
+
window.removeEventListener("storage", onStorage);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const serverSnapshot = () => DEFAULT_USER_PREFERENCES;
|
|
88
|
+
/** The current settings; the component renders again when they change. */
|
|
89
|
+
export function useUserPreferences() {
|
|
90
|
+
return useSyncExternalStore(subscribeUserPreferences, readUserPreferences, serverSnapshot);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The locale for the person's number settings (`numberLocale`): what a number box, a slider and a table's number cells
|
|
94
|
+
* use when they are given no `locale` of their own.
|
|
95
|
+
*/
|
|
96
|
+
export function useNumberLocale() {
|
|
97
|
+
const { numberScale, thousands } = useUserPreferences();
|
|
98
|
+
return numberLocale(numberScale, thousands);
|
|
99
|
+
}
|