nexus-shared 3.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 (121) hide show
  1. package/CHANGELOG.md +446 -262
  2. package/LICENSE +21 -21
  3. package/README.md +1084 -1000
  4. package/dist/Client.Index.d.ts +3 -0
  5. package/dist/Client.Index.js +3 -0
  6. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  7. package/dist/Components/Chats/ChatParts.js +3 -7
  8. package/dist/Components/Chats/ChatThread.js +3 -3
  9. package/dist/Components/Documents/Button.d.ts +6 -1
  10. package/dist/Components/Documents/Button.js +2 -2
  11. package/dist/Components/Documents/Menu.js +21 -9
  12. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  13. package/dist/Components/Documents/SplitButton.js +7 -5
  14. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  15. package/dist/Components/Documents/TabButtons.js +2 -2
  16. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  17. package/dist/Components/Forms/ApiForm.js +1 -1
  18. package/dist/Components/Forms/Crud.js +177 -58
  19. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  20. package/dist/Components/Forms/CrudParts.js +37 -5
  21. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  22. package/dist/Components/Forms/ExcelImport.js +189 -60
  23. package/dist/Components/Forms/Form.js +5 -13
  24. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  25. package/dist/Components/Forms/SubmitForm.js +23 -12
  26. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  27. package/dist/Components/Inputs/DropdownParts.js +4 -6
  28. package/dist/Components/Inputs/NumberBox.js +24 -2
  29. package/dist/Components/Inputs/RowsInput.js +2 -2
  30. package/dist/Components/Inputs/Slider.js +4 -1
  31. package/dist/Components/Inputs/TabularForm.js +42 -10
  32. package/dist/Components/Inputs/TimePanel.js +4 -3
  33. package/dist/Components/Layouts/Popup.js +1 -4
  34. package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
  35. package/dist/Components/Layouts/ThemePicker.js +77 -6
  36. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  37. package/dist/Components/Layouts/ThemeScript.js +4 -2
  38. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  39. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  40. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  41. package/dist/Components/Viewers/Avatar.js +21 -0
  42. package/dist/Components/Viewers/DataTable.js +192 -44
  43. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  44. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  45. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  46. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  47. package/dist/Components/Viewers/DataTableParts.d.ts +2 -2
  48. package/dist/Components/Viewers/DataTableParts.js +3 -3
  49. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  50. package/dist/Helpers/AnimationHelpers.js +20 -2
  51. package/dist/Helpers/CrudBackend.d.ts +7 -4
  52. package/dist/Helpers/CrudBackend.js +4 -2
  53. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  54. package/dist/Helpers/ExcelBackend.js +8 -2
  55. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  56. package/dist/Helpers/ExcelHelpers.js +55 -13
  57. package/dist/Helpers/FormStore.d.ts +9 -5
  58. package/dist/Helpers/FormStore.js +25 -5
  59. package/dist/Helpers/MessageBuilder.js +1 -0
  60. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  61. package/dist/Helpers/NumberHelpers.js +41 -3
  62. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  63. package/dist/Helpers/PopoverHelpers.js +52 -17
  64. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  65. package/dist/Helpers/PopoverHooks.js +62 -0
  66. package/dist/Helpers/RowsStore.d.ts +7 -2
  67. package/dist/Helpers/RowsStore.js +15 -3
  68. package/dist/Helpers/TableExport.js +12 -12
  69. package/dist/Helpers/TableFilters.d.ts +25 -0
  70. package/dist/Helpers/TableFilters.js +162 -0
  71. package/dist/Helpers/TableHelpers.d.ts +6 -3
  72. package/dist/Helpers/TableHelpers.js +8 -4
  73. package/dist/Helpers/TableStore.d.ts +11 -3
  74. package/dist/Helpers/TableStore.js +16 -5
  75. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  76. package/dist/Helpers/ThemeHelpers.js +46 -9
  77. package/dist/Helpers/UserPreferences.d.ts +14 -0
  78. package/dist/Helpers/UserPreferences.js +99 -0
  79. package/dist/Interfaces/CrudInterfaces.d.ts +20 -1
  80. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  81. package/dist/Interfaces/FormInterfaces.d.ts +33 -5
  82. package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
  83. package/dist/Interfaces/PreferenceInterfaces.js +28 -0
  84. package/dist/Interfaces/TableInterfaces.d.ts +59 -12
  85. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  86. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  87. package/dist/Services/ExcelApi.d.ts +3 -1
  88. package/dist/Services/ExcelApi.js +1 -1
  89. package/dist/Services/ThemeService.d.ts +18 -2
  90. package/dist/Services/ThemeService.js +80 -16
  91. package/dist/Shared.Index.d.ts +2 -0
  92. package/dist/Shared.Index.js +2 -0
  93. package/package.json +85 -85
  94. package/src/Styles/Nexus.Avatar.css +79 -0
  95. package/src/Styles/Nexus.Base.css +117 -70
  96. package/src/Styles/Nexus.Button.css +180 -153
  97. package/src/Styles/Nexus.Calendar.css +273 -275
  98. package/src/Styles/Nexus.Chat.css +1443 -1489
  99. package/src/Styles/Nexus.Choice.css +343 -345
  100. package/src/Styles/Nexus.Crud.css +168 -161
  101. package/src/Styles/Nexus.DateTime.css +81 -81
  102. package/src/Styles/Nexus.Dropdown.css +725 -774
  103. package/src/Styles/Nexus.Editor.css +295 -297
  104. package/src/Styles/Nexus.Excel.css +389 -370
  105. package/src/Styles/Nexus.Form.css +279 -321
  106. package/src/Styles/Nexus.Index.css +37 -36
  107. package/src/Styles/Nexus.Input.css +602 -637
  108. package/src/Styles/Nexus.Markdown.css +237 -265
  109. package/src/Styles/Nexus.Menu.css +237 -259
  110. package/src/Styles/Nexus.Popup.css +724 -731
  111. package/src/Styles/Nexus.RichText.css +2 -32
  112. package/src/Styles/Nexus.Rows.css +637 -596
  113. package/src/Styles/Nexus.Slider.css +276 -278
  114. package/src/Styles/Nexus.Tab.Buttons.css +432 -419
  115. package/src/Styles/Nexus.Table.css +1386 -1296
  116. package/src/Styles/Nexus.Theme.Picker.css +286 -232
  117. package/src/Styles/Nexus.Themes.css +194 -194
  118. package/src/Styles/Nexus.Time.css +144 -147
  119. package/src/Styles/Nexus.Toast.css +263 -265
  120. package/src/Styles/Nexus.Tokens.css +89 -72
  121. package/src/Styles/Nexus.Tree.css +431 -435
@@ -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);
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import type { CSSProperties, ReactNode, Ref } from "react";
2
2
  import type { PopupSize } from "../Components/Layouts/Popup.tsx";
3
+ import type { PopupHandle } from "../Components/Layouts/PopupHost.tsx";
3
4
  import type { FormStore } from "../Helpers/FormStore.ts";
4
5
  import type { TableStore } from "../Helpers/TableStore.ts";
5
6
  import type { ApiEndpoint } from "./ApiInterfaces.ts";
@@ -128,6 +129,24 @@ export interface CrudDetailsOptions<TRow extends FormValues = FormValues> {
128
129
  * "{Subject} {label}". Default `details`. A popup whose panel is the point says so: `schema`, `preview`.
129
130
  */
130
131
  label?: string;
132
+ /** Buttons of the page's own in the footer, before Edit and Close: "View history". */
133
+ actions?: readonly CrudDetailsAction<TRow>[];
134
+ }
135
+ /**
136
+ * A button in the details popup's footer. One that names the endpoint it calls shows only to a user who may call
137
+ * it, by the same rule every built-in action follows - so it appears and disappears with the user's rights, with
138
+ * nothing to change here when they come from the backend.
139
+ */
140
+ export interface CrudDetailsAction<TRow extends FormValues = FormValues> {
141
+ id: string;
142
+ label: string;
143
+ icon?: ReactNode;
144
+ /** What it calls: the button shows only when the user may call it. */
145
+ endpoint?: ApiEndpoint;
146
+ /** Hidden for this record, by a rule of the page's own. */
147
+ hidden?: (row: TRow) => boolean;
148
+ /** On a click, with the record and the popup (close it, or leave it open beside what this opens). */
149
+ onSelect: (row: TRow, popup: PopupHandle) => void;
131
150
  }
132
151
  /** What changed, for `onChange`. */
133
152
  export interface CrudChange<TRow extends FormValues = FormValues> {
@@ -137,7 +156,7 @@ export interface CrudChange<TRow extends FormValues = FormValues> {
137
156
  mode: CrudMode;
138
157
  }
139
158
  /** Table settings a CRUD passes on to its `DataTable`. */
140
- export type CrudTableOptions<TRow extends FormValues = FormValues> = Pick<DataTableProps<TRow>, "pageSize" | "pageSizes" | "defaultSort" | "numbered" | "maxHeight" | "fill" | "excel" | "size" | "searchPlaceholder" | "maxSelection" | "emptyMessage" | "columnsMenu" | "stateKey" | "searchDelay" | "reorderable" | "resizable" | "pinnable" | "moreActions" | "exportable" | "printable" | "exportName" | "onImport" | "openOnClick">;
159
+ export type CrudTableOptions<TRow extends FormValues = FormValues> = Pick<DataTableProps<TRow>, "pageSize" | "pageSizes" | "defaultSort" | "numbered" | "maxHeight" | "fill" | "excel" | "size" | "searchPlaceholder" | "maxSelection" | "emptyMessage" | "columnsMenu" | "stateKey" | "searchDelay" | "filterInputs" | "reorderable" | "resizable" | "pinnable" | "moreActions" | "exportable" | "printable" | "exportName" | "onImport" | "openOnClick">;
141
160
  /** Props of `Crud`. */
142
161
  export interface CrudProps<TRow extends FormValues = FormValues> {
143
162
  /** The endpoints: `MODULES.controller(NexusModule.Reservation, "Guest", "guest")`, or an object of your own. */
@@ -84,6 +84,18 @@ export interface ExcelSettings {
84
84
  * what the editors draw from until it is not.
85
85
  */
86
86
  inputs?: readonly unknown[];
87
+ /**
88
+ * The statuses a new record may start in when it is imported, each with its words: the import offers them as a
89
+ * choice. Empty, or absent, when the model offers none, and then no choice is shown.
90
+ */
91
+ recordStatuses?: ExcelRecordStatus[];
92
+ /** The status picked unless the person picks another: the backend's "Imported". */
93
+ defaultRecordStatus?: number;
94
+ }
95
+ /** One status an imported record may start in: the backend's number, and the words for it. */
96
+ export interface ExcelRecordStatus {
97
+ value: number;
98
+ label: string;
87
99
  }
88
100
  /** One thing wrong with one cell, told so the person can find it. */
89
101
  export interface ExcelCellProblem {
@@ -213,8 +213,13 @@ export interface RowsInputHandle<TRow extends RowValue = RowValue> {
213
213
  setValue: (rows: readonly TRow[]) => void;
214
214
  /** Adds a row with these values (over `newRow`), at `index` or at the end, and focuses its first input. */
215
215
  add: (values?: Partial<TRow>, index?: number) => void;
216
- /** Removes the row at `index`. */
217
- remove: (index: number) => void;
216
+ /**
217
+ * Removes the row at `index`, with an Undo under the rows - or none with `undo: false`, for a row the code drops
218
+ * rather than the person, such as one nothing was typed in.
219
+ */
220
+ remove: (index: number, options?: {
221
+ undo?: boolean;
222
+ }) => void;
218
223
  /** Checks every row, shows every message, and focuses the first problem. Returns whether all is valid. */
219
224
  validate: () => boolean;
220
225
  /** Brings the row at `index` into view and focuses its input named `name`, or its first input. */
@@ -228,6 +233,17 @@ export interface TabularFormProps<TRow extends RowValue = RowValue> extends Rows
228
233
  maxHeight?: number | string;
229
234
  /** The row number column. Default true; it is also the button of each row's menu. */
230
235
  numbered?: boolean;
236
+ /**
237
+ * Where each row's actions sit. `"number"` (the default): a menu beside the row's number. `"end"`: a column fixed at
238
+ * the right edge, as a data table has them - Remove and Lock as buttons of their own, any other action in a menu
239
+ * after them.
240
+ */
241
+ actionsAt?: "number" | "end";
242
+ /**
243
+ * The person can lock a row, and unlock it: a locked row's inputs are read-only and it cannot be removed. The locks
244
+ * are the screen's, never part of the value - a locked row is still sent. Its button is with the row's actions.
245
+ */
246
+ lockable?: boolean;
231
247
  }
232
248
  /** `GroupForm`: each row as a section of inputs, laid out on a 12-column grid by each input's `span`. */
233
249
  export interface GroupFormProps<TRow extends RowValue = RowValue> extends RowsInputProps<TRow> {
@@ -292,7 +308,10 @@ export interface FormProps<TValues extends FormValues = FormValues> {
292
308
  readOnly?: boolean;
293
309
  /** Every input is disabled, and nothing is checked. */
294
310
  disabled?: boolean;
295
- /** Shows placeholders in place of the inputs, e.g. while the values load. */
311
+ /**
312
+ * The values are loading: the inputs show at once, disabled, so the form has its final size from the first frame, and
313
+ * nothing typed is taken (even with `disabled` taken off by hand) until it ends. A submit waits too.
314
+ */
296
315
  loading?: boolean;
297
316
  id?: string;
298
317
  className?: string;
@@ -325,6 +344,13 @@ export type FormSubmitResult<TValues extends FormValues = FormValues> = void | u
325
344
  };
326
345
  /** Where `SubmitForm` puts its buttons: at the end with the status line before them, at the start, or nowhere (`FormButtons` elsewhere). */
327
346
  export type FormButtonsPlacement = "end" | "start" | false;
347
+ /**
348
+ * What a form's status line reports. `full`: loading, saving, what needs attention, unsaved changes, and when it saved.
349
+ * `errors`: only an error that belongs to no field, such as the server's "The sign-in took too long", for a short form
350
+ * that keeps nothing (a sign-in, a search, a code to verify): "Unsaved changes" would be untrue there, the button's
351
+ * spinner already says it is working, and every field is in view with its own message under it.
352
+ */
353
+ export type FormStatusLineMode = "full" | "errors";
328
354
  /** Props of `SubmitForm`: a `Form` in a `<form>`, checked on submit, with Save, Cancel, and Reset. */
329
355
  export interface SubmitFormProps<TValues extends FormValues = FormValues> extends FormProps<TValues> {
330
356
  /**
@@ -353,6 +379,8 @@ export interface SubmitFormProps<TValues extends FormValues = FormValues> extend
353
379
  resetOnSuccess?: boolean;
354
380
  /** Where the buttons go. Default `end`. With `false`, put a `FormButtons` with the same `form` elsewhere, such as a popup's footer. */
355
381
  buttons?: FormButtonsPlacement;
382
+ /** What the status line reports. Default `full`; `errors` for a short form that keeps nothing, such as a sign-in or a search. */
383
+ statusLine?: FormStatusLineMode;
356
384
  /** Asks before the page closes or reloads with unsaved changes. Default: true without a `draftKey` (a draft keeps them). */
357
385
  confirmLeave?: boolean;
358
386
  /** Name for screen readers. */
@@ -394,8 +422,8 @@ export interface ApiFormProps<TValues extends FormValues = FormValues> extends O
394
422
  /** The toast after a save: text, or `false` for none. Default: the server's message, else the endpoint's ("Room saved."), else "Saved." */
395
423
  successMessage?: ReactNode | false;
396
424
  /**
397
- * Loads the values to edit, e.g. a record by id. Placeholders show while it runs, and a failure offers Retry. It runs
398
- * once; give the form a new `key` to load another record.
425
+ * Loads the values to edit, e.g. a record by id. The inputs show disabled while it runs, and a failure offers Retry.
426
+ * It runs once; give the form a new `key` to load another record.
399
427
  */
400
428
  loadValue?: (signal: AbortSignal) => Promise<Partial<TValues> | null | undefined>;
401
429
  }
@@ -0,0 +1,43 @@
1
+ /** The words of the app itself. Data keeps the language it was entered in. */
2
+ export type InterfaceLanguage = "en" | "ne";
3
+ /**
4
+ * The core's `ENumberScales`, by member value: where the groups of a number fall. `International` writes 1,234,567
5
+ * (thousand, million, billion); `SouthAsian` writes 12,34,567 (thousand, lakh, crore).
6
+ */
7
+ export declare const ENumberScales: {
8
+ readonly International: 0;
9
+ readonly SouthAsian: 1;
10
+ };
11
+ export type ENumberScales = (typeof ENumberScales)[keyof typeof ENumberScales];
12
+ /**
13
+ * The mark between groups of digits: 1,200, 1.200, 1 200 (a no-break space) or 1200. A period between the groups
14
+ * takes a comma for the decimal mark, as it does in much of Europe; every other choice keeps the period.
15
+ */
16
+ export type ThousandsSeparator = "comma" | "period" | "space" | "none";
17
+ export interface UserPreferences {
18
+ /** Default `en`. */
19
+ language: InterfaceLanguage;
20
+ /** Default `International`. */
21
+ numberScale: ENumberScales;
22
+ /** Default `comma`. */
23
+ thousands: ThousandsSeparator;
24
+ /**
25
+ * The rows every table opens with, over the size the table names itself, when the table offers it; a size picked in a
26
+ * table with a `stateKey` still wins there. `null` (the default): not chosen, each table's own.
27
+ */
28
+ pageSize: number | null;
29
+ /**
30
+ * A single click on a row opens its record (`true`) or only a double-click does (`false`), in every table, over what
31
+ * the table says itself. `null` (the default): not chosen, each table's own `openOnClick`.
32
+ */
33
+ rowClick: boolean | null;
34
+ }
35
+ export declare const DEFAULT_USER_PREFERENCES: UserPreferences;
36
+ /** Local storage keys of the settings. A number scale is kept as its member value ("0", "1"), a row click as "1" or "0". */
37
+ export declare const PREFERENCE_STORAGE_KEYS: {
38
+ readonly language: "nx-language";
39
+ readonly numberScale: "nx-number-scale";
40
+ readonly thousands: "nx-thousands";
41
+ readonly pageSize: "nx-page-size";
42
+ readonly rowClick: "nx-row-click";
43
+ };
@@ -0,0 +1,28 @@
1
+ /*
2
+ * The person's own settings beyond the theme and the dates: the interface language, how numbers are written, and what a
3
+ * table does before anyone touches its own controls. Saved on this device and read by the components that show them,
4
+ * so a change applies to everything on the page at once. The date settings are `DatePreferences` (DateInterfaces.ts).
5
+ */
6
+ /**
7
+ * The core's `ENumberScales`, by member value: where the groups of a number fall. `International` writes 1,234,567
8
+ * (thousand, million, billion); `SouthAsian` writes 12,34,567 (thousand, lakh, crore).
9
+ */
10
+ export const ENumberScales = {
11
+ International: 0,
12
+ SouthAsian: 1,
13
+ };
14
+ export const DEFAULT_USER_PREFERENCES = {
15
+ language: "en",
16
+ numberScale: ENumberScales.International,
17
+ thousands: "comma",
18
+ pageSize: null,
19
+ rowClick: null,
20
+ };
21
+ /** Local storage keys of the settings. A number scale is kept as its member value ("0", "1"), a row click as "1" or "0". */
22
+ export const PREFERENCE_STORAGE_KEYS = {
23
+ language: "nx-language",
24
+ numberScale: "nx-number-scale",
25
+ thousands: "nx-thousands",
26
+ pageSize: "nx-page-size",
27
+ rowClick: "nx-row-click",
28
+ };