nexus-shared 3.1.0 → 3.2.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 +704 -446
- package/LICENSE +21 -21
- package/README.md +44 -5
- package/dist/Client.Index.d.ts +3 -0
- package/dist/Client.Index.js +3 -0
- package/dist/Components/Forms/Crud.js +322 -161
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudParts.js +2 -1
- package/dist/Components/Forms/Form.js +4 -1
- package/dist/Components/Inputs/CodeBox.d.ts +69 -0
- package/dist/Components/Inputs/CodeBox.js +316 -0
- package/dist/Components/Inputs/DatePicker.js +4 -1
- package/dist/Components/Inputs/DateRangePicker.js +4 -1
- package/dist/Components/Inputs/DateTimePicker.js +4 -1
- package/dist/Components/Inputs/Dropdown.js +4 -1
- package/dist/Components/Inputs/InputRenderer.js +3 -0
- package/dist/Components/Inputs/NumberBox.js +4 -2
- package/dist/Components/Inputs/TextArea.js +4 -1
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +55 -5
- package/dist/Components/Inputs/TreeView.js +372 -23
- package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
- package/dist/Components/Layouts/ThemePicker.js +16 -4
- package/dist/Components/Viewers/DataTable.js +63 -21
- package/dist/Components/Viewers/DataTableParts.d.ts +26 -8
- package/dist/Components/Viewers/DataTableParts.js +39 -10
- package/dist/Helpers/ApiModules.d.ts +3 -34
- package/dist/Helpers/ApiModules.js +3 -3
- package/dist/Helpers/ApiRoutes.d.ts +68 -26
- package/dist/Helpers/ApiRoutes.js +31 -27
- package/dist/Helpers/ArrayHelpers.d.ts +0 -0
- package/dist/Helpers/ArrayHelpers.js +2 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +94 -4
- package/dist/Helpers/CrudBackend.js +168 -9
- package/dist/Helpers/CrudColumns.d.ts +51 -0
- package/dist/Helpers/CrudColumns.js +69 -0
- package/dist/Helpers/CrudHelpers.d.ts +50 -5
- package/dist/Helpers/CrudHelpers.js +109 -12
- package/dist/Helpers/InputParamsHelpers.js +8 -0
- package/dist/Helpers/MessageBuilder.js +2 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/RequestCache.d.ts +35 -0
- package/dist/Helpers/RequestCache.js +113 -0
- package/dist/Helpers/TableExport.js +12 -12
- package/dist/Helpers/TableHelpers.d.ts +1 -1
- package/dist/Helpers/TableHelpers.js +7 -2
- package/dist/Helpers/TableJoins.d.ts +70 -0
- package/dist/Helpers/TableJoins.js +250 -0
- package/dist/Helpers/TableStore.d.ts +5 -0
- package/dist/Helpers/TableStore.js +9 -0
- package/dist/Helpers/TreeStore.d.ts +225 -11
- package/dist/Helpers/TreeStore.js +683 -90
- package/dist/Helpers/UserPreferences.d.ts +8 -0
- package/dist/Helpers/UserPreferences.js +17 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +47 -9
- package/dist/Interfaces/FormInterfaces.d.ts +4 -1
- package/dist/Interfaces/InputInterfaces.d.ts +24 -0
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
- package/dist/Interfaces/PreferenceInterfaces.js +2 -5
- package/dist/Interfaces/TableInterfaces.d.ts +86 -4
- package/dist/Services/ApiProxy.d.ts +36 -1
- package/dist/Services/ApiProxy.js +182 -52
- package/dist/Services/ApiProxyRules.d.ts +203 -0
- package/dist/Services/ApiProxyRules.js +426 -0
- package/dist/Services/JoinSources.d.ts +48 -0
- package/dist/Services/JoinSources.js +74 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Shared.Index.d.ts +4 -0
- package/dist/Shared.Index.js +4 -0
- package/package.json +87 -85
- package/src/Styles/Nexus.Avatar.css +79 -79
- package/src/Styles/Nexus.Base.css +117 -117
- package/src/Styles/Nexus.Button.css +180 -180
- package/src/Styles/Nexus.Calendar.css +273 -273
- package/src/Styles/Nexus.Chat.css +1443 -1443
- package/src/Styles/Nexus.Choice.css +343 -343
- package/src/Styles/Nexus.Crud.css +168 -168
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +725 -725
- package/src/Styles/Nexus.Editor.css +295 -295
- package/src/Styles/Nexus.Excel.css +389 -389
- package/src/Styles/Nexus.Form.css +279 -279
- package/src/Styles/Nexus.Index.css +37 -37
- package/src/Styles/Nexus.Input.css +196 -4
- package/src/Styles/Nexus.Markdown.css +237 -237
- package/src/Styles/Nexus.Menu.css +237 -237
- package/src/Styles/Nexus.Popup.css +724 -724
- package/src/Styles/Nexus.Rows.css +637 -637
- package/src/Styles/Nexus.Slider.css +276 -276
- package/src/Styles/Nexus.Tab.Buttons.css +432 -432
- package/src/Styles/Nexus.Table.css +31 -30
- package/src/Styles/Nexus.Theme.Picker.css +59 -3
- package/src/Styles/Nexus.Themes.css +194 -194
- package/src/Styles/Nexus.Time.css +144 -144
- package/src/Styles/Nexus.Toast.css +263 -263
- package/src/Styles/Nexus.Tokens.css +89 -89
- package/src/Styles/Nexus.Tree.css +500 -431
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type CrudBackend } from "../../Helpers/CrudBackend.ts";
|
|
2
|
+
import type { ApiEndpoint } from "../../Interfaces/ApiInterfaces.ts";
|
|
3
|
+
/** One column of a change, as the backend's history answers it (`AuditChangeResponseContract`). */
|
|
4
|
+
export interface AuditChange {
|
|
5
|
+
/** The property: `RegionName`. */
|
|
6
|
+
column: string;
|
|
7
|
+
/** What a person calls it: "Region name". */
|
|
8
|
+
label: string;
|
|
9
|
+
/** How the value reads, as the backend's `EAuditValueTypes`: 0 text, 1 number, 2 yes or no, 3 date, 4 date and time, 5 enum, 6 id. */
|
|
10
|
+
type: number;
|
|
11
|
+
/** Before, as text. Absent when this change set it. */
|
|
12
|
+
oldValue?: string | null;
|
|
13
|
+
/** After, as text. Absent when this change cleared it. */
|
|
14
|
+
newValue?: string | null;
|
|
15
|
+
/** The words for an enum's or an id's value before, when the history kept them: RecordStatus 40 is "Trash". */
|
|
16
|
+
oldLabel?: string | null;
|
|
17
|
+
/** The words for the value after. */
|
|
18
|
+
newLabel?: string | null;
|
|
19
|
+
}
|
|
20
|
+
/** One entry of a record's history (`AuditResponseContract`). Every id in it is a token made for the reader. */
|
|
21
|
+
export interface AuditEntry {
|
|
22
|
+
/** When, in UTC. Every entry of one mass call shares it. */
|
|
23
|
+
creationTime: string;
|
|
24
|
+
/** Who made the change. */
|
|
25
|
+
creatorUserId?: string;
|
|
26
|
+
/** The company they made it in. */
|
|
27
|
+
companyId?: string;
|
|
28
|
+
/** The branch they made it in. */
|
|
29
|
+
branchId?: string;
|
|
30
|
+
/** What the request did, as the backend numbers its commands. */
|
|
31
|
+
commandType: number;
|
|
32
|
+
/** The part of the record it was about, as the backend numbers its audit keys. */
|
|
33
|
+
auditKey: number;
|
|
34
|
+
/** 0 for one record; else how many records one mass call changed, which every entry of that call shares. */
|
|
35
|
+
massCount: number;
|
|
36
|
+
/** What moved, column by column. Empty for a request that found the record already as it asked. */
|
|
37
|
+
changes?: AuditChange[];
|
|
38
|
+
}
|
|
39
|
+
export interface AuditHistoryProps {
|
|
40
|
+
/** The model's history endpoint: GET `{Model}/AuditListing/{ownerId}/{skipCount}`, as `apiController(...).audit` builds it. */
|
|
41
|
+
endpoint: ApiEndpoint;
|
|
42
|
+
/** The record's key, as the backend handed it out. */
|
|
43
|
+
id: string | number;
|
|
44
|
+
/** What one record is, for the words: "region". Default "record". */
|
|
45
|
+
subject?: string;
|
|
46
|
+
/** Its plural, when adding "s" is wrong. */
|
|
47
|
+
subjectPlural?: string;
|
|
48
|
+
/** The backend whose numbers the entries carry. Default the app's (`getCrudBackend`). */
|
|
49
|
+
backend?: CrudBackend;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A record's history, newest first, 50 entries at a time: "Show more" while the last page came back full. Each entry
|
|
53
|
+
* says what was done, when, by whom and how many records it changed at once, and lists the columns it changed with
|
|
54
|
+
* their values before and after - the words the backend kept for an enum or an id where it has them.
|
|
55
|
+
*/
|
|
56
|
+
export declare function AuditHistory({ endpoint, id, subject, subjectPlural, backend }: AuditHistoryProps): import("react").JSX.Element;
|
|
57
|
+
/** What `openAuditHistory` takes: the history's own props, and the record's name for the title bar. */
|
|
58
|
+
export interface AuditHistoryPopupOptions extends AuditHistoryProps {
|
|
59
|
+
/** Names the record beside the title: "Asia". */
|
|
60
|
+
label?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Opens a record's history in a popup, "Region history" with the record's name beside it. One popup per record:
|
|
64
|
+
* opening it again brings back the one that is open, with what it has loaded.
|
|
65
|
+
*/
|
|
66
|
+
export declare function openAuditHistory({ label, ...props }: AuditHistoryPopupOptions): import("../Layouts/PopupHost.tsx").PopupHandle;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { iconAlertCircle, iconHistory, iconInfoCircle, iconLoader2, iconRefresh, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { Fragment, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { getCrudBackend } from "../../Helpers/CrudBackend.js";
|
|
6
|
+
import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
7
|
+
import { pluralize } from "../../Helpers/MessageBuilder.js";
|
|
8
|
+
import { formatCellText } from "../../Helpers/TableHelpers.js";
|
|
9
|
+
import { api } from "../../Services/BrowserApi.js";
|
|
10
|
+
import { Button } from "../Documents/Button.js";
|
|
11
|
+
import { openPopup } from "../Layouts/PopupHost.js";
|
|
12
|
+
/** How many entries one call answers (the backend's `AUDIT_PAGE_SIZE`): a page that came back full may have more behind it. */
|
|
13
|
+
const PAGE_SIZE = 50;
|
|
14
|
+
/** What an entry did, in words: "Added", "Updated: basic information", "Flagged", "Moved to the trash". */
|
|
15
|
+
function whatOf(entry, words) {
|
|
16
|
+
const direction = words.directions[entry.auditKey];
|
|
17
|
+
if (direction)
|
|
18
|
+
return direction;
|
|
19
|
+
const command = words.commands[entry.commandType] ?? `Action ${entry.commandType}`;
|
|
20
|
+
const part = entry.commandType === words.update ? words.parts[entry.auditKey] : undefined;
|
|
21
|
+
return part ? `${command}: ${part}` : command;
|
|
22
|
+
}
|
|
23
|
+
/** How each `EAuditValueTypes` is written: as a table column of that format writes it. Text, enums and ids are left as they are. */
|
|
24
|
+
const FORMATS = { 1: "number", 2: "boolean", 3: "date", 4: "date-time" };
|
|
25
|
+
/** `EAuditValueTypes.Id`: a record's key, handed out as a token made for the reader. */
|
|
26
|
+
const ID = 6;
|
|
27
|
+
/** When an entry happened, written as the table writes a date-time column: the user's settings, in their zone. */
|
|
28
|
+
const WHEN = { key: "creationTime", label: "When", format: "date-time" };
|
|
29
|
+
const NEWLINE = String.fromCharCode(10);
|
|
30
|
+
/**
|
|
31
|
+
* A token, short. It names nobody and only tells two apart, so its first characters are enough. A token is the same
|
|
32
|
+
* text for one id on every entry this reader is given (the id is encrypted for the reader, not at random).
|
|
33
|
+
*/
|
|
34
|
+
const shortToken = (token) => (token.length > 8 ? `${token.slice(0, 6)}…` : token);
|
|
35
|
+
/** One side of a change, in words; "" when that side has no value. */
|
|
36
|
+
function sideText(change, value, label, options) {
|
|
37
|
+
if (label)
|
|
38
|
+
return label;
|
|
39
|
+
if (value === null || value === undefined || value === "")
|
|
40
|
+
return "";
|
|
41
|
+
// An id the history kept no name for: its token, which is all there is to show.
|
|
42
|
+
if (change.type === ID)
|
|
43
|
+
return `#${shortToken(value)}`;
|
|
44
|
+
return formatCellText({ key: change.column, label: change.label, format: FORMATS[change.type] ?? "text" }, value, options);
|
|
45
|
+
}
|
|
46
|
+
/** One side of a change as a cell: its words, a line of text to a line, or what having no value there means. */
|
|
47
|
+
function SideCell({ text, missing }) {
|
|
48
|
+
if (!text)
|
|
49
|
+
return (_jsx("td", { children: _jsx("span", { className: "nx-markdown__empty", children: missing }) }));
|
|
50
|
+
return (_jsx("td", { children: text.split(NEWLINE).map((line, index) => (_jsxs(Fragment, { children: [index > 0 && _jsx("br", {}), line] }, index))) }));
|
|
51
|
+
}
|
|
52
|
+
/** One entry: what was done as a heading, when and by whom under it, and the columns it changed as a small table. */
|
|
53
|
+
function HistoryEntry({ entry, words, subject, plural, options }) {
|
|
54
|
+
const changes = entry.changes ?? [];
|
|
55
|
+
const count = entry.massCount;
|
|
56
|
+
return (_jsxs(_Fragment, { children: [_jsx("h3", { children: whatOf(entry, words) }), _jsxs("p", { children: [_jsx("time", { dateTime: entry.creationTime, children: formatCellText(WHEN, entry.creationTime, options) }), entry.creatorUserId && ` · User ${shortToken(entry.creatorUserId)}`, count > 1 && ` · ${count} ${plural} at once`, count === 1 && " · from a selection"] }), changes.length > 0 ? (_jsx("div", { className: "nx-markdown__table", children: _jsxs("table", { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Field" }), _jsx("th", { scope: "col", children: "Before" }), _jsx("th", { scope: "col", children: "After" })] }) }), _jsx("tbody", { children: changes.map((change, index) => (_jsxs("tr", { children: [_jsx("td", { children: change.label || change.column }), _jsx(SideCell, { text: sideText(change, change.oldValue, change.oldLabel, options), missing: "Not set" }), _jsx(SideCell, { text: sideText(change, change.newValue, change.newLabel, options), missing: "Cleared" })] }, `${change.column}:${index}`))) })] }) })) : (_jsxs("p", { className: "nx-markdown__empty", children: ["Nothing changed: the ", subject, " was already as asked."] }))] }));
|
|
57
|
+
}
|
|
58
|
+
/** One page of a record's history, newest first: the entries after the first `skipCount`. */
|
|
59
|
+
function readHistory(endpoint, id, skipCount, options = {}) {
|
|
60
|
+
// `apiController`'s own `audit` endpoint names the key `{ownerId}`, and one a page writes itself may say `{id}`: both
|
|
61
|
+
// are given, and the path takes the one it has.
|
|
62
|
+
return api.get(endpoint, { params: { id, ownerId: id, skipCount }, subject: "the history", ...options });
|
|
63
|
+
}
|
|
64
|
+
/** A status line: an icon and its words, in the form's muted line. */
|
|
65
|
+
function Note({ icon, children, alert }) {
|
|
66
|
+
return (_jsxs("p", { className: "nx-form-status", role: alert ? "alert" : "status", children: [_jsx(InlineIcon, { icon: icon, className: icon === iconLoader2 ? "nx-spin" : undefined }), _jsx("span", { children: children })] }));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A record's history, newest first, 50 entries at a time: "Show more" while the last page came back full. Each entry
|
|
70
|
+
* says what was done, when, by whom and how many records it changed at once, and lists the columns it changed with
|
|
71
|
+
* their values before and after - the words the backend kept for an enum or an id where it has them.
|
|
72
|
+
*/
|
|
73
|
+
export function AuditHistory({ endpoint, id, subject = "record", subjectPlural, backend }) {
|
|
74
|
+
const plural = subjectPlural ?? pluralize(subject);
|
|
75
|
+
const words = (backend ?? getCrudBackend()).auditWords;
|
|
76
|
+
const preferences = useDatePreferences();
|
|
77
|
+
const timeZone = useBrowserTimeZone();
|
|
78
|
+
const options = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined }), [preferences, timeZone]);
|
|
79
|
+
const [entries, setEntries] = useState([]);
|
|
80
|
+
const [status, setStatus] = useState("loading");
|
|
81
|
+
const [problem, setProblem] = useState();
|
|
82
|
+
const [more, setMore] = useState(false);
|
|
83
|
+
const [busy, setBusy] = useState(false);
|
|
84
|
+
const [attempt, setAttempt] = useState(0);
|
|
85
|
+
// The first page. Its failure is said here, with a way to try again, rather than as a toast over the popup.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
setStatus("loading");
|
|
89
|
+
void readHistory(endpoint, id, 0, { signal: controller.signal, toast: false }).then(answer => {
|
|
90
|
+
if (answer.aborted)
|
|
91
|
+
return;
|
|
92
|
+
if (!answer.ok) {
|
|
93
|
+
setProblem(answer.message);
|
|
94
|
+
setStatus("failed");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const page = Array.isArray(answer.result) ? answer.result : [];
|
|
98
|
+
setEntries(page);
|
|
99
|
+
setMore(page.length >= PAGE_SIZE);
|
|
100
|
+
setStatus("ready");
|
|
101
|
+
});
|
|
102
|
+
return () => controller.abort();
|
|
103
|
+
}, [endpoint, id, attempt]);
|
|
104
|
+
/** The next page, after the entries already shown. A failure is the api's own toast, and the button stays to try again. */
|
|
105
|
+
async function showMore() {
|
|
106
|
+
setBusy(true);
|
|
107
|
+
const answer = await readHistory(endpoint, id, entries.length);
|
|
108
|
+
setBusy(false);
|
|
109
|
+
if (!answer.ok)
|
|
110
|
+
return;
|
|
111
|
+
const page = Array.isArray(answer.result) ? answer.result : [];
|
|
112
|
+
setEntries(current => [...current, ...page]);
|
|
113
|
+
setMore(page.length >= PAGE_SIZE);
|
|
114
|
+
}
|
|
115
|
+
if (status === "loading")
|
|
116
|
+
return _jsx(Note, { icon: iconLoader2, children: "Loading the history\u2026" });
|
|
117
|
+
if (status === "failed") {
|
|
118
|
+
return (_jsxs("div", { className: "nx-crud-details", children: [_jsx(Note, { icon: iconAlertCircle, alert: true, children: problem ?? "The history could not be loaded." }), _jsx("div", { children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh }), onClick: () => setAttempt(count => count + 1), children: "Try again" }) })] }));
|
|
119
|
+
}
|
|
120
|
+
if (entries.length === 0)
|
|
121
|
+
return _jsx(Note, { icon: iconInfoCircle, children: `Nothing has been recorded for this ${subject} yet.` });
|
|
122
|
+
return (_jsxs("div", { className: "nx-markdown", children: [entries.map((entry, index) => (_jsx(HistoryEntry, { entry: entry, words: words, subject: subject, plural: plural, options: options }, index))), more && (_jsx("p", { children: _jsx(Button, { size: "sm", loading: busy, onClick: () => void showMore(), children: "Show more" }) }))] }));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Opens a record's history in a popup, "Region history" with the record's name beside it. One popup per record:
|
|
126
|
+
* opening it again brings back the one that is open, with what it has loaded.
|
|
127
|
+
*/
|
|
128
|
+
export function openAuditHistory({ label, ...props }) {
|
|
129
|
+
const subject = props.subject ?? "record";
|
|
130
|
+
return openPopup({
|
|
131
|
+
key: `${subject}-history-${props.id}`,
|
|
132
|
+
reopen: "restore",
|
|
133
|
+
title: `${subject.charAt(0).toUpperCase()}${subject.slice(1)} history`,
|
|
134
|
+
description: label,
|
|
135
|
+
icon: _jsx(InlineIcon, { icon: iconHistory }),
|
|
136
|
+
size: "lg",
|
|
137
|
+
content: _jsx(AuditHistory, { ...props }),
|
|
138
|
+
footer: popup => (_jsx(Button, { variant: "primary", onClick: () => popup.close(), children: "Close" })),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -8,6 +8,7 @@ import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePrefer
|
|
|
8
8
|
import { readPath } from "../../Helpers/TableHelpers.js";
|
|
9
9
|
import { useNumberLocale } from "../../Helpers/UserPreferences.js";
|
|
10
10
|
import { TableCellValue } from "../Viewers/DataTableParts.js";
|
|
11
|
+
import { MarkdownView } from "../Viewers/MarkdownView.js";
|
|
11
12
|
/**
|
|
12
13
|
* An element's height as its `height` would set it: unrounded, unlike `offsetHeight`, so an unroll between two of
|
|
13
14
|
* them starts and ends exactly where the details are and the popup never steps by a fraction of a pixel. NaN while
|
|
@@ -64,5 +65,5 @@ export function CrudDetails({ row, columns, load, render }) {
|
|
|
64
65
|
// A field the list's row does not carry can only be filled by the full record. Columns that draw or work out their
|
|
65
66
|
// own value (`render`, `value`) draw as always, because only they know what they read.
|
|
66
67
|
const awaited = (column) => state !== "done" && !column.render && !column.value && readPath(record, column.key) === undefined;
|
|
67
|
-
return (_jsx("div", { ref: rootRef, className: "nx-crud-details", "aria-busy": state === "loading" || undefined, children: render ? (render(record)) : (_jsx("dl", { className: "nx-crud-details__list", children: columns.map(column => (_jsxs("div", { className: cx("nx-crud-details__item", column.wrap && "nx-crud-details__item--wide"), children: [_jsx("dt", { className: "nx-crud-details__label", children: column.label }), _jsx("dd", { className: "nx-crud-details__value", children:
|
|
68
|
+
return (_jsx("div", { ref: rootRef, className: "nx-crud-details", "aria-busy": state === "loading" || undefined, children: render ? (render(record)) : (_jsx("dl", { className: "nx-crud-details__list", children: columns.map(column => (_jsxs("div", { className: cx("nx-crud-details__item", column.wrap && "nx-crud-details__item--wide"), children: [_jsx("dt", { className: "nx-crud-details__label", children: column.label }), _jsx("dd", { className: "nx-crud-details__value", children: awaited(column) ? (state === "failed" ? _jsx("span", { className: "nx-crud-details__missing", children: "Not loaded" }) : null) : column.markdown && !column.render ? (_jsx(MarkdownView, { value: String(readPath(record, column.key) ?? ""), placeholder: `No ${column.label.toLowerCase()}` })) : (_jsx(TableCellValue, { column: column, row: record, options: options })) })] }, column.key))) })) }));
|
|
68
69
|
}
|
|
@@ -4,6 +4,7 @@ import { Fragment } from "react";
|
|
|
4
4
|
import { iconAlertCircle, iconHistory, iconX, InlineIcon } from "nexus-icons";
|
|
5
5
|
import { memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
6
6
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
|
+
import { useInputDesign } from "../../Helpers/UserPreferences.js";
|
|
7
8
|
import { formatDate, toIsoDate, usesDevanagari } from "../../Helpers/DateHelpers.js";
|
|
8
9
|
import { useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
9
10
|
import { FormStore } from "../../Helpers/FormStore.js";
|
|
@@ -122,6 +123,8 @@ const FormCell = memo(function FormCell({ store, params, context }) {
|
|
|
122
123
|
useSyncExternalStore(subscribe, version, version);
|
|
123
124
|
const stamp = store.stamp(name);
|
|
124
125
|
const span = spanOf(params);
|
|
126
|
+
// The design the inputs draw in, the person's choice included: choice boxes sit beside others only in `standard`.
|
|
127
|
+
const design = useInputDesign(context.design);
|
|
125
128
|
// An input that follows others is asked whether it shows each time it draws: it comes and goes with their values.
|
|
126
129
|
if (params.dependsOn?.length && params.hidden)
|
|
127
130
|
return null;
|
|
@@ -131,7 +134,7 @@ const FormCell = memo(function FormCell({ store, params, context }) {
|
|
|
131
134
|
return;
|
|
132
135
|
store.touch(name);
|
|
133
136
|
}
|
|
134
|
-
return (_jsx("div", { className: cx("nx-form__cell", CHOICE_TYPES.has(params.type) && "nx-form__cell--choice"), style: { "--nx-span": span }, "data-span": span, "data-beside": besideBoxes(params, span,
|
|
137
|
+
return (_jsx("div", { className: cx("nx-form__cell", CHOICE_TYPES.has(params.type) && "nx-form__cell--choice"), style: { "--nx-span": span }, "data-span": span, "data-beside": besideBoxes(params, span, design) || undefined, "data-nx-field": name, onBlur: onBlur, children: _jsx(FieldInput, { store: store, params: params, stamp: stamp, error: store.shownError(name), context: context }, stamp) }));
|
|
135
138
|
});
|
|
136
139
|
/** The note over a form whose unsaved changes came back from a draft. */
|
|
137
140
|
function DraftNotice({ store, restored, inputs, preferences }) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type ChangeEvent, type ComponentPropsWithRef, type ReactNode } from "react";
|
|
2
|
+
import type { CodeRules, InputDesign, ValidateOn } from "../../Interfaces/InputInterfaces.ts";
|
|
3
|
+
import type { Size } from "../../Interfaces/TypeInterfaces.ts";
|
|
4
|
+
type NativeInputProps = Omit<ComponentPropsWithRef<"input">, "type" | "size" | "value" | "defaultValue" | "pattern" | "minLength" | "maxLength" | "prefix" | "placeholder" | "children" | keyof CodeRules>;
|
|
5
|
+
export interface CodeBoxProps extends NativeInputProps, CodeRules {
|
|
6
|
+
label?: ReactNode;
|
|
7
|
+
/** Help text under the boxes. */
|
|
8
|
+
hint?: ReactNode;
|
|
9
|
+
/** An error from outside, such as the server's "That code is wrong". Shown before the field's own message. */
|
|
10
|
+
error?: string | null;
|
|
11
|
+
/** Accepted for forms that set one on every input; the label of a code always sits above its boxes. */
|
|
12
|
+
design?: InputDesign;
|
|
13
|
+
/** Default `md`. */
|
|
14
|
+
size?: Size;
|
|
15
|
+
/** The boxes share the field's whole width, as on a sign-in card. Default: each box keeps its size. */
|
|
16
|
+
block?: boolean;
|
|
17
|
+
/** Shown between groups, such as "-" for an SSN. Default: a wider gap only. */
|
|
18
|
+
separator?: string;
|
|
19
|
+
/** A dot in each filled box instead of its character, for a PIN or an SSN. The browser treats it as a password. */
|
|
20
|
+
masked?: boolean;
|
|
21
|
+
/** Faint text in the empty boxes: one character for every box ("0"), or one per box ("MMDDYYYY"). */
|
|
22
|
+
slotPlaceholder?: string;
|
|
23
|
+
/**
|
|
24
|
+
* The code: only the characters it takes, without separators ("123456789" for an SSN). A saved code loses only its
|
|
25
|
+
* separators and case (`loadCode`); one the boxes cannot hold - a character the code does not take, or more characters
|
|
26
|
+
* than boxes - is kept as it is and shown invalid at once, with its message, never cut.
|
|
27
|
+
*/
|
|
28
|
+
value?: string;
|
|
29
|
+
defaultValue?: string;
|
|
30
|
+
/** What a read-only field says when someone tries to change it. Default "Read-only: this code cannot be changed." */
|
|
31
|
+
readOnlyMessage?: string;
|
|
32
|
+
/** Name used in messages, e.g. "The code needs all 6 digits." Default: the label, when it is text. */
|
|
33
|
+
fieldName?: string;
|
|
34
|
+
/** When the field shows its own message. Default `blur`; submitting a form always shows it. */
|
|
35
|
+
validateOn?: ValidateOn;
|
|
36
|
+
/** The code after each change. */
|
|
37
|
+
onValueChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Once every box is filled, with the code, so a one-time code can go without a button press. Once per code, and again
|
|
40
|
+
* once the boxes were short, however they got so: typing, a form's reset, or the page emptying a controlled `value`.
|
|
41
|
+
*/
|
|
42
|
+
onComplete?: (value: string) => void;
|
|
43
|
+
/** Class of the outer field. `inputClassName` goes on the input. */
|
|
44
|
+
className?: string;
|
|
45
|
+
inputClassName?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A code typed one character to a box: a one-time code, a PIN, an SSN, a licence key, a hex colour. Digits, letters,
|
|
49
|
+
* both, hex, or any one-character pattern; boxes in groups with a separator; dots for a secret.
|
|
50
|
+
*
|
|
51
|
+
* It is one real input under the boxes, so a phone offers the code from its message (`autoComplete="one-time-code"`),
|
|
52
|
+
* a pasted "123-45-6789" fills every box, and a screen reader reads one field. The boxes are drawn from that input
|
|
53
|
+
* straight in the DOM, so typing re-renders nothing. Uncontrolled by default, like the text box; `value` also works.
|
|
54
|
+
*
|
|
55
|
+
* Typing follows one model, however the text comes - a key, a phone's keyboard, an input method, a paste, autofill
|
|
56
|
+
* (`typeCode`): a whole code replaces everything; a character goes over a selected box, or into the box with the ring -
|
|
57
|
+
* inserted while the code has an empty box, so a gap Backspace or Delete left takes back what was there, and over that
|
|
58
|
+
* box once the code is full. A click or an arrow key selects a filled box; a full code's caret selects its last box. An
|
|
59
|
+
* input method's word goes in once, when it commits.
|
|
60
|
+
*
|
|
61
|
+
* A saved code loses only its separators and case (`loadCode`); one the boxes cannot hold is kept and shown invalid at
|
|
62
|
+
* once, never cut. A screen reader hears the label, then what the boxes show: "9 digits, in groups of 3, 2 and 4", or
|
|
63
|
+
* "8 digits: DD/MM/YYYY" with a hint in each box (`describeCode`). A read-only code rings every box while it has focus.
|
|
64
|
+
*
|
|
65
|
+
* @example <CodeBox label="Verification code" autoComplete="one-time-code" required onComplete={code => verify(code)} />
|
|
66
|
+
* @example <CodeBox label="SSN" groups={[3, 2, 4]} separator="-" masked />
|
|
67
|
+
*/
|
|
68
|
+
export declare function CodeBox(props: CodeBoxProps): import("react").JSX.Element;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Fragment, useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, } from "react";
|
|
4
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
5
|
+
import { codeAfterInput, codeFits, codeGroups, codeLength, describeCode, loadCode, moveCode, typeCode, validateCode, } from "../../Helpers/CodeHelpers.js";
|
|
6
|
+
import { Field } from "./Field.js";
|
|
7
|
+
import { useInputField } from "./InputField.js";
|
|
8
|
+
import { useReadOnlyNotice } from "./ReadOnlyNotice.js";
|
|
9
|
+
/**
|
|
10
|
+
* A code typed one character to a box: a one-time code, a PIN, an SSN, a licence key, a hex colour. Digits, letters,
|
|
11
|
+
* both, hex, or any one-character pattern; boxes in groups with a separator; dots for a secret.
|
|
12
|
+
*
|
|
13
|
+
* It is one real input under the boxes, so a phone offers the code from its message (`autoComplete="one-time-code"`),
|
|
14
|
+
* a pasted "123-45-6789" fills every box, and a screen reader reads one field. The boxes are drawn from that input
|
|
15
|
+
* straight in the DOM, so typing re-renders nothing. Uncontrolled by default, like the text box; `value` also works.
|
|
16
|
+
*
|
|
17
|
+
* Typing follows one model, however the text comes - a key, a phone's keyboard, an input method, a paste, autofill
|
|
18
|
+
* (`typeCode`): a whole code replaces everything; a character goes over a selected box, or into the box with the ring -
|
|
19
|
+
* inserted while the code has an empty box, so a gap Backspace or Delete left takes back what was there, and over that
|
|
20
|
+
* box once the code is full. A click or an arrow key selects a filled box; a full code's caret selects its last box. An
|
|
21
|
+
* input method's word goes in once, when it commits.
|
|
22
|
+
*
|
|
23
|
+
* A saved code loses only its separators and case (`loadCode`); one the boxes cannot hold is kept and shown invalid at
|
|
24
|
+
* once, never cut. A screen reader hears the label, then what the boxes show: "9 digits, in groups of 3, 2 and 4", or
|
|
25
|
+
* "8 digits: DD/MM/YYYY" with a hint in each box (`describeCode`). A read-only code rings every box while it has focus.
|
|
26
|
+
*
|
|
27
|
+
* @example <CodeBox label="Verification code" autoComplete="one-time-code" required onComplete={code => verify(code)} />
|
|
28
|
+
* @example <CodeBox label="SSN" groups={[3, 2, 4]} separator="-" masked />
|
|
29
|
+
*/
|
|
30
|
+
export function CodeBox(props) {
|
|
31
|
+
const { label, hint, error, design: _design, size = "md", block = false, separator, masked = false, slotPlaceholder, length, groups, characters = "digits", allowed, letterCase = "upper", required, validate, value, defaultValue, readOnlyMessage = "Read-only: this code cannot be changed.", fieldName, validateOn, onValueChange, onComplete, className, inputClassName, style, disabled, readOnly, ref, autoComplete = "off", inputMode, onChange, onBlur, onInvalid, onKeyDown, onClick, onPaste, onFocus, onSelect, ...inputProps } = props;
|
|
32
|
+
const rules = { length, groups, characters, allowed, letterCase, required, validate };
|
|
33
|
+
const shape = { ...rules, separator, slotPlaceholder };
|
|
34
|
+
const sizes = codeGroups(rules);
|
|
35
|
+
const total = codeLength(rules);
|
|
36
|
+
const editable = !disabled && !readOnly;
|
|
37
|
+
// What the boxes say to the eye - how many, of what, in which groups, the hint in each - said to a screen reader.
|
|
38
|
+
const shapeId = useId();
|
|
39
|
+
const rowRef = useRef(null);
|
|
40
|
+
// The last code `onComplete` was told, so moving the caret or retyping the same code does not tell it again. It is
|
|
41
|
+
// forgotten whenever the boxes are short of a whole code, however they got so: typing, a form's reset, the page.
|
|
42
|
+
const completedRef = useRef(null);
|
|
43
|
+
// The input as its last change left it, for a change no `beforeinput` announced (autofill).
|
|
44
|
+
const lastRef = useRef(null);
|
|
45
|
+
// The input just before the browser changes it, from `beforeinput`.
|
|
46
|
+
const beforeRef = useRef(null);
|
|
47
|
+
// While an input method composes: the input as it was before. Its word is applied once, when it commits.
|
|
48
|
+
const composingRef = useRef(null);
|
|
49
|
+
// Set while this box announces its own change, which the browser-change listener leaves alone.
|
|
50
|
+
const announcingRef = useRef(false);
|
|
51
|
+
// A controlled box's text while an input method composes, so a render then does not put the code back over the word.
|
|
52
|
+
const [composed, setComposed] = useState(null);
|
|
53
|
+
const field = useInputField({
|
|
54
|
+
...props,
|
|
55
|
+
"aria-describedby": [shapeId, props["aria-describedby"]].filter(Boolean).join(" "),
|
|
56
|
+
onChange: event => {
|
|
57
|
+
const code = event.currentTarget.value;
|
|
58
|
+
onChange?.(event);
|
|
59
|
+
onValueChange?.(code, event);
|
|
60
|
+
if ([...code].length < total)
|
|
61
|
+
completedRef.current = null;
|
|
62
|
+
else if (completedRef.current !== code) {
|
|
63
|
+
completedRef.current = code;
|
|
64
|
+
onComplete?.(code);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}, {
|
|
68
|
+
check: (element, name) => validateCode(element.value, rules, name),
|
|
69
|
+
checkKeys: [value, required, total, sizes.join(), characters, allowed, letterCase, validate],
|
|
70
|
+
onReset: element => {
|
|
71
|
+
forgetIfShort(element.value);
|
|
72
|
+
lastRef.current = readEdit(element);
|
|
73
|
+
paint();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const readOnlyNotice = useReadOnlyNotice(Boolean(readOnly) && !disabled, field.elementRef, readOnlyMessage);
|
|
77
|
+
function forgetIfShort(code) {
|
|
78
|
+
if ([...code].length < total)
|
|
79
|
+
completedRef.current = null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Draws the boxes from the input: each one's character (or a dot), and the caret, the selection, and focus. While an
|
|
83
|
+
* input method composes, they show its word as the code will hold it.
|
|
84
|
+
*/
|
|
85
|
+
function paint() {
|
|
86
|
+
const input = field.elementRef.current;
|
|
87
|
+
const row = rowRef.current;
|
|
88
|
+
if (!input || !row)
|
|
89
|
+
return;
|
|
90
|
+
const composing = composingRef.current;
|
|
91
|
+
const shown = composing ? codeAfterInput(composing, readEdit(input), rules) : readEdit(input);
|
|
92
|
+
const characters = [...shown.value];
|
|
93
|
+
const hasFocus = !disabled && input.ownerDocument.activeElement === input;
|
|
94
|
+
const focused = editable && hasFocus;
|
|
95
|
+
// A read-only code has no caret: focus marks every box, which shows the accent border alone, as a read-only box does.
|
|
96
|
+
const lookedAt = hasFocus && !editable;
|
|
97
|
+
const { start, end } = shown;
|
|
98
|
+
row.querySelectorAll(".nx-code__slot").forEach((slot, index, slots) => {
|
|
99
|
+
const character = characters[index];
|
|
100
|
+
const text = character === undefined ? "" : masked ? "•" : character;
|
|
101
|
+
if (slot.textContent !== text)
|
|
102
|
+
slot.textContent = text;
|
|
103
|
+
const selected = focused && end > start && index >= start && index < end;
|
|
104
|
+
// A caret is drawn on the box after it; at the end of a full code, on the last box.
|
|
105
|
+
const caret = focused && start === end && index === Math.min(start, slots.length - 1);
|
|
106
|
+
mark(slot, "data-active", lookedAt || selected || caret);
|
|
107
|
+
mark(slot, "data-selected", selected);
|
|
108
|
+
mark(slot, "data-caret", caret && character === undefined);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// A value from the parent, a new shape, or the first render: the input holds the code as the boxes take it. Not
|
|
112
|
+
// while an input method composes: its word goes in when it commits. A saved code the boxes cannot hold says so at once.
|
|
113
|
+
useLayoutEffect(() => {
|
|
114
|
+
const input = field.elementRef.current;
|
|
115
|
+
if (!input)
|
|
116
|
+
return;
|
|
117
|
+
if (!composingRef.current) {
|
|
118
|
+
if (value !== undefined) {
|
|
119
|
+
const code = loadCode(value, shape);
|
|
120
|
+
if (input.value !== code)
|
|
121
|
+
input.value = code;
|
|
122
|
+
}
|
|
123
|
+
lastRef.current = readEdit(input);
|
|
124
|
+
if (editable && input.value && !codeFits(input.value, rules))
|
|
125
|
+
field.touch();
|
|
126
|
+
}
|
|
127
|
+
forgetIfShort(input.value);
|
|
128
|
+
paint();
|
|
129
|
+
});
|
|
130
|
+
// The caret moves without a change (a click, the browser's own keys): follow it while the input has focus.
|
|
131
|
+
const follow = useEffectEvent(() => {
|
|
132
|
+
const input = field.elementRef.current;
|
|
133
|
+
if (!input || input.ownerDocument.activeElement !== input)
|
|
134
|
+
return;
|
|
135
|
+
if (!composingRef.current)
|
|
136
|
+
lastRef.current = readEdit(input);
|
|
137
|
+
paint();
|
|
138
|
+
});
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const document = field.elementRef.current?.ownerDocument;
|
|
141
|
+
document?.addEventListener("selectionchange", follow);
|
|
142
|
+
return () => document?.removeEventListener("selectionchange", follow);
|
|
143
|
+
}, []);
|
|
144
|
+
/* Text the browser puts in itself: a phone's keyboard, an input method, autofill, a drop, Backspace and Delete */
|
|
145
|
+
const beforeInput = useEffectEvent(() => {
|
|
146
|
+
const input = field.elementRef.current;
|
|
147
|
+
if (input && !composingRef.current)
|
|
148
|
+
beforeRef.current = readEdit(input);
|
|
149
|
+
});
|
|
150
|
+
const compositionStart = useEffectEvent(() => {
|
|
151
|
+
const input = field.elementRef.current;
|
|
152
|
+
if (input)
|
|
153
|
+
composingRef.current = readEdit(input);
|
|
154
|
+
beforeRef.current = null;
|
|
155
|
+
});
|
|
156
|
+
// The input method committed (or gave up): its word goes in once, as typed text does, and is announced.
|
|
157
|
+
const compositionEnd = useEffectEvent(() => {
|
|
158
|
+
const input = field.elementRef.current;
|
|
159
|
+
const composing = composingRef.current;
|
|
160
|
+
composingRef.current = null;
|
|
161
|
+
if (value !== undefined)
|
|
162
|
+
setComposed(null);
|
|
163
|
+
if (input && composing)
|
|
164
|
+
apply(input, codeAfterInput(composing, readEdit(input), rules), true);
|
|
165
|
+
});
|
|
166
|
+
const browserInput = useEffectEvent((event) => {
|
|
167
|
+
const input = field.elementRef.current;
|
|
168
|
+
if (!input || announcingRef.current)
|
|
169
|
+
return;
|
|
170
|
+
if (!composingRef.current && event.isComposing)
|
|
171
|
+
composingRef.current = lastRef.current;
|
|
172
|
+
if (composingRef.current) {
|
|
173
|
+
// Mid-composition the input keeps the input method's text: rewriting it now makes the input method type its whole
|
|
174
|
+
// word again. React does not hear of it either, so nothing checks or reports a word half typed.
|
|
175
|
+
event.stopPropagation();
|
|
176
|
+
if (value !== undefined)
|
|
177
|
+
setComposed(input.value);
|
|
178
|
+
paint();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Before React's onChange: the input holds the code the model makes of the change, and React reports that.
|
|
182
|
+
const before = beforeRef.current ?? lastRef.current ?? readEdit(input);
|
|
183
|
+
beforeRef.current = null;
|
|
184
|
+
apply(input, codeAfterInput(before, readEdit(input), rules), false);
|
|
185
|
+
});
|
|
186
|
+
useLayoutEffect(() => {
|
|
187
|
+
const input = field.elementRef.current;
|
|
188
|
+
if (!input)
|
|
189
|
+
return;
|
|
190
|
+
const listeners = [
|
|
191
|
+
["beforeinput", () => beforeInput()],
|
|
192
|
+
["compositionstart", () => compositionStart()],
|
|
193
|
+
["compositionend", () => compositionEnd()],
|
|
194
|
+
["input", event => browserInput(event)],
|
|
195
|
+
];
|
|
196
|
+
for (const [name, listener] of listeners)
|
|
197
|
+
input.addEventListener(name, listener);
|
|
198
|
+
return () => {
|
|
199
|
+
for (const [name, listener] of listeners)
|
|
200
|
+
input.removeEventListener(name, listener);
|
|
201
|
+
};
|
|
202
|
+
}, []);
|
|
203
|
+
/** Puts an edit into the input and draws it; `announce` reports it as typing does, so React and every listener hear. */
|
|
204
|
+
function apply(input, edit, announce) {
|
|
205
|
+
// setRangeText leaves React's own copy of the value alone, so React sees the change and calls onChange.
|
|
206
|
+
if (input.value !== edit.value)
|
|
207
|
+
input.setRangeText(edit.value, 0, input.value.length, "end");
|
|
208
|
+
input.setSelectionRange(edit.start, edit.end);
|
|
209
|
+
lastRef.current = edit;
|
|
210
|
+
if (announce) {
|
|
211
|
+
announcingRef.current = true;
|
|
212
|
+
try {
|
|
213
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
announcingRef.current = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
paint();
|
|
220
|
+
}
|
|
221
|
+
function handleChange(event) {
|
|
222
|
+
lastRef.current = readEdit(event.currentTarget);
|
|
223
|
+
paint();
|
|
224
|
+
field.controlProps.onChange(event);
|
|
225
|
+
}
|
|
226
|
+
function handleKeyDown(event) {
|
|
227
|
+
readOnlyNotice.onKeyDown(event);
|
|
228
|
+
onKeyDown?.(event);
|
|
229
|
+
// An input method's keys are its own until it commits (229 is a key it holds, in every browser).
|
|
230
|
+
const composing = event.nativeEvent.isComposing || event.keyCode === 229;
|
|
231
|
+
if (event.defaultPrevented || !editable || composing || event.ctrlKey || event.metaKey || event.altKey)
|
|
232
|
+
return;
|
|
233
|
+
const input = event.currentTarget;
|
|
234
|
+
if (isCodeMove(event.key) && !event.shiftKey) {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
apply(input, moveCode(readEdit(input), event.key, rules), false);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if ([...event.key].length !== 1)
|
|
240
|
+
return;
|
|
241
|
+
// One character from a keyboard goes in by the model; one the code does not take is not typed at all.
|
|
242
|
+
event.preventDefault();
|
|
243
|
+
const next = typeCode(readEdit(input), event.key, rules);
|
|
244
|
+
if (next)
|
|
245
|
+
apply(input, next, true);
|
|
246
|
+
}
|
|
247
|
+
function handlePaste(event) {
|
|
248
|
+
onPaste?.(event);
|
|
249
|
+
if (event.defaultPrevented || !editable)
|
|
250
|
+
return;
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
const input = event.currentTarget;
|
|
253
|
+
// A whole code replaces what is there, a part of one goes in at the caret, and a message is read for its code.
|
|
254
|
+
const next = typeCode(readEdit(input), event.clipboardData.getData("text"), rules);
|
|
255
|
+
if (next)
|
|
256
|
+
apply(input, next, true);
|
|
257
|
+
}
|
|
258
|
+
function handleClick(event) {
|
|
259
|
+
readOnlyNotice.onClick(event);
|
|
260
|
+
onClick?.(event);
|
|
261
|
+
const input = event.currentTarget;
|
|
262
|
+
const slots = rowRef.current?.querySelectorAll(".nx-code__slot");
|
|
263
|
+
if (!editable || !slots?.length)
|
|
264
|
+
return;
|
|
265
|
+
// The box under the pointer: a filled one is selected, to type over it; past the code, the caret goes to its end.
|
|
266
|
+
let nearest = 0;
|
|
267
|
+
let distance = Infinity;
|
|
268
|
+
slots.forEach((slot, index) => {
|
|
269
|
+
const box = slot.getBoundingClientRect();
|
|
270
|
+
const away = Math.abs(event.clientX - (box.left + box.right) / 2);
|
|
271
|
+
if (away < distance) {
|
|
272
|
+
nearest = index;
|
|
273
|
+
distance = away;
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
const filled = [...input.value].length;
|
|
277
|
+
if (nearest < filled)
|
|
278
|
+
input.setSelectionRange(nearest, nearest + 1);
|
|
279
|
+
else
|
|
280
|
+
input.setSelectionRange(filled, filled);
|
|
281
|
+
paint();
|
|
282
|
+
}
|
|
283
|
+
function handleFocus(event) {
|
|
284
|
+
onFocus?.(event);
|
|
285
|
+
paint();
|
|
286
|
+
}
|
|
287
|
+
function handleBlur(event) {
|
|
288
|
+
readOnlyNotice.hide();
|
|
289
|
+
field.controlProps.onBlur(event);
|
|
290
|
+
paint();
|
|
291
|
+
}
|
|
292
|
+
const hints = [...(slotPlaceholder ?? "")];
|
|
293
|
+
let next = 0;
|
|
294
|
+
const boxes = sizes.map((count, group) => (_jsxs(Fragment, { children: [group > 0 && _jsx("span", { className: "nx-code__separator", children: separator }), _jsx("span", { className: "nx-code__group", children: Array.from({ length: count }, () => {
|
|
295
|
+
const index = next++;
|
|
296
|
+
return _jsx("span", { className: "nx-code__slot", "data-placeholder": (hints.length === 1 ? hints[0] : hints[index]) || undefined }, index);
|
|
297
|
+
}) })] }, group)));
|
|
298
|
+
return (_jsxs(_Fragment, { children: [_jsx(Field, { controlId: field.controlId, messageId: field.messageId, label: label, required: required, size: size, hint: hint, error: field.error, actions: readOnlyNotice.lock, disabled: disabled, readOnly: readOnly, className: cx("nx-field--code", className), style: style, children: _jsxs("span", { className: "nx-code", "data-block": block || undefined, children: [_jsx("span", { ref: rowRef, className: "nx-code__slots", "aria-hidden": "true", children: boxes }), _jsx("span", { id: shapeId, className: "nx-visually-hidden", children: describeCode(shape) }), _jsx("input", { autoCorrect: "off", spellCheck: false, autoCapitalize: characters === "digits" && !allowed ? "off" : "characters", ...inputProps, ...field.controlProps, type: masked ? "password" : "text", inputMode: inputMode ?? (characters === "digits" && !allowed ? "numeric" : "text"), autoComplete: autoComplete, className: cx("nx-code__input", inputClassName), value: value === undefined ? undefined : (composed ?? loadCode(value, shape)), defaultValue: value === undefined ? loadCode(defaultValue, shape) : undefined, disabled: disabled, readOnly: readOnly, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, onClick: handleClick, onFocus: handleFocus, onBlur: handleBlur, onSelect: event => {
|
|
299
|
+
onSelect?.(event);
|
|
300
|
+
paint();
|
|
301
|
+
} })] }) }), readOnlyNotice.notice] }));
|
|
302
|
+
}
|
|
303
|
+
function mark(element, name, on) {
|
|
304
|
+
if (on)
|
|
305
|
+
element.setAttribute(name, "");
|
|
306
|
+
else
|
|
307
|
+
element.removeAttribute(name);
|
|
308
|
+
}
|
|
309
|
+
/** The input's code and selection, as the typing model takes them. */
|
|
310
|
+
function readEdit(input) {
|
|
311
|
+
const value = input.value;
|
|
312
|
+
const start = input.selectionStart ?? value.length;
|
|
313
|
+
return { value, start, end: input.selectionEnd ?? start };
|
|
314
|
+
}
|
|
315
|
+
const CODE_MOVES = new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
|
|
316
|
+
const isCodeMove = (key) => CODE_MOVES.has(key);
|