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
@@ -3,7 +3,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import { InlineIcon, iconMinus, iconPlus } from "nexus-icons";
4
4
  import { useEffect, useLayoutEffect, useRef, } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
- import { clampNumber, cleanNumberText, DEFAULT_NUMBER_LOCALE, formatNumber, getNumberSymbols, isPartialNumber, parseNumber, readPastedNumber, roundDecimal, stepNumber, } from "../../Helpers/NumberHelpers.js";
6
+ import { clampNumber, cleanNumberText, formatNumber, getNumberSymbols, isPartialNumber, parseNumber, readPastedNumber, roundDecimal, stepNumber, } from "../../Helpers/NumberHelpers.js";
7
+ import { useNumberLocale } from "../../Helpers/UserPreferences.js";
7
8
  import { validateNumber } from "../../Helpers/ValidationHelpers.js";
8
9
  import { Field, hasContent } from "./Field.js";
9
10
  import { setInputValue, useInputField } from "./InputField.js";
@@ -23,7 +24,10 @@ const isNumber = (value) => value !== null && !Number.isNaN(value);
23
24
  * @example <NumberBox label="Amount" prefix="Rs" min={0} decimals={2} fixedDecimals grouping required />
24
25
  */
25
26
  export function NumberBox(props) {
26
- const { label, hint, error, design = "standard", size = "md", icon, iconEnd, prefix, suffix, step = 1, stepper = false, clamp = false, readOnlyMessage = "Read-only: this number cannot be changed.", fieldName, validateOn, value, defaultValue, onValueChange, decimals, fixedDecimals = false, grouping = false, locale = DEFAULT_NUMBER_LOCALE, className, inputClassName, style, required, min, max, validate, name, form, placeholder, disabled, readOnly, ref, onChange, onBlur, onFocus, onInvalid, onKeyDown, onPaste, onClick, ...inputProps } = props;
27
+ const { label, hint, error, design = "standard", size = "md", icon, iconEnd, prefix, suffix, step = 1, stepper = false, clamp = false, readOnlyMessage = "Read-only: this number cannot be changed.", fieldName, validateOn, value, defaultValue, onValueChange, decimals, fixedDecimals = false, grouping = false, locale: ownLocale, className, inputClassName, style, required, min, max, validate, name, form, placeholder, disabled, readOnly, ref, onChange, onBlur, onFocus, onInvalid, onKeyDown, onPaste, onClick, ...inputProps } = props;
28
+ // Without a locale of its own, the box writes and reads numbers the way the person chose (Number scale, Thousands).
29
+ const preferred = useNumberLocale();
30
+ const locale = ownLocale ?? preferred;
27
31
  const format = { decimals, fixedDecimals, grouping, locale };
28
32
  const { decimal, group } = getNumberSymbols(locale);
29
33
  const textRules = { decimals, negative: min === undefined || min < 0 };
@@ -55,8 +59,11 @@ export function NumberBox(props) {
55
59
  const readOnlyNotice = useReadOnlyNotice(Boolean(readOnly) && !disabled, field.elementRef, readOnlyMessage, {
56
60
  keys: ["ArrowUp", "ArrowDown", "PageUp", "PageDown"],
57
61
  });
62
+ // The number the box holds, as last read in the marks it was written in.
63
+ const numberRef = useRef(initial);
58
64
  /** Writes what depends on the value straight to the DOM, so typing re-renders nothing: the hidden input and the button limits. */
59
65
  function sync(number) {
66
+ numberRef.current = number;
60
67
  if (hiddenRef.current)
61
68
  hiddenRef.current.value = isNumber(number) ? String(number) : "";
62
69
  for (const [button, direction] of [
@@ -69,6 +76,21 @@ export function NumberBox(props) {
69
76
  button?.removeAttribute("aria-disabled");
70
77
  }
71
78
  }
79
+ // The person changed how numbers are written (Number scale, Thousands): the number the box held is written again in
80
+ // the new marks. Its text is not read again, because by now it may be either: React has already rewritten an untouched
81
+ // box's default text, and "1,250.5" read with a decimal comma would be 1.2505. Runs before the effects below read it.
82
+ const localeRef = useRef(locale);
83
+ useLayoutEffect(() => {
84
+ const previous = localeRef.current;
85
+ localeRef.current = locale;
86
+ const input = field.elementRef.current;
87
+ const number = numberRef.current;
88
+ if (previous === locale || !input || !input.value || !isNumber(number))
89
+ return;
90
+ input.value = document.activeElement === input ? editText(number) : displayText(number);
91
+ sync(number);
92
+ // eslint-disable-next-line react-hooks/exhaustive-deps
93
+ }, [locale]);
72
94
  // A controlled value that differs from the box's text replaces it. Text that already means this number ("12.") stays.
73
95
  useLayoutEffect(() => {
74
96
  const input = field.elementRef.current;
@@ -135,10 +135,10 @@ export function useRowsInput(props, fallbackItem, layoutRef) {
135
135
  add: (values, index) => {
136
136
  actions.add(values, index);
137
137
  },
138
- remove: index => {
138
+ remove: (index, options) => {
139
139
  const key = store.keyAt(index);
140
140
  if (key)
141
- store.remove(key);
141
+ store.remove(key, options);
142
142
  },
143
143
  validate: () => {
144
144
  const place = store.validateAll();
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Fragment, useEffect, useEffectEvent, useRef, } from "react";
4
4
  import { cx } from "../../Helpers/ClassHelpers.js";
5
5
  import { clampNumber, countDecimals, formatNumber, roundDecimal } from "../../Helpers/NumberHelpers.js";
6
+ import { useNumberLocale } from "../../Helpers/UserPreferences.js";
6
7
  import { GroupFooter, leavesField, OPTION_ATTRIBUTE, useChoiceField } from "./ChoiceField.js";
7
8
  import { hasContent } from "./Field.js";
8
9
  import { setInputValue } from "./InputField.js";
@@ -54,7 +55,9 @@ function SliderField(props) {
54
55
  const { readOnlyMessage = range ? "Read-only: this range cannot be changed." : "Read-only: this value cannot be changed." } = props;
55
56
  const span = max - min || 1;
56
57
  const initial = (values ?? defaultValues).map(value => clampNumber(value, min, max));
57
- const text = (value) => (format ? format(value) : `${prefix ?? ""}${formatNumber(value, { decimals, fixedDecimals, grouping, locale })}${suffix ?? ""}`);
58
+ // Without a locale of its own, the values are written the way the person chose.
59
+ const numbers = useNumberLocale();
60
+ const text = (value) => (format ? format(value) : `${prefix ?? ""}${formatNumber(value, { decimals, fixedDecimals, grouping, locale: locale ?? numbers })}${suffix ?? ""}`);
58
61
  const fraction = (value) => (clampNumber(value, min, max) - min) / span;
59
62
  const controlRef = useRef(null);
60
63
  const valueRef = useRef(null);
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconArrowDown, iconArrowUp, iconCopy, iconDotsVertical, iconLock, iconRowInsertBottom, iconRowInsertTop, iconTrash, 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
6
  import { Menu } from "../Documents/Menu.js";
@@ -35,6 +35,8 @@ const COLUMN_WIDTH = {
35
35
  };
36
36
  /** Columns that keep their width instead of sharing the spare room. */
37
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.";
38
40
  const ENTER_MOVES = new Set(["text", "email", "tel", "url", "search", "password", "number"]);
39
41
  const cssLength = (value) => (typeof value === "number" ? `${value}px` : value);
40
42
  /** How a cell lines up: numbers at the end, checkboxes and radios in the middle, the rest at the start. */
@@ -64,11 +66,17 @@ const TabularRow = memo(function TabularRow({ store, rowKey, index, count, colum
64
66
  items.push({ separator: true });
65
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 && !fixed) {
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 && (_jsxs("div", { role: "cell", className: "nx-tabular__num", children: [_jsx("span", { className: "nx-tabular__num-text", children: index + 1 }), items.length > 0 && (_jsx(Menu, { items: items, 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 }) }) }))] })), 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)))] }));
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
@@ -108,6 +116,10 @@ export function TabularForm(props) {
108
116
  const addable = props.addable !== false && !readOnly;
109
117
  const removable = props.removable !== false && !readOnly && !disabled;
110
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());
111
123
  const layoutRef = useRef(null);
112
124
  const rows = useRowsInput(props, "Row", layoutRef);
113
125
  const store = rows.store;
@@ -238,9 +250,22 @@ export function TabularForm(props) {
238
250
  run.move(key, -1);
239
251
  else if (action === "move-down")
240
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
+ }
241
263
  else
242
264
  run.remove(key);
243
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]);
244
269
  /** The message or hint of the input with focus, shown under the table: cells have no room for their own. */
245
270
  function readStatus() {
246
271
  const active = document.activeElement;
@@ -286,7 +311,7 @@ export function TabularForm(props) {
286
311
  layoutRef.current?.focusRow(key, name);
287
312
  }
288
313
  }
289
- const context = useMemo(() => ({ idPrefix: frame.id, size, readOnly, disabled, numbered, removable, sortable, canAdd: rows.canAdd, showAll: snapshot.showAll, cellLock: rows.cellLock, cellParams: rows.cellParams, rowLock: rows.rowLock }), [frame.id, size, readOnly, disabled, numbered, removable, sortable, rows.canAdd, snapshot.showAll, rows.cellLock, rows.cellParams, rows.rowLock]);
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]);
290
315
  /**
291
316
  * Which columns are generated for **every** row on the page. The lock is then said once, on the column header,
292
317
  * instead of on each of its cells: eighteen locks down one column is eighteen times the ink for one fact. The cells
@@ -307,7 +332,7 @@ export function TabularForm(props) {
307
332
  let reason;
308
333
  for (const [offset, key] of shownKeys.entries()) {
309
334
  // A locked row says nothing about its columns: the rows around it decide whether a column is generated.
310
- if (rows.rowLock(key, start + offset))
335
+ if (lockedKeys.has(key) || rows.rowLock(key, start + offset))
311
336
  continue;
312
337
  const lock = rows.cellLock(key, params.name, start + offset);
313
338
  if (!lock) {
@@ -323,9 +348,13 @@ export function TabularForm(props) {
323
348
  return locked;
324
349
  // `shownKeys` is a fresh array on every render; the work is a call per visible cell, and nothing memoised is
325
350
  // handed to the rows, so recomputing it costs a map and never a re-render of a row.
326
- }, [columns, readOnly, rows.cellLock, rows.rowLock, shownKeys, start]);
351
+ }, [columns, readOnly, rows.cellLock, rows.rowLock, shownKeys, start, lockedKeys]);
327
352
  // The number and the row's menu, side by side, plus the frame around them. Both are always there, so nothing moves.
328
- const numberWidth = `calc(${Math.max(2, String(count).length)}ch + 18px + var(--nx-tabular-menu))`;
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;
329
358
  const widths = columns.map(columnWidth);
330
359
  const template = [
331
360
  numbered ? numberWidth : null,
@@ -333,16 +362,19 @@ export function TabularForm(props) {
333
362
  const grow = params.grow ?? (FIXED_TYPES.has(params.type) ? 0 : 1);
334
363
  return grow > 0 ? `minmax(${widths[index]}, ${grow}fr)` : widths[index];
335
364
  }),
365
+ actionsWidth,
336
366
  ]
337
367
  .filter(Boolean)
338
368
  .join(" ");
339
- const minWidth = `calc(${[numbered ? numberWidth : "0px", ...widths].join(" + ")})`;
369
+ const minWidth = `calc(${[numbered ? numberWidth : "0px", ...widths, actionsWidth ?? "0px"].join(" + ")})`;
340
370
  const tableStyle = { "--nx-tabular-columns": template, "--nx-tabular-num": numbered ? numberWidth : "0px", minWidth };
341
371
  const invalid = Boolean(error || snapshot.message || snapshot.invalidRows);
342
- 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: viewportMax ?? 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 => {
343
375
  const columnHint = params.hint;
344
376
  const locked = lockedColumns.get(params.name);
345
377
  const reason = typeof locked === "string" ? locked : locked ? "Read-only" : undefined;
346
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));
347
- })] }) }), _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() })] }));
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() })] }));
348
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)
@@ -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, with the panel's arrow on the button. The panel is a native
11
- * popover: 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,15 +1,16 @@
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
4
  import { useId, useLayoutEffect, useRef, useState } from "react";
5
- import { followAnchor, isPopoverOpen, placeUnderAnchor } from "../../Helpers/PopoverHelpers.js";
5
+ import { followAnchor, isPopoverOpen, placeUnderAnchor, showPanel } from "../../Helpers/PopoverHelpers.js";
6
6
  import { Button } from "../Documents/Button.js";
7
- import { ThemePicker } from "./ThemePicker.js";
7
+ import { PreferencesPicker, ThemePicker } from "./ThemePicker.js";
8
8
  /**
9
- * A palette button that opens the theme picker below it, with the panel's arrow on the button. The panel is a native
10
- * popover: 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.
11
12
  */
12
- export function ThemeSwitcher({ label = "Theme", variant = "ghost", className, ...pickerProps }) {
13
+ export function ThemeSwitcher({ label = "Theme", variant = "ghost", className, preferences = true, ...pickerProps }) {
13
14
  const panelId = useId();
14
15
  const buttonRef = useRef(null);
15
16
  const panelRef = useRef(null);
@@ -42,8 +43,8 @@ export function ThemeSwitcher({ label = "Theme", variant = "ghost", className, .
42
43
  else if (isPopoverOpen(panel))
43
44
  panel?.hidePopover();
44
45
  else if (panel && typeof panel.showPopover === "function") {
45
- panel.showPopover();
46
+ showPanel(panel);
46
47
  setOpen(true);
47
48
  }
48
- } }), _jsx("div", { ref: panelRef, id: panelId, popover: "auto", role: "dialog", "aria-label": label, className: "nx-popover nx-theme-switcher__panel", onToggle: onToggle, children: _jsx(ThemePicker, { ...pickerProps }) })] }));
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, {})] }) })] }));
49
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;
@@ -0,0 +1,21 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Icon } from "nexus-icons";
4
+ import { useState } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { avatarTone, initials } from "../../Helpers/DropdownHelpers.js";
7
+ /**
8
+ * A person: a round picture, or their initials on a soft tone when there is none or it fails to load. The chat,
9
+ * the dropdown's avatar column and the admin bar all draw this one.
10
+ */
11
+ export function Avatar({ name, src, size = "md", className, onChangePicture, changeLabel = "Change picture", role, tabIndex }) {
12
+ const url = typeof src === "string" ? src.trim() : "";
13
+ const [failed, setFailed] = useState(null);
14
+ const photo = url !== "" && failed !== url;
15
+ const face = photo ? _jsx("img", { src: url, alt: "", loading: "lazy", decoding: "async", onError: () => setFailed(url) }) : initials(name);
16
+ const shared = { className: cx("nx-avatar", size !== "md" && `nx-avatar--${size}`, className), "data-tone": photo ? undefined : avatarTone(name) };
17
+ if (!onChangePicture) {
18
+ return (_jsx("span", { ...shared, "aria-hidden": "true", children: face }));
19
+ }
20
+ return (_jsxs("button", { ...shared, type: "button", role: role, tabIndex: tabIndex, "aria-label": changeLabel, title: changeLabel, onClick: onChangePicture, children: [face, _jsx("span", { className: "nx-avatar__veil", "aria-hidden": "true", children: _jsx(Icon, { name: "camera", size: "40%" }) })] }));
21
+ }