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
@@ -1,11 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconArrowDown, iconArrowUp, iconCopy, iconDotsVertical, iconRowInsertBottom, iconRowInsertTop, iconTrash, iconX, InlineIcon } from "nexus-icons";
3
+ import { iconArrowDown, iconArrowUp, iconCopy, iconDotsVertical, iconLock, iconLockOpen, iconRowInsertBottom, iconRowInsertTop, iconTrash, InlineIcon } from "nexus-icons";
4
4
  import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
- import { Button } from "../Documents/Button.js";
7
6
  import { Menu } from "../Documents/Menu.js";
8
- import { firstFocusable, labelText, RowCell, rowName, RowsFooter, RowsLegend, useRowsFrame, useRowsInput, useRowVersion } from "./RowsInput.js";
7
+ import { firstFocusable, labelText, RowCell, RowsFooter, RowsLegend, useRowsFrame, useRowsInput, useRowVersion } from "./RowsInput.js";
9
8
  /** Height of a row: the input's box and the line under it. Measured from the first row once it renders. */
10
9
  const ROW_HEIGHT = { sm: 29, md: 35, lg: 41 };
11
10
  /** Rows rendered above and below the ones in view, so Tab and Enter always find the next row on the page. */
@@ -36,6 +35,8 @@ const COLUMN_WIDTH = {
36
35
  };
37
36
  /** Columns that keep their width instead of sharing the spare room. */
38
37
  const FIXED_TYPES = new Set(["checkbox", "switch", "radio"]);
38
+ /** What a locked row's cells say when one is used. */
39
+ const LOCKED_ROW = "Locked: unlock the row to change it.";
39
40
  const ENTER_MOVES = new Set(["text", "email", "tel", "url", "search", "password", "number"]);
40
41
  const cssLength = (value) => (typeof value === "number" ? `${value}px` : value);
41
42
  /** How a cell lines up: numbers at the end, checkboxes and radios in the middle, the rest at the start. */
@@ -52,23 +53,30 @@ function columnWidth(params) {
52
53
  /** One row of inputs. It renders again only when its position, its messages, or a value set from code change. */
53
54
  const TabularRow = memo(function TabularRow({ store, rowKey, index, count, columns, context, onAction }) {
54
55
  useRowVersion(store, rowKey);
55
- const name = rowName(store, index);
56
56
  const title = `${store.names.title} ${index + 1}`;
57
57
  const invalid = context.showAll && store.rowProblems(rowKey) > 0;
58
58
  const items = [];
59
- if (context.canAdd) {
59
+ // A row nobody may change (an inherited field) has nothing to offer: no copy of it, no move, no removal.
60
+ const fixed = Boolean(context.rowLock(rowKey, index));
61
+ if (context.canAdd && !fixed) {
60
62
  items.push({ value: "insert-above", label: "Insert above", icon: _jsx(InlineIcon, { icon: iconRowInsertTop }) }, { value: "insert-below", label: "Insert below", icon: _jsx(InlineIcon, { icon: iconRowInsertBottom }) }, { value: "duplicate", label: "Duplicate", icon: _jsx(InlineIcon, { icon: iconCopy }) });
61
63
  }
62
- if (context.sortable) {
64
+ if (context.sortable && !fixed) {
63
65
  if (items.length)
64
66
  items.push({ separator: true });
65
- items.push({ value: "move-up", label: "Move up", icon: _jsx(InlineIcon, { icon: iconArrowUp }), disabled: index === 0 }, { value: "move-down", label: "Move down", icon: _jsx(InlineIcon, { icon: iconArrowDown }), disabled: index === count - 1 });
67
+ items.push({ value: "move-up", label: "Move up", icon: _jsx(InlineIcon, { icon: iconArrowUp }), disabled: index === 0 || Boolean(context.rowLock(store.keyAt(index - 1), index - 1)) }, { value: "move-down", label: "Move down", icon: _jsx(InlineIcon, { icon: iconArrowDown }), disabled: index === count - 1 });
66
68
  }
67
- if (context.removable) {
69
+ // At the end of the row, Remove and Lock are buttons of their own, as a data table's are; the menu holds the rest.
70
+ const atEnd = context.actionsAt === "end";
71
+ const locked = context.isLocked(rowKey);
72
+ const removable = context.removable && !fixed;
73
+ const lockable = context.lockable && !fixed;
74
+ if (removable && !atEnd) {
68
75
  if (items.length)
69
76
  items.push({ separator: true });
70
77
  items.push({ value: "remove", label: "Remove", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" });
71
78
  }
79
+ const menu = items.length > 0 && (_jsx(Menu, { items: items, align: atEnd ? "end" : undefined, onSelect: value => onAction(value, rowKey), "aria-label": `${title} actions`, children: _jsx("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `${title} actions`, title: `${title} actions`, "data-nx-row-actions": "", children: _jsx(InlineIcon, { icon: iconDotsVertical }) }) }));
72
80
  // Leaving an input counts as using it: a repeated value shows its message from then on.
73
81
  function onBlur(event) {
74
82
  const cell = event.target.closest("[data-nx-cell]");
@@ -76,7 +84,7 @@ const TabularRow = memo(function TabularRow({ store, rowKey, index, count, colum
76
84
  return;
77
85
  store.touch(rowKey, cell.dataset.nxCell ?? "");
78
86
  }
79
- return (_jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": index + 2, "data-nx-row": rowKey, "data-problem": invalid || undefined, onBlur: onBlur, children: [context.numbered && (_jsx("div", { role: "cell", className: "nx-tabular__num", children: items.length > (context.removable ? 1 : 0) ? (_jsx(Menu, { items: items, onSelect: value => onAction(value, rowKey), "aria-label": `${title} actions`, children: _jsxs("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `${title} actions`, title: `${title} actions`, "data-nx-row-actions": "", children: [_jsx("span", { className: "nx-tabular__num-text", children: index + 1 }), _jsx(InlineIcon, { icon: iconDotsVertical, className: "nx-tabular__num-icon" })] }) })) : (_jsx("span", { className: "nx-tabular__num-text", children: index + 1 })) })), columns.map(params => (_jsx("div", { role: "cell", className: cx("nx-tabular__cell", `nx-tabular__cell--${cellKind(params)}`), "data-nx-cell": params.name, children: _jsx(RowCell, { store: store, rowKey: rowKey, params: params, index: index, error: store.shownError(rowKey, params.name), layout: "tabular", idPrefix: context.idPrefix, size: context.size, readOnly: context.readOnly, disabled: context.disabled }, store.revision(rowKey, params.name)) }, params.name))), context.removable && (_jsx("div", { role: "cell", className: "nx-tabular__end", children: _jsx(Button, { variant: "ghost", size: "sm", hoverTone: "danger", className: "nx-tabular__remove", icon: _jsx(InlineIcon, { icon: iconX }), "aria-label": `Remove ${name}`, title: `Remove ${name}`, onClick: () => onAction("remove", rowKey) }) }))] }));
87
+ return (_jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": index + 2, "data-nx-row": rowKey, "data-problem": invalid || undefined, onBlur: onBlur, children: [context.numbered && (_jsxs("div", { role: "cell", className: "nx-tabular__num", children: [_jsx("span", { className: "nx-tabular__num-text", children: index + 1 }), !atEnd && menu] })), columns.map(params => (_jsx("div", { role: "cell", className: cx("nx-tabular__cell", `nx-tabular__cell--${cellKind(params)}`), "data-nx-cell": params.name, children: _jsx(RowCell, { store: store, rowKey: rowKey, params: context.cellParams(rowKey, params, index), index: index, error: store.shownError(rowKey, params.name), layout: "tabular", idPrefix: context.idPrefix, size: context.size, readOnly: context.readOnly, disabled: context.disabled, lock: context.cellLock(rowKey, params.name, index) }, store.revision(rowKey, params.name)) }, params.name))), atEnd && (_jsxs("div", { role: "cell", className: "nx-tabular__actions", children: [removable && (_jsx("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `Remove ${title}`, title: locked ? "Unlock the row to remove it" : "Remove", "data-tone": "danger", "data-nx-row-actions": "", disabled: locked || context.disabled, onClick: () => onAction("remove", rowKey), children: _jsx(InlineIcon, { icon: iconTrash }) })), lockable && (_jsx("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `${locked ? "Unlock" : "Lock"} ${title}`, "aria-pressed": locked, title: locked ? "Locked: unlock to change it" : "Lock, so it cannot be changed", "data-nx-row-actions": "", disabled: context.disabled, onClick: () => onAction(locked ? "unlock" : "lock", rowKey), children: _jsx(InlineIcon, { icon: locked ? iconLock : iconLockOpen }) })), menu] }))] }));
80
88
  });
81
89
  /**
82
90
  * Rows of inputs in a table, one column per input: a person's emails or addresses, invoice lines, a salary sheet. The
@@ -84,9 +92,13 @@ const TabularRow = memo(function TabularRow({ store, rowKey, index, count, colum
84
92
  * come as params, the same objects a form takes, so the same list can show as sections in a `GroupForm`.
85
93
  *
86
94
  * Only the rows in view are on the page, so a sheet of thousands of rows opens and types as fast as a short one; the
87
- * values live in a store outside React, and typing renders nothing. The header, the row numbers, and the remove buttons
88
- * stay in place while the table scrolls. A row number opens the row's menu (insert, duplicate, move, remove). Enter in
89
- * a text or number cell moves down the column, and on the last row adds a row.
95
+ * values live in a store outside React, and typing renders nothing. The header and the numbered column stay in place
96
+ * while the table scrolls. Each row has one control, beside its number and never in its place: a menu of insert,
97
+ * duplicate, move and remove. Enter in a text or number cell moves down the column, and on the last row adds a row.
98
+ *
99
+ * Every row is one line high, whatever it holds, so nothing shifts as the table is used; a column generated for every
100
+ * row (`cellReadOnly`) says so once on its header rather than on each of its cells; and `maxHeight` is brought down
101
+ * to whole rows, because a scroller that cuts the last one in half reads as broken rather than as scrollable.
90
102
  *
91
103
  * Inside a `<form>`, the rows are sent as JSON under `name`; a row with a problem blocks submit, and the first one
92
104
  * scrolls into view with focus.
@@ -104,6 +116,10 @@ export function TabularForm(props) {
104
116
  const addable = props.addable !== false && !readOnly;
105
117
  const removable = props.removable !== false && !readOnly && !disabled;
106
118
  const sortable = Boolean(props.sortable) && !readOnly && !disabled;
119
+ const actionsAt = props.actionsAt ?? "number";
120
+ const lockable = Boolean(props.lockable) && !readOnly;
121
+ /** The rows the person locked, by key: the screen's, never the value's. */
122
+ const [lockedKeys, setLockedKeys] = useState(() => new Set());
107
123
  const layoutRef = useRef(null);
108
124
  const rows = useRowsInput(props, "Row", layoutRef);
109
125
  const store = rows.store;
@@ -115,6 +131,7 @@ export function TabularForm(props) {
115
131
  const headRef = useRef(null);
116
132
  const bodyRef = useRef(null);
117
133
  const [rowHeight, setRowHeight] = useState(ROW_HEIGHT[size]);
134
+ const [viewportMax, setViewportMax] = useState(null);
118
135
  const pageRows = Math.ceil((typeof maxHeight === "number" ? maxHeight : 600) / rowHeight);
119
136
  const [range, setRange] = useState({ start: 0, end: pageRows + OVERSCAN });
120
137
  const [status, setStatus] = useState(null);
@@ -148,6 +165,27 @@ export function TabularForm(props) {
148
165
  if (height && height !== rowHeight)
149
166
  setRowHeight(height);
150
167
  });
168
+ /**
169
+ * `maxHeight`, brought down to whole rows. A scroller that cuts the last row in half reads as broken rather than as
170
+ * scrollable, and the header is not a row, so the arithmetic has to leave it out — as it does the frame and the
171
+ * horizontal scroll bar, neither of which is a row either.
172
+ */
173
+ useLayoutEffect(() => {
174
+ const viewport = viewportRef.current;
175
+ const wanted = typeof maxHeight === "number" ? maxHeight : null;
176
+ if (!viewport || wanted === null || count === 0) {
177
+ if (viewportMax !== null)
178
+ setViewportMax(null);
179
+ return;
180
+ }
181
+ const head = headRef.current?.offsetHeight ?? 0;
182
+ const chrome = viewport.offsetHeight - viewport.clientHeight;
183
+ const room = wanted - chrome - head;
184
+ // Not even one row fits: leave what was asked for rather than showing a header and a sliver.
185
+ const next = room < rowHeight ? null : chrome + head + Math.floor(room / rowHeight) * rowHeight;
186
+ if (next !== viewportMax)
187
+ setViewportMax(next);
188
+ });
151
189
  // A resize, a new row height, or fewer rows change which rows are in view.
152
190
  useLayoutEffect(() => {
153
191
  const viewport = viewportRef.current;
@@ -212,9 +250,22 @@ export function TabularForm(props) {
212
250
  run.move(key, -1);
213
251
  else if (action === "move-down")
214
252
  run.move(key, 1);
253
+ else if (action === "lock" || action === "unlock") {
254
+ setLockedKeys(current => {
255
+ const next = new Set(current);
256
+ if (action === "lock")
257
+ next.add(key);
258
+ else
259
+ next.delete(key);
260
+ return next;
261
+ });
262
+ }
215
263
  else
216
264
  run.remove(key);
217
265
  }, [store]);
266
+ const isLocked = useCallback((key) => lockedKeys.has(key), [lockedKeys]);
267
+ // A locked row's cells are read-only with the lock's reason, as a row nobody may change has its own.
268
+ const cellLock = useCallback((key, name, index) => (lockedKeys.has(key) ? LOCKED_ROW : rows.cellLock(key, name, index)), [lockedKeys, rows.cellLock]);
218
269
  /** The message or hint of the input with focus, shown under the table: cells have no room for their own. */
219
270
  function readStatus() {
220
271
  const active = document.activeElement;
@@ -260,8 +311,50 @@ export function TabularForm(props) {
260
311
  layoutRef.current?.focusRow(key, name);
261
312
  }
262
313
  }
263
- const context = useMemo(() => ({ idPrefix: frame.id, size, readOnly, disabled, numbered, removable, sortable, canAdd: rows.canAdd, showAll: snapshot.showAll }), [frame.id, size, readOnly, disabled, numbered, removable, sortable, rows.canAdd, snapshot.showAll]);
264
- const numberWidth = `calc(${Math.max(2, String(count).length)}ch + 24px)`;
314
+ const context = useMemo(() => ({ idPrefix: frame.id, size, readOnly, disabled, numbered, removable, sortable, canAdd: rows.canAdd, showAll: snapshot.showAll, cellLock, cellParams: rows.cellParams, rowLock: rows.rowLock, actionsAt, lockable, isLocked }), [frame.id, size, readOnly, disabled, numbered, removable, sortable, rows.canAdd, snapshot.showAll, cellLock, rows.cellParams, rows.rowLock, actionsAt, lockable, isLocked]);
315
+ /**
316
+ * Which columns are generated for **every** row on the page. The lock is then said once, on the column header,
317
+ * instead of on each of its cells: eighteen locks down one column is eighteen times the ink for one fact. The cells
318
+ * keep their read-only tint and their reason, which shows under the table when one is used.
319
+ *
320
+ * Only the rows in view are asked, which is what a column-wide rule (`cellReadOnly` on a name) answers the same way
321
+ * however the table is scrolled. A lock that really is per row leaves the column unlocked, which is what it means.
322
+ */
323
+ const lockedColumns = useMemo(() => {
324
+ const locked = new Map();
325
+ for (const params of columns) {
326
+ if (readOnly || params.readOnly === true) {
327
+ locked.set(params.name, true);
328
+ continue;
329
+ }
330
+ if (!shownKeys.length)
331
+ continue;
332
+ let reason;
333
+ for (const [offset, key] of shownKeys.entries()) {
334
+ // A locked row says nothing about its columns: the rows around it decide whether a column is generated.
335
+ if (lockedKeys.has(key) || rows.rowLock(key, start + offset))
336
+ continue;
337
+ const lock = rows.cellLock(key, params.name, start + offset);
338
+ if (!lock) {
339
+ reason = undefined;
340
+ break;
341
+ }
342
+ if (reason === undefined)
343
+ reason = typeof lock === "string" ? lock : true;
344
+ }
345
+ if (reason !== undefined)
346
+ locked.set(params.name, reason);
347
+ }
348
+ return locked;
349
+ // `shownKeys` is a fresh array on every render; the work is a call per visible cell, and nothing memoised is
350
+ // handed to the rows, so recomputing it costs a map and never a re-render of a row.
351
+ }, [columns, readOnly, rows.cellLock, rows.rowLock, shownKeys, start, lockedKeys]);
352
+ // The number and the row's menu, side by side, plus the frame around them. Both are always there, so nothing moves.
353
+ const atEnd = actionsAt === "end";
354
+ const numberWidth = atEnd ? `calc(${Math.max(2, String(count).length)}ch + 16px)` : `calc(${Math.max(2, String(count).length)}ch + 18px + var(--nx-tabular-menu))`;
355
+ // At the end: a button for Remove, one for Lock, and one for the menu when there is anything else in it.
356
+ const buttons = atEnd ? Number(removable) + Number(lockable) + Number(addable || sortable) : 0;
357
+ const actionsWidth = buttons ? `calc(${buttons} * var(--nx-tabular-menu) + ${buttons - 1} * 2px + 9px)` : null;
265
358
  const widths = columns.map(columnWidth);
266
359
  const template = [
267
360
  numbered ? numberWidth : null,
@@ -269,15 +362,19 @@ export function TabularForm(props) {
269
362
  const grow = params.grow ?? (FIXED_TYPES.has(params.type) ? 0 : 1);
270
363
  return grow > 0 ? `minmax(${widths[index]}, ${grow}fr)` : widths[index];
271
364
  }),
272
- removable ? "var(--nx-tabular-end)" : null,
365
+ actionsWidth,
273
366
  ]
274
367
  .filter(Boolean)
275
368
  .join(" ");
276
- const minWidth = `calc(${[numbered ? numberWidth : "0px", ...widths, removable ? "var(--nx-tabular-end)" : "0px"].join(" + ")})`;
277
- const tableStyle = { "--nx-tabular-columns": template, minWidth };
369
+ const minWidth = `calc(${[numbered ? numberWidth : "0px", ...widths, actionsWidth ?? "0px"].join(" + ")})`;
370
+ const tableStyle = { "--nx-tabular-columns": template, "--nx-tabular-num": numbered ? numberWidth : "0px", minWidth };
278
371
  const invalid = Boolean(error || snapshot.message || snapshot.invalidRows);
279
- return (_jsxs("fieldset", { id: frame.id, className: cx("nx-rows", "nx-tabular", `nx-tabular--${size}`, className), style: style, disabled: disabled, "data-problem": invalid || undefined, "data-readonly": readOnly || undefined, "aria-label": props["aria-label"], "aria-describedby": frame.messageId, children: [_jsx(RowsLegend, { id: frame.legendId, label: label, required: required || (props.min ?? 0) > 0, lock: frame.lock }), _jsx("input", { ref: rows.proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", form: props.form, onInvalid: rows.onInvalid }), _jsx("div", { ref: viewportRef, className: "nx-tabular__viewport", tabIndex: -1, style: { maxHeight: cssLength(maxHeight) }, onScroll: updateRange, children: _jsxs("div", { role: "table", className: "nx-tabular__table", style: tableStyle, "aria-labelledby": label ? frame.legendId : undefined, "aria-label": label ? undefined : props["aria-label"], "aria-rowcount": count + 1, children: [_jsx("div", { ref: headRef, role: "rowgroup", className: "nx-tabular__head", children: _jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": 1, children: [numbered && (_jsxs("div", { role: "columnheader", className: "nx-tabular__num", children: [_jsx("span", { "aria-hidden": "true", children: "#" }), _jsx("span", { className: "nx-visually-hidden", children: store.names.title })] })), columns.map(params => {
372
+ return (_jsxs("fieldset", { id: frame.id, className: cx("nx-rows", "nx-tabular", `nx-tabular--${size}`, className), style: style, disabled: disabled, "data-problem": invalid || undefined, "data-readonly": readOnly || undefined, "data-actions-at": atEnd ? "end" : undefined, "aria-label": props["aria-label"], "aria-describedby": frame.messageId, children: [_jsx(RowsLegend, { id: frame.legendId, label: label, required: required || (props.min ?? 0) > 0, lock: frame.lock }), _jsx("input", { ref: rows.proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", form: props.form, onInvalid: rows.onInvalid }), _jsx("div", { ref: viewportRef, className: "nx-tabular__viewport", tabIndex: -1,
373
+ // A focused cell scrolls clear of the actions fixed at the end, as it does of the number at the start.
374
+ style: { maxHeight: viewportMax ?? cssLength(maxHeight), scrollPaddingRight: actionsWidth ?? undefined }, onScroll: updateRange, children: _jsxs("div", { role: "table", className: "nx-tabular__table", style: tableStyle, "aria-labelledby": label ? frame.legendId : undefined, "aria-label": label ? undefined : props["aria-label"], "aria-rowcount": count + 1, children: [_jsx("div", { ref: headRef, role: "rowgroup", className: "nx-tabular__head", children: _jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": 1, children: [numbered && (_jsxs("div", { role: "columnheader", className: "nx-tabular__num", children: [_jsx("span", { "aria-hidden": "true", children: "#" }), _jsx("span", { className: "nx-visually-hidden", children: store.names.title })] })), columns.map(params => {
280
375
  const columnHint = params.hint;
281
- return (_jsxs("div", { role: "columnheader", className: cx("nx-tabular__head-cell", `nx-tabular__cell--${cellKind(params)}`), title: typeof columnHint === "string" ? columnHint : undefined, children: [_jsx("span", { className: "nx-tabular__head-label", children: params.label ?? params.name }), params.required && (_jsx("span", { className: "nx-field__required", "aria-hidden": "true", children: "*" }))] }, params.name));
282
- }), removable && (_jsx("div", { role: "columnheader", className: "nx-tabular__end", children: _jsx("span", { className: "nx-visually-hidden", children: "Remove" }) }))] }) }), _jsx("div", { ref: bodyRef, role: "rowgroup", className: "nx-tabular__body", style: { paddingTop: start * rowHeight, paddingBottom: Math.max(0, count - end) * rowHeight }, onKeyDown: onBodyKeyDown, onFocus: readStatus, onBlur: readStatusSoon, onInput: readStatusSoon, children: count === 0 ? (_jsx("div", { role: "row", className: "nx-tabular__row nx-tabular__row--empty", children: _jsx("div", { role: "cell", className: "nx-tabular__empty", children: frame.emptyMessage }) })) : (shownKeys.map((key, offset) => (_jsx(TabularRow, { store: store, rowKey: key, index: start + offset, count: count, columns: columns, context: context, onAction: onAction }, `${snapshot.generation}:${key}`)))) })] }) }), _jsx(RowsFooter, { messageId: frame.messageId, store: store, snapshot: snapshot, error: error, hint: hint, status: status, addLabel: frame.addLabel, addable: addable, canAdd: rows.canAdd, max: max, maxMessage: frame.maxMessage, size: size, onAdd: () => rows.actions.add(), onUndo: () => rows.actions.undo() })] }));
376
+ const locked = lockedColumns.get(params.name);
377
+ const reason = typeof locked === "string" ? locked : locked ? "Read-only" : undefined;
378
+ return (_jsxs("div", { role: "columnheader", className: cx("nx-tabular__head-cell", `nx-tabular__cell--${cellKind(params)}`), title: reason ?? (typeof columnHint === "string" ? columnHint : undefined), "data-nx-locked": locked ? "" : undefined, children: [_jsx("span", { className: "nx-tabular__head-label", children: params.label ?? params.name }), params.required && !locked && (_jsx("span", { className: "nx-field__required", "aria-hidden": "true", children: "*" })), locked && _jsx(InlineIcon, { icon: iconLock, className: "nx-tabular__head-lock", "aria-hidden": "true" })] }, params.name));
379
+ }), actionsWidth && (_jsx("div", { role: "columnheader", className: "nx-tabular__actions", children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }) }), _jsx("div", { ref: bodyRef, role: "rowgroup", className: "nx-tabular__body", style: { paddingTop: start * rowHeight, paddingBottom: Math.max(0, count - end) * rowHeight }, onKeyDown: onBodyKeyDown, onFocus: readStatus, onBlur: readStatusSoon, onInput: readStatusSoon, children: count === 0 ? (_jsx("div", { role: "row", className: "nx-tabular__row nx-tabular__row--empty", children: _jsx("div", { role: "cell", className: "nx-tabular__empty", children: frame.emptyMessage }) })) : (shownKeys.map((key, offset) => (_jsx(TabularRow, { store: store, rowKey: key, index: start + offset, count: count, columns: columns, context: context, onAction: onAction }, `${snapshot.generation}:${key}`)))) })] }) }), _jsx(RowsFooter, { messageId: frame.messageId, store: store, snapshot: snapshot, error: error, hint: hint, status: status, addLabel: frame.addLabel, addable: addable, canAdd: rows.canAdd, max: max, maxMessage: frame.maxMessage, size: size, onAdd: () => rows.actions.add(), onUndo: () => rows.actions.undo() })] }));
283
380
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useLayoutEffect, useMemo, useRef } from "react";
4
+ import { smoothScrollAllowed } from "../../Helpers/AnimationHelpers.js";
4
5
  import { cx } from "../../Helpers/ClassHelpers.js";
5
6
  import { toDevanagariDigits } from "../../Helpers/DateHelpers.js";
6
7
  import { formatTime, getMeridiemNames, isSecondsInRange, isTwelveHourFormat, listTimeSlots, normalizeTimeStep, nowIsoTime, SECONDS_PER_DAY, secondsToIsoTime, timeToSeconds, toIsoTime, } from "../../Helpers/TimeHelpers.js";
@@ -157,15 +158,15 @@ export function TimePanel(props) {
157
158
  return (slots.find(slot => !slot.disabled) ?? slots[0]).value;
158
159
  }, [view, slots, selected]);
159
160
  // Chosen items scroll to the middle row of their column, so they line up across columns with their neighbours above
160
- // and below; the list centers its slot the same way. At once when the panel opens, smoothly after that, unless the
161
- // user asked for reduced motion. (Columns are positioned, so an item's offsetTop is measured from the column.)
161
+ // and below; the list centers its slot the same way. At once when the panel opens, smoothly after that, unless motion
162
+ // is off. (Columns are positioned, so an item's offsetTop is measured from the column.)
162
163
  // With `autoFocus`, focus then moves in once.
163
164
  useLayoutEffect(() => {
164
165
  const root = rootRef.current;
165
166
  if (!root)
166
167
  return;
167
168
  const align = () => {
168
- const smooth = scrolledRef.current && !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
169
+ const smooth = scrolledRef.current && smoothScrollAllowed();
169
170
  for (const column of root.querySelectorAll("[data-column]")) {
170
171
  const target = column.querySelector(view === "list" ? "[data-anchor]" : '[aria-selected="true"]');
171
172
  if (!target)
@@ -111,7 +111,8 @@ export function TimePicker(props) {
111
111
  return;
112
112
  if (!isPopoverOpen(panel))
113
113
  panel.showPopover();
114
- const place = () => placeUnderAnchor(panel, box.getBoundingClientRect());
114
+ // No arrow: the panel opens flush under the box and is read as the field's own list.
115
+ const place = () => placeUnderAnchor(panel, box.getBoundingClientRect(), { arrow: false });
115
116
  place();
116
117
  const stopFollowing = followAnchor(place);
117
118
  const onPointerDown = (event) => {
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { iconMinus, iconSquare, iconSquares, iconX, InlineIcon } from "nexus-icons";
4
4
  import { useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
5
5
  import { flushSync } from "react-dom";
6
- import { afterAnimation } from "../../Helpers/AnimationHelpers.js";
6
+ import { afterAnimation, motionAllowed } from "../../Helpers/AnimationHelpers.js";
7
7
  import { cx } from "../../Helpers/ClassHelpers.js";
8
8
  import { cssVariablePosition, DRAG_CANCEL_SELECTOR, moveWithin, startDrag, viewportBounds } from "../../Helpers/DragHelpers.js";
9
9
  import { HostedPopupContext } from "./PopupContext.js";
@@ -12,9 +12,6 @@ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([t
12
12
  // Where a minimized popup flies to: the newest bar of the dock, at the bottom right.
13
13
  const DOCK_EDGE = 16;
14
14
  const DOCK_BAR = { width: 300, height: 44 };
15
- function motionAllowed() {
16
- return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
17
- }
18
15
  function popupPosition(dialog) {
19
16
  return cssVariablePosition(dialog, "--nx-popup-x", "--nx-popup-y");
20
17
  }
@@ -4,10 +4,27 @@ export interface ThemePickerProps {
4
4
  themes?: readonly ThemeOption[];
5
5
  /** Show the Light / Dark / System choice. Default `true`. */
6
6
  showMode?: boolean;
7
+ /** Show Compact / Cozy. Default `true`. */
8
+ showDensity?: boolean;
9
+ /** Show Animations: On / Off / System. Default `true`. */
10
+ showAnimations?: boolean;
11
+ /** Show the scale grid, a development tool. Default: in a development build only. */
12
+ showGrid?: boolean;
7
13
  className?: string;
8
14
  }
9
15
  /**
10
- * Light / Dark / System, and a card per theme with a live preview of its colors.
11
- * The choice applies at once and is saved in the browser.
16
+ * Light / Dark / System, a card per theme with a live preview of its colors, the density, the animations and, in a
17
+ * development build, the scale grid. Each choice applies at once and is saved in the browser.
12
18
  */
13
- export declare function ThemePicker({ themes, showMode, className }: ThemePickerProps): import("react").JSX.Element;
19
+ export declare function ThemePicker({ themes, showMode, showDensity, showAnimations, showGrid, className }: ThemePickerProps): import("react").JSX.Element;
20
+ export interface PreferencesPickerProps {
21
+ className?: string;
22
+ }
23
+ /**
24
+ * The person's quick preferences, each applied at once and saved on this device: the interface language and the
25
+ * calendar; the date picker's language, the clock and the date format (the date settings every date component reads);
26
+ * the number scale and the thousands separator (every number box, slider and table number without a locale of its
27
+ * own); and the page size tables open with and whether a row click opens a record (every table, once chosen, over what
28
+ * the table names itself). `ThemeSwitcher` shows it under the theme.
29
+ */
30
+ export declare function PreferencesPicker({ className }: PreferencesPickerProps): import("react").JSX.Element;
@@ -1,22 +1,93 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconCheck, iconDeviceDesktop, iconMoon, iconSun, InlineIcon } from "nexus-icons";
3
+ import { iconArrowsMaximize, iconArrowsMinimize, iconCheck, iconCircleCheck, iconCircleX, iconDeviceDesktop, iconEyeOff, iconLayoutGrid, iconMoon, iconSun, iconTable, InlineIcon, } from "nexus-icons";
4
4
  import { useId } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { formatDate } from "../../Helpers/DateHelpers.js";
7
+ import { setDatePreferences, useDatePreferences } from "../../Helpers/DatePreferences.js";
8
+ import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES } from "../../Helpers/TableHelpers.js";
9
+ import { isDevelopmentBuild } from "../../Helpers/ThemeHelpers.js";
10
+ import { setUserPreferences, useUserPreferences } from "../../Helpers/UserPreferences.js";
11
+ import { DATE_FORMATS, DEFAULT_TIME_FORMAT } from "../../Interfaces/DateInterfaces.js";
12
+ import { ENumberScales } from "../../Interfaces/PreferenceInterfaces.js";
6
13
  import { NEXUS_THEMES } from "../../Interfaces/ThemeInterfaces.js";
7
14
  import { useTheme } from "../../Services/ThemeService.js";
8
15
  import { TabButtons } from "../Documents/TabButtons.js";
16
+ import { Switch } from "../Inputs/CheckBox.js";
17
+ import { Dropdown } from "../Inputs/Dropdown.js";
9
18
  const MODES = [
10
19
  { value: "light", label: "Light", icon: _jsx(InlineIcon, { icon: iconSun }) },
11
20
  { value: "dark", label: "Dark", icon: _jsx(InlineIcon, { icon: iconMoon }) },
12
21
  { value: "system", label: "System", icon: _jsx(InlineIcon, { icon: iconDeviceDesktop }), title: "Follow the operating system" },
13
22
  ];
23
+ const DENSITIES = [
24
+ { value: "compact", label: "Compact", icon: _jsx(InlineIcon, { icon: iconArrowsMinimize }) },
25
+ { value: "cozy", label: "Cozy", icon: _jsx(InlineIcon, { icon: iconArrowsMaximize }), title: "Taller controls and rows, for touch screens and large monitors" },
26
+ ];
27
+ const ANIMATIONS = [
28
+ { value: "on", label: "On", icon: _jsx(InlineIcon, { icon: iconCircleCheck }), title: "Animate, even when the operating system asks for less motion" },
29
+ { value: "off", label: "Off", icon: _jsx(InlineIcon, { icon: iconCircleX }), title: "Nothing moves" },
30
+ { value: "system", label: "System", icon: _jsx(InlineIcon, { icon: iconDeviceDesktop }), title: "Follow the operating system's animations setting" },
31
+ ];
32
+ const GRIDS = [
33
+ { value: "off", label: "Off", icon: _jsx(InlineIcon, { icon: iconEyeOff }) },
34
+ { value: "px", label: "px", icon: _jsx(InlineIcon, { icon: iconLayoutGrid }), title: "A line every 4px, a solid one every 48px" },
35
+ { value: "cm", label: "mm", icon: _jsx(InlineIcon, { icon: iconTable }), title: "A line every millimetre, a solid one every centimetre" },
36
+ ];
37
+ /** A small heading and what it names: a row of tab buttons by default, or the control given. */
38
+ function Choice({ id, legend, options, value, onChange, children }) {
39
+ return (_jsxs("div", { className: "nx-theme-picker__group", children: [_jsx("p", { id: id, className: "nx-theme-picker__legend", children: legend }), children ?? (options && value !== undefined && onChange && _jsx(TabButtons, { "aria-labelledby": id, name: id, options: options, value: value, onChange: onChange, block: true }))] }));
40
+ }
41
+ /**
42
+ * Light / Dark / System, a card per theme with a live preview of its colors, the density, the animations and, in a
43
+ * development build, the scale grid. Each choice applies at once and is saved in the browser.
44
+ */
45
+ export function ThemePicker({ themes = NEXUS_THEMES, showMode = true, showDensity = true, showAnimations = true, showGrid = isDevelopmentBuild(), className }) {
46
+ const { theme, mode, density, animations, grid, setTheme, setMode, setDensity, setAnimations, setGrid } = useTheme();
47
+ const name = useId();
48
+ return (_jsxs("div", { className: cx("nx-theme-picker", className), children: [showMode && _jsx(Choice, { id: `${name}-mode`, legend: "Mode", options: MODES, value: mode, onChange: setMode }), _jsxs("fieldset", { className: "nx-theme-picker__group", children: [_jsx("legend", { className: "nx-theme-picker__legend", children: "Theme" }), _jsx("div", { className: "nx-theme-picker__grid", children: themes.map(option => (_jsxs("label", { className: "nx-theme-option", title: option.description, children: [_jsx("input", { type: "radio", className: "nx-visually-hidden", name: `${name}-theme`, value: option.id, checked: theme === option.id, onChange: () => setTheme(option.id) }), _jsx("span", { className: "nx-theme-option__preview", "data-nx-theme": option.id, "aria-hidden": "true", children: _jsxs("span", { className: "nx-theme-option__window", children: [_jsx("span", { className: "nx-theme-option__line" }), _jsx("span", { className: "nx-theme-option__line nx-theme-option__line--muted" }), _jsxs("span", { className: "nx-theme-option__swatches", children: [_jsx("span", { className: "nx-theme-option__pill" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--success" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--warning" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--danger" })] })] }) }), _jsxs("span", { className: "nx-theme-option__name", children: [option.name, _jsx(InlineIcon, { icon: iconCheck, className: "nx-theme-option__check" })] })] }, option.id))) })] }), showDensity && _jsx(Choice, { id: `${name}-density`, legend: "Density", options: DENSITIES, value: density, onChange: setDensity }), showAnimations && _jsx(Choice, { id: `${name}-animations`, legend: "Animations", options: ANIMATIONS, value: animations, onChange: setAnimations }), showGrid && _jsx(Choice, { id: `${name}-grid`, legend: "Scale grid", options: GRIDS, value: grid, onChange: setGrid })] }));
49
+ }
50
+ /* The quick preferences: language, dates, numbers and tables, beside the theme. */
51
+ const LANGUAGES = [
52
+ { value: "en", label: "English" },
53
+ { value: "ne", label: _jsx("span", { lang: "ne", children: "\u0928\u0947\u092A\u093E\u0932\u0940" }), title: "Nepali" },
54
+ ];
55
+ const CALENDARS = [
56
+ { value: "ad", label: "AD", title: "Gregorian (AD)" },
57
+ { value: "bs", label: "BS", title: "Bikram Sambat (BS)" },
58
+ ];
59
+ const SCRIPTS = [
60
+ { value: "latin", label: "English", title: "Month names and digits in English" },
61
+ { value: "devanagari", label: _jsx("span", { lang: "ne", children: "\u0928\u0947\u092A\u093E\u0932\u0940" }), title: "Month names and digits in Nepali" },
62
+ ];
63
+ const CLOCKS = [
64
+ { value: "12", label: "12-hour", title: "2:30 PM" },
65
+ { value: "24", label: "24-hour", title: "14:30" },
66
+ ];
67
+ const SCALES = [
68
+ { value: String(ENumberScales.International), label: "International", title: "1,234,567: thousand, million, billion" },
69
+ { value: String(ENumberScales.SouthAsian), label: "South Asian", title: "12,34,567: thousand, lakh, crore" },
70
+ ];
71
+ const SEPARATORS = [
72
+ { value: "comma", label: "1,200", title: "Comma" },
73
+ { value: "period", label: "1.200", title: "Period, with a comma for decimals" },
74
+ { value: "space", label: "1\u00a0200", title: "Space" },
75
+ { value: "none", label: "1200", title: "None" },
76
+ ];
77
+ const PAGE_SIZES = DEFAULT_PAGE_SIZES.map(size => ({ value: size, label: `${size} rows` }));
78
+ // A fixed day, so the server and the browser write the same examples, and one past the 12th, so day and month differ.
79
+ const SAMPLE_DATE = "2026-09-27";
14
80
  /**
15
- * Light / Dark / System, and a card per theme with a live preview of its colors.
16
- * The choice applies at once and is saved in the browser.
81
+ * The person's quick preferences, each applied at once and saved on this device: the interface language and the
82
+ * calendar; the date picker's language, the clock and the date format (the date settings every date component reads);
83
+ * the number scale and the thousands separator (every number box, slider and table number without a locale of its
84
+ * own); and the page size tables open with and whether a row click opens a record (every table, once chosen, over what
85
+ * the table names itself). `ThemeSwitcher` shows it under the theme.
17
86
  */
18
- export function ThemePicker({ themes = NEXUS_THEMES, showMode = true, className }) {
19
- const { theme, mode, setTheme, setMode } = useTheme();
87
+ export function PreferencesPicker({ className }) {
88
+ const dates = useDatePreferences();
89
+ const preferences = useUserPreferences();
20
90
  const name = useId();
21
- return (_jsxs("div", { className: cx("nx-theme-picker", className), children: [showMode && (_jsxs("div", { className: "nx-theme-picker__group", children: [_jsx("p", { id: `${name}-mode`, className: "nx-theme-picker__legend", children: "Mode" }), _jsx(TabButtons, { "aria-labelledby": `${name}-mode`, name: `${name}-mode`, options: MODES, value: mode, onChange: setMode, block: true })] })), _jsxs("fieldset", { className: "nx-theme-picker__group", children: [_jsx("legend", { className: "nx-theme-picker__legend", children: "Theme" }), _jsx("div", { className: "nx-theme-picker__grid", children: themes.map(option => (_jsxs("label", { className: "nx-theme-option", title: option.description, children: [_jsx("input", { type: "radio", className: "nx-visually-hidden", name: `${name}-theme`, value: option.id, checked: theme === option.id, onChange: () => setTheme(option.id) }), _jsx("span", { className: "nx-theme-option__preview", "data-nx-theme": option.id, "aria-hidden": "true", children: _jsxs("span", { className: "nx-theme-option__window", children: [_jsx("span", { className: "nx-theme-option__line" }), _jsx("span", { className: "nx-theme-option__line nx-theme-option__line--muted" }), _jsxs("span", { className: "nx-theme-option__swatches", children: [_jsx("span", { className: "nx-theme-option__pill" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--success" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--warning" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--danger" })] })] }) }), _jsxs("span", { className: "nx-theme-option__name", children: [option.name, _jsx(InlineIcon, { icon: iconCheck, className: "nx-theme-option__check" })] })] }, option.id))) })] })] }));
91
+ const formats = DATE_FORMATS.map(format => ({ value: format, label: formatDate(SAMPLE_DATE, { ...dates, format }), description: format }));
92
+ return (_jsxs("div", { className: cx("nx-theme-picker", className), children: [_jsx("h3", { className: "nx-theme-picker__section", children: "Language and region" }), _jsx(Choice, { id: `${name}-language`, legend: "Interface language", options: LANGUAGES, value: preferences.language, onChange: language => setUserPreferences({ language }) }), _jsx(Choice, { id: `${name}-calendar`, legend: "Calendar", options: CALENDARS, value: dates.calendar, onChange: calendar => setDatePreferences({ calendar }) }), _jsx("h3", { className: "nx-theme-picker__section", children: "Dates and times" }), _jsx(Choice, { id: `${name}-script`, legend: "Date picker language", options: SCRIPTS, value: dates.script, onChange: script => setDatePreferences({ script }) }), _jsx(Choice, { id: `${name}-clock`, legend: "Clock", options: CLOCKS, value: /H/.test(dates.timeFormat) ? "24" : "12", onChange: clock => setDatePreferences({ timeFormat: clock === "24" ? "HH:mm" : DEFAULT_TIME_FORMAT }) }), _jsx(Choice, { id: `${name}-format`, legend: "Display date format", children: _jsx(Dropdown, { "aria-labelledby": `${name}-format`, options: formats, value: dates.format, clearable: false, searchable: false, onValueChange: format => format && setDatePreferences({ format }) }) }), _jsx("h3", { className: "nx-theme-picker__section", children: "Numbers" }), _jsx(Choice, { id: `${name}-scale`, legend: "Number scale", options: SCALES, value: String(preferences.numberScale), onChange: scale => setUserPreferences({ numberScale: Number(scale) === ENumberScales.SouthAsian ? ENumberScales.SouthAsian : ENumberScales.International }) }), _jsx(Choice, { id: `${name}-thousands`, legend: "Thousands separator", options: SEPARATORS, value: preferences.thousands, onChange: thousands => setUserPreferences({ thousands }) }), _jsx("h3", { className: "nx-theme-picker__section", children: "Tables" }), _jsx(Choice, { id: `${name}-page-size`, legend: "Default page size", children: _jsx(Dropdown, { "aria-labelledby": `${name}-page-size`, options: PAGE_SIZES, value: preferences.pageSize ?? DEFAULT_PAGE_SIZE, clearable: false, searchable: false, onValueChange: pageSize => pageSize && setUserPreferences({ pageSize }) }) }), _jsx(Switch, { label: "A row click opens the record", labelPosition: "start", checked: preferences.rowClick ?? false, onValueChange: rowClick => setUserPreferences({ rowClick }) })] }));
22
93
  }
@@ -4,8 +4,10 @@ export interface ThemeScriptProps extends ThemeScriptOptions {
4
4
  nonce?: string;
5
5
  }
6
6
  /**
7
- * Applies the saved theme and mode before the first paint. Render it in `<head>` of the root layout,
8
- * and put `suppressHydrationWarning` on `<html>`, because the script sets attributes there.
7
+ * Applies the saved theme, mode, density, animations and (in a development build) scale grid before the first paint,
8
+ * and keeps `data-nx-motion` in step with the system's reduced-motion setting: the stylesheets read that attribute,
9
+ * not the media query, so an app without this script animates whatever the system says. Render it in `<head>` of the
10
+ * root layout, and put `suppressHydrationWarning` on `<html>`, because the script sets attributes there.
9
11
  *
10
12
  * @example
11
13
  * <html lang="en" suppressHydrationWarning>
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { themeScript } from "../../Helpers/ThemeHelpers.js";
3
3
  /**
4
- * Applies the saved theme and mode before the first paint. Render it in `<head>` of the root layout,
5
- * and put `suppressHydrationWarning` on `<html>`, because the script sets attributes there.
4
+ * Applies the saved theme, mode, density, animations and (in a development build) scale grid before the first paint,
5
+ * and keeps `data-nx-motion` in step with the system's reduced-motion setting: the stylesheets read that attribute,
6
+ * not the media query, so an app without this script animates whatever the system says. Render it in `<head>` of the
7
+ * root layout, and put `suppressHydrationWarning` on `<html>`, because the script sets attributes there.
6
8
  *
7
9
  * @example
8
10
  * <html lang="en" suppressHydrationWarning>
@@ -5,9 +5,12 @@ export interface ThemeSwitcherProps extends ThemePickerProps {
5
5
  label?: string;
6
6
  /** Default `ghost`. */
7
7
  variant?: ButtonVariant;
8
+ /** The quick preferences under the theme: language, dates, numbers and tables (`PreferencesPicker`). Default true. */
9
+ preferences?: boolean;
8
10
  }
9
11
  /**
10
- * A palette button that opens the theme picker below it. The panel is a native popover:
11
- * it closes with Esc or a click outside, and needs no library.
12
+ * A palette button that opens a quick preference panel below it, with the panel's arrow on the button: the theme
13
+ * (`ThemePicker`) and, under it, the language, dates, numbers and tables (`PreferencesPicker`). The panel is a native
14
+ * popover: it closes with Esc or a click outside, stays inside the window and scrolls inside itself.
12
15
  */
13
- export declare function ThemeSwitcher({ label, variant, className, ...pickerProps }: ThemeSwitcherProps): import("react").JSX.Element;
16
+ export declare function ThemeSwitcher({ label, variant, className, preferences, ...pickerProps }: ThemeSwitcherProps): import("react").JSX.Element;
@@ -1,29 +1,50 @@
1
1
  "use client";
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { iconPalette, InlineIcon } from "nexus-icons";
4
- import { useId, useRef } from "react";
4
+ import { useId, useLayoutEffect, useRef, useState } from "react";
5
+ import { followAnchor, isPopoverOpen, placeUnderAnchor, showPanel } from "../../Helpers/PopoverHelpers.js";
5
6
  import { Button } from "../Documents/Button.js";
6
- import { ThemePicker } from "./ThemePicker.js";
7
- // Keep in step with the width of .nx-theme-switcher__panel.
8
- const PANEL_WIDTH = 340;
9
- const GAP = 8;
7
+ import { PreferencesPicker, ThemePicker } from "./ThemePicker.js";
10
8
  /**
11
- * A palette button that opens the theme picker below it. The panel is a native popover:
12
- * it closes with Esc or a click outside, and needs no library.
9
+ * A palette button that opens a quick preference panel below it, with the panel's arrow on the button: the theme
10
+ * (`ThemePicker`) and, under it, the language, dates, numbers and tables (`PreferencesPicker`). The panel is a native
11
+ * popover: it closes with Esc or a click outside, stays inside the window and scrolls inside itself.
13
12
  */
14
- export function ThemeSwitcher({ label = "Theme", variant = "ghost", className, ...pickerProps }) {
13
+ export function ThemeSwitcher({ label = "Theme", variant = "ghost", className, preferences = true, ...pickerProps }) {
15
14
  const panelId = useId();
16
15
  const buttonRef = useRef(null);
17
- // Opens under the button, aligned to its right edge, and kept inside the window.
18
- function place(event) {
16
+ const panelRef = useRef(null);
17
+ const [open, setOpen] = useState(false);
18
+ // Set on pointer down while the panel is open: that press closes it (light dismiss), and its click must not reopen it.
19
+ const dismissingRef = useRef(false);
20
+ // The panel is shown from here rather than by the button's popovertarget, because it can be measured only once it is
21
+ // shown: placing it needs its size. It opens under the button, lined up with its right edge and inside the window.
22
+ useLayoutEffect(() => {
23
+ const panel = panelRef.current;
19
24
  const button = buttonRef.current;
20
- if (event.newState !== "open" || !button)
25
+ if (!open || !panel || !button)
21
26
  return;
22
- const box = button.getBoundingClientRect();
23
- const width = Math.min(PANEL_WIDTH, window.innerWidth - GAP * 2);
24
- const left = Math.min(Math.max(GAP, box.right - width), window.innerWidth - width - GAP);
25
- event.currentTarget.style.top = `${box.bottom + GAP}px`;
26
- event.currentTarget.style.left = `${left}px`;
27
+ const place = () => placeUnderAnchor(panel, button.getBoundingClientRect(), { align: "end" });
28
+ place();
29
+ return followAnchor(place);
30
+ }, [open]);
31
+ // Opening sets the state in the click itself, so the panel is placed before the frame it first paints in; closing
32
+ // can also come from Esc or a click outside, which only the event reports.
33
+ function onToggle(event) {
34
+ if (event.newState === "closed")
35
+ setOpen(false);
27
36
  }
28
- return (_jsxs(_Fragment, { children: [_jsx(Button, { ref: buttonRef, variant: variant, className: className, "aria-label": label, title: label, popoverTarget: panelId, icon: _jsx(InlineIcon, { icon: iconPalette }) }), _jsx("div", { id: panelId, popover: "auto", role: "dialog", "aria-label": label, className: "nx-popover nx-theme-switcher__panel", onBeforeToggle: place, children: _jsx(ThemePicker, { ...pickerProps }) })] }));
37
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { ref: buttonRef, variant: variant, className: className, "aria-label": label, title: label, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": panelId, icon: _jsx(InlineIcon, { icon: iconPalette }), onPointerDown: () => {
38
+ dismissingRef.current = isPopoverOpen(panelRef.current);
39
+ }, onClick: () => {
40
+ const panel = panelRef.current;
41
+ if (dismissingRef.current)
42
+ dismissingRef.current = false;
43
+ else if (isPopoverOpen(panel))
44
+ panel?.hidePopover();
45
+ else if (panel && typeof panel.showPopover === "function") {
46
+ showPanel(panel);
47
+ setOpen(true);
48
+ }
49
+ } }), _jsx("div", { ref: panelRef, id: panelId, popover: "auto", role: "dialog", "aria-label": label, className: "nx-popover nx-theme-switcher__panel", onToggle: onToggle, children: _jsxs("div", { className: "nx-popover__scroll nx-theme-switcher__body", children: [_jsx(ThemePicker, { ...pickerProps }), preferences && _jsx(PreferencesPicker, {})] }) })] }));
29
50
  }
@@ -0,0 +1,21 @@
1
+ export interface AvatarProps {
2
+ /** Whose picture it is: the initials stand in without one, and the name picks their tone. */
3
+ name: string;
4
+ /** Address of the picture. Anything but a non-empty string is no picture. */
5
+ src?: unknown;
6
+ /** `sm` 22px, `md` 28px, `lg` 44px. Default `md`; `--nx-avatar-size` sets any other. */
7
+ size?: "sm" | "md" | "lg";
8
+ className?: string;
9
+ /** Makes the picture a button that offers to change it: a camera shows over it on hover, and a click calls this. */
10
+ onChangePicture?: () => void;
11
+ /** The button's name. Default "Change picture". */
12
+ changeLabel?: string;
13
+ /** For the button inside a menu: `role="menuitem"` and `tabIndex={-1}` let the arrow keys reach it. */
14
+ role?: string;
15
+ tabIndex?: number;
16
+ }
17
+ /**
18
+ * A person: a round picture, or their initials on a soft tone when there is none or it fails to load. The chat,
19
+ * the dropdown's avatar column and the admin bar all draw this one.
20
+ */
21
+ export declare function Avatar({ name, src, size, className, onChangePicture, changeLabel, role, tabIndex }: AvatarProps): import("react").JSX.Element;