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.
Files changed (176) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -21
  3. package/README.md +1084 -903
  4. package/dist/Client.Index.d.ts +14 -0
  5. package/dist/Client.Index.js +14 -0
  6. package/dist/Components/Chats/Chat.d.ts +28 -0
  7. package/dist/Components/Chats/Chat.js +19 -0
  8. package/dist/Components/Chats/ChatButton.d.ts +26 -0
  9. package/dist/Components/Chats/ChatButton.js +41 -0
  10. package/dist/Components/Chats/ChatComposer.d.ts +41 -0
  11. package/dist/Components/Chats/ChatComposer.js +177 -0
  12. package/dist/Components/Chats/ChatConversations.d.ts +35 -0
  13. package/dist/Components/Chats/ChatConversations.js +38 -0
  14. package/dist/Components/Chats/ChatPanel.d.ts +66 -0
  15. package/dist/Components/Chats/ChatPanel.js +94 -0
  16. package/dist/Components/Chats/ChatParts.d.ts +87 -0
  17. package/dist/Components/Chats/ChatParts.js +96 -0
  18. package/dist/Components/Chats/ChatThread.d.ts +22 -0
  19. package/dist/Components/Chats/ChatThread.js +96 -0
  20. package/dist/Components/Documents/Button.d.ts +6 -1
  21. package/dist/Components/Documents/Button.js +2 -2
  22. package/dist/Components/Documents/Menu.js +44 -28
  23. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  24. package/dist/Components/Documents/SplitButton.js +12 -8
  25. package/dist/Components/Documents/TabButtons.d.ts +29 -8
  26. package/dist/Components/Documents/TabButtons.js +24 -5
  27. package/dist/Components/Forms/ApiForm.d.ts +6 -4
  28. package/dist/Components/Forms/ApiForm.js +15 -14
  29. package/dist/Components/Forms/Crud.js +342 -71
  30. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  31. package/dist/Components/Forms/CrudParts.js +37 -5
  32. package/dist/Components/Forms/ExcelImport.d.ts +58 -0
  33. package/dist/Components/Forms/ExcelImport.js +319 -0
  34. package/dist/Components/Forms/Form.js +9 -13
  35. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  36. package/dist/Components/Forms/SubmitForm.js +23 -12
  37. package/dist/Components/Inputs/DateTimePicker.js +2 -1
  38. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  39. package/dist/Components/Inputs/DropdownParts.js +4 -6
  40. package/dist/Components/Inputs/GroupForm.js +4 -2
  41. package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
  42. package/dist/Components/Inputs/InputRenderer.js +1 -1
  43. package/dist/Components/Inputs/NumberBox.js +24 -2
  44. package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
  45. package/dist/Components/Inputs/RowsInput.d.ts +12 -1
  46. package/dist/Components/Inputs/RowsInput.js +55 -6
  47. package/dist/Components/Inputs/Slider.js +4 -1
  48. package/dist/Components/Inputs/TabularForm.d.ts +7 -3
  49. package/dist/Components/Inputs/TabularForm.js +117 -20
  50. package/dist/Components/Inputs/TimePanel.js +4 -3
  51. package/dist/Components/Inputs/TimePicker.js +2 -1
  52. package/dist/Components/Layouts/Popup.js +1 -4
  53. package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
  54. package/dist/Components/Layouts/ThemePicker.js +77 -6
  55. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  56. package/dist/Components/Layouts/ThemeScript.js +4 -2
  57. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  58. package/dist/Components/Layouts/ThemeSwitcher.js +39 -18
  59. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  60. package/dist/Components/Viewers/Avatar.js +21 -0
  61. package/dist/Components/Viewers/DataTable.js +262 -60
  62. package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
  63. package/dist/Components/Viewers/DataTableColumns.js +200 -0
  64. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  65. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  66. package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
  67. package/dist/Components/Viewers/DataTableParts.js +24 -12
  68. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  69. package/dist/Helpers/AnimationHelpers.js +20 -2
  70. package/dist/Helpers/ApiClient.d.ts +22 -0
  71. package/dist/Helpers/ApiClient.js +4 -1
  72. package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
  73. package/dist/Helpers/ApiFormHelpers.js +73 -9
  74. package/dist/Helpers/ApiModules.d.ts +0 -4
  75. package/dist/Helpers/ApiModules.js +1 -0
  76. package/dist/Helpers/ApiResponses.d.ts +14 -4
  77. package/dist/Helpers/ApiResponses.js +116 -25
  78. package/dist/Helpers/ApiRoutes.d.ts +0 -10
  79. package/dist/Helpers/ApiRoutes.js +2 -10
  80. package/dist/Helpers/ChatBackend.d.ts +134 -0
  81. package/dist/Helpers/ChatBackend.js +332 -0
  82. package/dist/Helpers/ChatHelpers.d.ts +189 -0
  83. package/dist/Helpers/ChatHelpers.js +486 -0
  84. package/dist/Helpers/ChatHooks.d.ts +81 -0
  85. package/dist/Helpers/ChatHooks.js +175 -0
  86. package/dist/Helpers/ChatStore.d.ts +132 -0
  87. package/dist/Helpers/ChatStore.js +394 -0
  88. package/dist/Helpers/ChatSummaries.d.ts +39 -0
  89. package/dist/Helpers/ChatSummaries.js +118 -0
  90. package/dist/Helpers/CrudBackend.d.ts +54 -9
  91. package/dist/Helpers/CrudBackend.js +103 -6
  92. package/dist/Helpers/CrudHelpers.d.ts +39 -4
  93. package/dist/Helpers/CrudHelpers.js +76 -11
  94. package/dist/Helpers/ExcelBackend.d.ts +106 -0
  95. package/dist/Helpers/ExcelBackend.js +296 -0
  96. package/dist/Helpers/ExcelHelpers.d.ts +126 -0
  97. package/dist/Helpers/ExcelHelpers.js +344 -0
  98. package/dist/Helpers/FormStore.d.ts +11 -5
  99. package/dist/Helpers/FormStore.js +44 -6
  100. package/dist/Helpers/MessageBuilder.js +1 -2
  101. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  102. package/dist/Helpers/NumberHelpers.js +41 -3
  103. package/dist/Helpers/PopoverHelpers.d.ts +46 -9
  104. package/dist/Helpers/PopoverHelpers.js +163 -24
  105. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  106. package/dist/Helpers/PopoverHooks.js +62 -0
  107. package/dist/Helpers/RowsStore.d.ts +14 -3
  108. package/dist/Helpers/RowsStore.js +37 -3
  109. package/dist/Helpers/TableColumns.d.ts +7 -0
  110. package/dist/Helpers/TableColumns.js +41 -13
  111. package/dist/Helpers/TableExport.js +12 -12
  112. package/dist/Helpers/TableFilters.d.ts +25 -0
  113. package/dist/Helpers/TableFilters.js +162 -0
  114. package/dist/Helpers/TableHelpers.d.ts +15 -4
  115. package/dist/Helpers/TableHelpers.js +28 -4
  116. package/dist/Helpers/TableStore.d.ts +11 -3
  117. package/dist/Helpers/TableStore.js +16 -5
  118. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  119. package/dist/Helpers/ThemeHelpers.js +46 -9
  120. package/dist/Helpers/UserPreferences.d.ts +14 -0
  121. package/dist/Helpers/UserPreferences.js +99 -0
  122. package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
  123. package/dist/Interfaces/ApiInterfaces.js +16 -10
  124. package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
  125. package/dist/Interfaces/ChatInterfaces.js +1 -0
  126. package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
  127. package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
  128. package/dist/Interfaces/ExcelInterfaces.js +45 -0
  129. package/dist/Interfaces/FormInterfaces.d.ts +85 -7
  130. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  131. package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
  132. package/dist/Interfaces/PreferenceInterfaces.js +28 -0
  133. package/dist/Interfaces/TableInterfaces.d.ts +116 -9
  134. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  135. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  136. package/dist/Services/BrowserApi.d.ts +18 -1
  137. package/dist/Services/BrowserApi.js +18 -0
  138. package/dist/Services/ChatApi.d.ts +37 -0
  139. package/dist/Services/ChatApi.js +98 -0
  140. package/dist/Services/ExcelApi.d.ts +48 -0
  141. package/dist/Services/ExcelApi.js +196 -0
  142. package/dist/Services/ServerApi.d.ts +6 -1
  143. package/dist/Services/ServerApi.js +3 -0
  144. package/dist/Services/ThemeService.d.ts +18 -2
  145. package/dist/Services/ThemeService.js +80 -16
  146. package/dist/Shared.Index.d.ts +10 -0
  147. package/dist/Shared.Index.js +10 -0
  148. package/package.json +85 -82
  149. package/src/Styles/Nexus.Avatar.css +79 -0
  150. package/src/Styles/Nexus.Base.css +117 -70
  151. package/src/Styles/Nexus.Button.css +180 -151
  152. package/src/Styles/Nexus.Calendar.css +273 -275
  153. package/src/Styles/Nexus.Chat.css +1443 -0
  154. package/src/Styles/Nexus.Choice.css +343 -345
  155. package/src/Styles/Nexus.Crud.css +168 -84
  156. package/src/Styles/Nexus.DateTime.css +81 -81
  157. package/src/Styles/Nexus.Dropdown.css +725 -774
  158. package/src/Styles/Nexus.Editor.css +295 -297
  159. package/src/Styles/Nexus.Excel.css +389 -0
  160. package/src/Styles/Nexus.Form.css +279 -298
  161. package/src/Styles/Nexus.Index.css +37 -34
  162. package/src/Styles/Nexus.Input.css +602 -637
  163. package/src/Styles/Nexus.Markdown.css +237 -265
  164. package/src/Styles/Nexus.Menu.css +237 -249
  165. package/src/Styles/Nexus.Popup.css +724 -731
  166. package/src/Styles/Nexus.RichText.css +2 -32
  167. package/src/Styles/Nexus.Rows.css +637 -566
  168. package/src/Styles/Nexus.Slider.css +276 -278
  169. package/src/Styles/Nexus.Tab.Buttons.css +432 -364
  170. package/src/Styles/Nexus.Table.css +1386 -1014
  171. package/src/Styles/Nexus.Theme.Picker.css +286 -159
  172. package/src/Styles/Nexus.Themes.css +194 -194
  173. package/src/Styles/Nexus.Time.css +144 -147
  174. package/src/Styles/Nexus.Toast.css +263 -265
  175. package/src/Styles/Nexus.Tokens.css +89 -72
  176. 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 { TableColumn, TableColumnOption, TableKey, TablePage, TablePinSide, TableQuery, TableRow, TableSort, TableViewColumn } from "../Interfaces/TableInterfaces.ts";
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, so a
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, so a
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 table, the
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 table, the
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 mode on `<html>` before the page paints, so a page never flashes
13
- * the default colors first. `ThemeScript` renders it; use this directly only outside React.
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 mode on `<html>` before the page paints, so a page never flashes
5
- * the default colors first. `ThemeScript` renders it; use this directly only outside React.
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 ids = themes.map(theme => theme.id).filter(id => THEME_ID_PATTERN.test(id));
9
- const config = [ids, defaultTheme, defaultMode, THEME_STORAGE_KEY, THEME_MODE_STORAGE_KEY, THEME_ATTRIBUTE, THEME_MODE_ATTRIBUTE];
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[1],m=c[2];" +
13
- "try{var s=localStorage,a=s.getItem(c[3]),b=s.getItem(c[4]);if(c[0].indexOf(a)>-1)t=a;if(b==='light'||b==='dark'||b==='system')m=b}catch(e){}" +
14
- "if(c[0].indexOf(t)>-1)d.setAttribute(c[5],t);if(m==='light'||m==='dark')d.setAttribute(c[6],m)" +
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
+ }