nexus-shared 2.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +446 -0
- package/LICENSE +21 -21
- package/README.md +1084 -903
- package/dist/Client.Index.d.ts +14 -0
- package/dist/Client.Index.js +14 -0
- package/dist/Components/Chats/Chat.d.ts +28 -0
- package/dist/Components/Chats/Chat.js +19 -0
- package/dist/Components/Chats/ChatButton.d.ts +26 -0
- package/dist/Components/Chats/ChatButton.js +41 -0
- package/dist/Components/Chats/ChatComposer.d.ts +41 -0
- package/dist/Components/Chats/ChatComposer.js +177 -0
- package/dist/Components/Chats/ChatConversations.d.ts +35 -0
- package/dist/Components/Chats/ChatConversations.js +38 -0
- package/dist/Components/Chats/ChatPanel.d.ts +66 -0
- package/dist/Components/Chats/ChatPanel.js +94 -0
- package/dist/Components/Chats/ChatParts.d.ts +87 -0
- package/dist/Components/Chats/ChatParts.js +96 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/Menu.js +44 -28
- package/dist/Components/Documents/SplitButton.d.ts +3 -1
- package/dist/Components/Documents/SplitButton.js +12 -8
- package/dist/Components/Documents/TabButtons.d.ts +29 -8
- package/dist/Components/Documents/TabButtons.js +24 -5
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +342 -71
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +37 -5
- package/dist/Components/Forms/ExcelImport.d.ts +58 -0
- package/dist/Components/Forms/ExcelImport.js +319 -0
- package/dist/Components/Forms/Form.js +9 -13
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/GroupForm.js +4 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
- package/dist/Components/Inputs/InputRenderer.js +1 -1
- package/dist/Components/Inputs/NumberBox.js +24 -2
- package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +55 -6
- package/dist/Components/Inputs/Slider.js +4 -1
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +117 -20
- package/dist/Components/Inputs/TimePanel.js +4 -3
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/Popup.js +1 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
- package/dist/Components/Layouts/ThemePicker.js +77 -6
- package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
- package/dist/Components/Layouts/ThemeScript.js +4 -2
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
- package/dist/Components/Layouts/ThemeSwitcher.js +39 -18
- package/dist/Components/Viewers/Avatar.d.ts +21 -0
- package/dist/Components/Viewers/Avatar.js +21 -0
- package/dist/Components/Viewers/DataTable.js +262 -60
- package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
- package/dist/Components/Viewers/DataTableColumns.js +200 -0
- package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
- package/dist/Components/Viewers/DataTableFilters.js +68 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
- package/dist/Components/Viewers/DataTableParts.js +24 -12
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- package/dist/Helpers/ApiClient.d.ts +22 -0
- package/dist/Helpers/ApiClient.js +4 -1
- package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
- package/dist/Helpers/ApiFormHelpers.js +73 -9
- package/dist/Helpers/ApiModules.d.ts +0 -4
- package/dist/Helpers/ApiModules.js +1 -0
- package/dist/Helpers/ApiResponses.d.ts +14 -4
- package/dist/Helpers/ApiResponses.js +116 -25
- package/dist/Helpers/ApiRoutes.d.ts +0 -10
- package/dist/Helpers/ApiRoutes.js +2 -10
- package/dist/Helpers/ChatBackend.d.ts +134 -0
- package/dist/Helpers/ChatBackend.js +332 -0
- package/dist/Helpers/ChatHelpers.d.ts +189 -0
- package/dist/Helpers/ChatHelpers.js +486 -0
- package/dist/Helpers/ChatHooks.d.ts +81 -0
- package/dist/Helpers/ChatHooks.js +175 -0
- package/dist/Helpers/ChatStore.d.ts +132 -0
- package/dist/Helpers/ChatStore.js +394 -0
- package/dist/Helpers/ChatSummaries.d.ts +39 -0
- package/dist/Helpers/ChatSummaries.js +118 -0
- package/dist/Helpers/CrudBackend.d.ts +54 -9
- package/dist/Helpers/CrudBackend.js +103 -6
- package/dist/Helpers/CrudHelpers.d.ts +39 -4
- package/dist/Helpers/CrudHelpers.js +76 -11
- package/dist/Helpers/ExcelBackend.d.ts +106 -0
- package/dist/Helpers/ExcelBackend.js +296 -0
- package/dist/Helpers/ExcelHelpers.d.ts +126 -0
- package/dist/Helpers/ExcelHelpers.js +344 -0
- package/dist/Helpers/FormStore.d.ts +11 -5
- package/dist/Helpers/FormStore.js +44 -6
- package/dist/Helpers/MessageBuilder.js +1 -2
- package/dist/Helpers/NumberHelpers.d.ts +7 -1
- package/dist/Helpers/NumberHelpers.js +41 -3
- package/dist/Helpers/PopoverHelpers.d.ts +46 -9
- package/dist/Helpers/PopoverHelpers.js +163 -24
- package/dist/Helpers/PopoverHooks.d.ts +24 -0
- package/dist/Helpers/PopoverHooks.js +62 -0
- package/dist/Helpers/RowsStore.d.ts +14 -3
- package/dist/Helpers/RowsStore.js +37 -3
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableExport.js +12 -12
- package/dist/Helpers/TableFilters.d.ts +25 -0
- package/dist/Helpers/TableFilters.js +162 -0
- package/dist/Helpers/TableHelpers.d.ts +15 -4
- package/dist/Helpers/TableHelpers.js +28 -4
- package/dist/Helpers/TableStore.d.ts +11 -3
- package/dist/Helpers/TableStore.js +16 -5
- package/dist/Helpers/ThemeHelpers.d.ts +21 -4
- package/dist/Helpers/ThemeHelpers.js +46 -9
- package/dist/Helpers/UserPreferences.d.ts +14 -0
- package/dist/Helpers/UserPreferences.js +99 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
- package/dist/Interfaces/ApiInterfaces.js +16 -10
- package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
- package/dist/Interfaces/ChatInterfaces.js +1 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +85 -7
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
- package/dist/Interfaces/PreferenceInterfaces.js +28 -0
- package/dist/Interfaces/TableInterfaces.d.ts +116 -9
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- package/dist/Services/BrowserApi.d.ts +18 -1
- package/dist/Services/BrowserApi.js +18 -0
- package/dist/Services/ChatApi.d.ts +37 -0
- package/dist/Services/ChatApi.js +98 -0
- package/dist/Services/ExcelApi.d.ts +48 -0
- package/dist/Services/ExcelApi.js +196 -0
- package/dist/Services/ServerApi.d.ts +6 -1
- package/dist/Services/ServerApi.js +3 -0
- package/dist/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +10 -0
- package/dist/Shared.Index.js +10 -0
- package/package.json +85 -82
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +117 -70
- package/src/Styles/Nexus.Button.css +180 -151
- package/src/Styles/Nexus.Calendar.css +273 -275
- package/src/Styles/Nexus.Chat.css +1443 -0
- package/src/Styles/Nexus.Choice.css +343 -345
- package/src/Styles/Nexus.Crud.css +168 -84
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +725 -774
- package/src/Styles/Nexus.Editor.css +295 -297
- package/src/Styles/Nexus.Excel.css +389 -0
- package/src/Styles/Nexus.Form.css +279 -298
- package/src/Styles/Nexus.Index.css +37 -34
- package/src/Styles/Nexus.Input.css +602 -637
- package/src/Styles/Nexus.Markdown.css +237 -265
- package/src/Styles/Nexus.Menu.css +237 -249
- package/src/Styles/Nexus.Popup.css +724 -731
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +637 -566
- package/src/Styles/Nexus.Slider.css +276 -278
- package/src/Styles/Nexus.Tab.Buttons.css +432 -364
- package/src/Styles/Nexus.Table.css +1386 -1014
- package/src/Styles/Nexus.Theme.Picker.css +286 -159
- package/src/Styles/Nexus.Themes.css +194 -194
- package/src/Styles/Nexus.Time.css +144 -147
- package/src/Styles/Nexus.Toast.css +263 -265
- package/src/Styles/Nexus.Tokens.css +89 -72
- package/src/Styles/Nexus.Tree.css +431 -435
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 && (
|
|
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
|
|
88
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
365
|
+
actionsWidth,
|
|
273
366
|
]
|
|
274
367
|
.filter(Boolean)
|
|
275
368
|
.join(" ");
|
|
276
|
-
const minWidth = `calc(${[numbered ? numberWidth : "0px", ...widths,
|
|
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,
|
|
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
|
-
|
|
282
|
-
|
|
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
|
|
161
|
-
//
|
|
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 &&
|
|
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
|
-
|
|
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,
|
|
11
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
19
|
-
const
|
|
87
|
+
export function PreferencesPicker({ className }) {
|
|
88
|
+
const dates = useDatePreferences();
|
|
89
|
+
const preferences = useUserPreferences();
|
|
20
90
|
const name = useId();
|
|
21
|
-
|
|
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
|
|
8
|
-
* and
|
|
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
|
|
5
|
-
* and
|
|
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
|
|
11
|
-
*
|
|
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,
|
|
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
|
|
12
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
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 (
|
|
25
|
+
if (!open || !panel || !button)
|
|
21
26
|
return;
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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,
|
|
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;
|