nexus-shared 4.0.0 → 4.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 +795 -0
- package/README.md +220 -14
- package/dist/Client.Index.d.ts +13 -0
- package/dist/Client.Index.js +13 -0
- package/dist/Components/Chats/ChatComposer.d.ts +7 -1
- package/dist/Components/Chats/ChatComposer.js +9 -6
- package/dist/Components/Chats/ChatParts.d.ts +15 -2
- package/dist/Components/Chats/ChatParts.js +15 -4
- package/dist/Components/Chats/ChatPopup.d.ts +46 -0
- package/dist/Components/Chats/ChatPopup.js +34 -0
- package/dist/Components/Chats/ChatThread.d.ts +9 -1
- package/dist/Components/Chats/ChatThread.js +54 -6
- package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
- package/dist/Components/Dashboards/DashboardBoard.js +594 -0
- package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
- package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
- package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
- package/dist/Components/Dashboards/WidgetFrame.js +45 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/TabButtons.d.ts +6 -0
- package/dist/Components/Documents/TabButtons.js +5 -4
- package/dist/Components/Domains/DomainCard.d.ts +98 -0
- package/dist/Components/Domains/DomainCard.js +182 -0
- package/dist/Components/Forms/ApiForm.js +5 -1
- package/dist/Components/Forms/Crud.js +72 -24
- package/dist/Components/Forms/CrudActions.d.ts +5 -4
- package/dist/Components/Forms/CrudActions.js +42 -12
- package/dist/Components/Forms/CrudHistory.d.ts +6 -1
- package/dist/Components/Forms/CrudHistory.js +14 -6
- package/dist/Components/Forms/CrudParts.d.ts +11 -4
- package/dist/Components/Forms/CrudParts.js +76 -10
- package/dist/Components/Forms/CrudRowForms.js +65 -5
- package/dist/Components/Forms/CrudTree.js +7 -7
- package/dist/Components/Forms/CrudWorkspace.d.ts +15 -5
- package/dist/Components/Forms/CrudWorkspace.js +19 -11
- package/dist/Components/Forms/Form.d.ts +3 -2
- package/dist/Components/Forms/Form.js +33 -26
- package/dist/Components/Forms/FormQuestion.d.ts +6 -0
- package/dist/Components/Forms/FormQuestion.js +28 -0
- package/dist/Components/Forms/SubmitForm.js +2 -15
- package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
- package/dist/Components/Inputs/ComputedInput.js +179 -0
- package/dist/Components/Inputs/Dropdown.js +4 -2
- package/dist/Components/Inputs/GroupForm.js +3 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +5 -1
- package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
- package/dist/Components/Inputs/MarkdownEditor.js +32 -6
- package/dist/Components/Inputs/PictureInput.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +14 -1
- package/dist/Components/Inputs/RowsInput.js +78 -5
- package/dist/Components/Inputs/TabularForm.js +16 -3
- package/dist/Components/Inputs/TreeView.d.ts +24 -1
- package/dist/Components/Inputs/TreeView.js +84 -7
- package/dist/Components/Layouts/Popup.d.ts +22 -2
- package/dist/Components/Layouts/Popup.js +108 -6
- package/dist/Components/Messages/MailClient.d.ts +50 -0
- package/dist/Components/Messages/MailClient.js +410 -0
- package/dist/Components/Messages/MailColumns.d.ts +21 -0
- package/dist/Components/Messages/MailColumns.js +92 -0
- package/dist/Components/Messages/MailCompose.d.ts +46 -0
- package/dist/Components/Messages/MailCompose.js +154 -0
- package/dist/Components/Messages/MailReader.d.ts +43 -0
- package/dist/Components/Messages/MailReader.js +207 -0
- package/dist/Components/Messages/MailThread.d.ts +71 -0
- package/dist/Components/Messages/MailThread.js +185 -0
- package/dist/Components/Messages/MessageBell.d.ts +41 -0
- package/dist/Components/Messages/MessageBell.js +76 -0
- package/dist/Components/Viewers/DataTable.js +7 -5
- package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
- package/dist/Components/Viewers/DataTableColumns.js +2 -1
- package/dist/Components/Viewers/DataTableParts.d.ts +3 -1
- package/dist/Components/Viewers/DataTableParts.js +2 -2
- package/dist/Components/Viewers/OwnerFiles.d.ts +57 -5
- package/dist/Components/Viewers/OwnerFiles.js +142 -31
- package/dist/Helpers/ApiModules.js +4 -0
- package/dist/Helpers/AuditEntries.d.ts +50 -0
- package/dist/Helpers/AuditEntries.js +96 -0
- package/dist/Helpers/ChatBackend.d.ts +8 -1
- package/dist/Helpers/ChatBackend.js +18 -0
- package/dist/Helpers/ChatHelpers.d.ts +2 -0
- package/dist/Helpers/ChatHelpers.js +4 -1
- package/dist/Helpers/CrudColumns.d.ts +44 -6
- package/dist/Helpers/CrudColumns.js +63 -13
- package/dist/Helpers/CrudHelpers.d.ts +36 -1
- package/dist/Helpers/CrudHelpers.js +61 -0
- package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
- package/dist/Helpers/CrudPickerPlan.js +46 -0
- package/dist/Helpers/CrudRowForms.d.ts +18 -1
- package/dist/Helpers/CrudRowForms.js +35 -2
- package/dist/Helpers/DashboardHelpers.d.ts +115 -0
- package/dist/Helpers/DashboardHelpers.js +305 -0
- package/dist/Helpers/DomainCards.d.ts +140 -0
- package/dist/Helpers/DomainCards.js +370 -0
- package/dist/Helpers/FileAdapter.d.ts +82 -2
- package/dist/Helpers/FileAdapter.js +72 -1
- package/dist/Helpers/FormStore.d.ts +11 -1
- package/dist/Helpers/FormStore.js +66 -19
- package/dist/Helpers/InputConditions.d.ts +6 -0
- package/dist/Helpers/InputConditions.js +24 -0
- package/dist/Helpers/InputMemory.js +40 -30
- package/dist/Helpers/InputNarrowing.d.ts +38 -6
- package/dist/Helpers/InputNarrowing.js +102 -21
- package/dist/Helpers/MailClientHelpers.d.ts +96 -0
- package/dist/Helpers/MailClientHelpers.js +211 -0
- package/dist/Helpers/MessageBackend.d.ts +152 -0
- package/dist/Helpers/MessageBackend.js +329 -0
- package/dist/Helpers/MessageHelpers.d.ts +160 -0
- package/dist/Helpers/MessageHelpers.js +573 -0
- package/dist/Helpers/MessageUnread.d.ts +95 -0
- package/dist/Helpers/MessageUnread.js +245 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +17 -1
- package/dist/Helpers/NexusDriveAdapter.js +32 -3
- package/dist/Helpers/NumberHelpers.d.ts +61 -0
- package/dist/Helpers/NumberHelpers.js +124 -8
- package/dist/Helpers/PagedPicker.d.ts +42 -0
- package/dist/Helpers/PagedPicker.js +85 -0
- package/dist/Helpers/RowsStore.d.ts +24 -0
- package/dist/Helpers/RowsStore.js +79 -10
- package/dist/Helpers/TableHelpers.d.ts +15 -2
- package/dist/Helpers/TableHelpers.js +28 -1
- package/dist/Helpers/TableStore.d.ts +7 -3
- package/dist/Helpers/TableStore.js +9 -4
- package/dist/Helpers/ToolbarFit.d.ts +23 -0
- package/dist/Helpers/ToolbarFit.js +30 -0
- package/dist/Helpers/TreeStore.d.ts +75 -6
- package/dist/Helpers/TreeStore.js +182 -18
- package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
- package/dist/Interfaces/ApiInterfaces.js +10 -1
- package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +78 -5
- package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
- package/dist/Interfaces/DashboardInterfaces.js +1 -0
- package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
- package/dist/Interfaces/DomainInterfaces.js +1 -0
- package/dist/Interfaces/FileInterfaces.d.ts +21 -0
- package/dist/Interfaces/FileInterfaces.js +2 -0
- package/dist/Interfaces/FormInterfaces.d.ts +78 -11
- package/dist/Interfaces/InputInterfaces.d.ts +12 -5
- package/dist/Interfaces/MailInterfaces.d.ts +192 -0
- package/dist/Interfaces/MailInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +28 -2
- package/dist/Services/JoinSources.d.ts +47 -6
- package/dist/Services/JoinSources.js +68 -33
- package/dist/Services/MessageApi.d.ts +18 -0
- package/dist/Services/MessageApi.js +64 -0
- package/dist/Shared.Index.d.ts +12 -0
- package/dist/Shared.Index.js +12 -0
- package/package.json +7 -1
- package/src/Styles/Nexus.Button.css +29 -0
- package/src/Styles/Nexus.Chat.css +114 -22
- package/src/Styles/Nexus.Crud.css +119 -28
- package/src/Styles/Nexus.Dashboard.css +763 -0
- package/src/Styles/Nexus.Domain.css +384 -0
- package/src/Styles/Nexus.Editor.css +20 -0
- package/src/Styles/Nexus.Files.css +27 -0
- package/src/Styles/Nexus.Index.css +3 -0
- package/src/Styles/Nexus.Input.css +31 -0
- package/src/Styles/Nexus.Mail.css +1062 -0
- package/src/Styles/Nexus.Popup.css +58 -0
- package/src/Styles/Nexus.Table.css +1458 -1449
- package/src/Styles/Nexus.Theme.Picker.css +1 -0
- package/src/Styles/Nexus.Tree.css +10 -0
|
@@ -9,7 +9,7 @@ import { formConfirmAsks, formConfirmQuestion } from "../../Helpers/FormConfirm.
|
|
|
9
9
|
import { FORM_SAVED_EVENT } from "../../Helpers/InputMemory.js";
|
|
10
10
|
import { Button } from "../Documents/Button.js";
|
|
11
11
|
import { messageBox } from "../Layouts/MessageBox.js";
|
|
12
|
-
import {
|
|
12
|
+
import { askFormQuestion } from "./FormQuestion.js";
|
|
13
13
|
import { Form, formatFormTime, formInputLabel, namesToShow, useFormStatus, useFormStore } from "./Form.js";
|
|
14
14
|
import { FormLeaveGuardContext, pageLeaveGuard, PageLeaveGuardContext, unloadLeaveGuard } from "./FormLeaveGuard.js";
|
|
15
15
|
/** The problems after a submit, each a button that goes to its input: "2 fields need attention: Email, Phone." */
|
|
@@ -159,20 +159,7 @@ async function askBeforeSaving(confirm, values, context, makeBody) {
|
|
|
159
159
|
const body = makeBody ? await makeBody(values, context) : values;
|
|
160
160
|
if (context.signal.aborted)
|
|
161
161
|
return false;
|
|
162
|
-
|
|
163
|
-
const details = question.details;
|
|
164
|
-
return messageBox.confirm({
|
|
165
|
-
tone: "question",
|
|
166
|
-
closable: true,
|
|
167
|
-
closeOnBackdrop: false,
|
|
168
|
-
signal: context.signal,
|
|
169
|
-
size: question.size,
|
|
170
|
-
title: question.title,
|
|
171
|
-
message: question.message,
|
|
172
|
-
details: details ? _jsx(CrudDetails, { row: details.row, columns: details.columns, currency: details.currency }) : null,
|
|
173
|
-
confirmLabel: question.confirmLabel,
|
|
174
|
-
cancelLabel: question.cancelLabel,
|
|
175
|
-
});
|
|
162
|
+
return askFormQuestion(formConfirmQuestion(confirm, values, body), context.signal);
|
|
176
163
|
}
|
|
177
164
|
/** Asks "Discard your changes?" when the form holds changes worth keeping (`isWorthKeeping`): true to go on. */
|
|
178
165
|
export async function confirmDiscard(form) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type FormValues, type InputParams } from "../../Interfaces/FormInterfaces.ts";
|
|
2
|
+
import type { InputDesign } from "../../Interfaces/InputInterfaces.ts";
|
|
3
|
+
import type { Size } from "../../Interfaces/TypeInterfaces.ts";
|
|
4
|
+
/** An input's name in messages: its `fieldName`, its label when it is text, or its name. */
|
|
5
|
+
export declare function formInputLabel(params: InputParams | undefined, name?: string): string;
|
|
6
|
+
/** What the line under a worked-out input that was typed in says: what was set, or why nothing was. */
|
|
7
|
+
export interface ComputedNote {
|
|
8
|
+
text: string;
|
|
9
|
+
/** The figure was refused: nothing was set. */
|
|
10
|
+
refused: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ComputedInputProps {
|
|
13
|
+
params: InputParams;
|
|
14
|
+
/** The control's id. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** The values it is worked out from as they are now: the form's, or the row's. Read on every draw and commit. */
|
|
17
|
+
read: () => FormValues;
|
|
18
|
+
/** The page's `computeWith`, handed to `compute.value` and `compute.solve`. */
|
|
19
|
+
withValue?: unknown;
|
|
20
|
+
/** Takes the values `compute.solve` answered as the person's own change. Not called for a refusal or for nothing new. */
|
|
21
|
+
write: (values: FormValues) => void;
|
|
22
|
+
/** Another input's params by name, as they are now: its name in the note, its number format, and whether it may change. */
|
|
23
|
+
inputParams: (name: string) => InputParams | undefined;
|
|
24
|
+
/** The control id of an input it is worked out from, for the `<output>`'s `for`. */
|
|
25
|
+
sourceId?: (name: string) => string;
|
|
26
|
+
size: Size;
|
|
27
|
+
/** The design around it (a form's); its params' own wins. */
|
|
28
|
+
design?: InputDesign;
|
|
29
|
+
/** The values are loading: it shows nothing and takes nothing. */
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
readOnly?: boolean;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
/** Why a box that can be typed in is read-only here, for its note. */
|
|
34
|
+
readOnlyMessage?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Drawn in a table cell: no label (the column header is it), named `aria-label` for screen readers, the standard
|
|
37
|
+
* design, and the line under the box handed to `onNote` - a table cell has no line under it.
|
|
38
|
+
*/
|
|
39
|
+
cell?: {
|
|
40
|
+
"aria-label": string;
|
|
41
|
+
};
|
|
42
|
+
/** Hears the note as it comes and goes (`cell`): a table shows it in the line under the rows. */
|
|
43
|
+
onNote?: (note: ComputedNote | null) => void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* An input worked out from others (`compute`): the field's frame (label, hint, prefix, suffix, size, design) around an
|
|
47
|
+
* `<output>` that says the value as it is now, in the person's number format, on the read-only tint - never typed in, so
|
|
48
|
+
* no lock, no note, no steppers and no Tab stop. Blank while the values load, and for a value that is no figure. A Number
|
|
49
|
+
* input with `compute.solve` is a box that can be typed in instead (`SolvableField`).
|
|
50
|
+
*/
|
|
51
|
+
export declare function ComputedInput(props: ComputedInputProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { iconAlertCircle, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useLayoutEffect, useState } from "react";
|
|
5
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
|
+
import { computedText, computedValue } from "../../Helpers/InputConditions.js";
|
|
7
|
+
import { inputFieldName } from "../../Helpers/InputParamsHelpers.js";
|
|
8
|
+
import { formatNumber, parseNumber, roundDecimal } from "../../Helpers/NumberHelpers.js";
|
|
9
|
+
import { useInputDesign, useNumberLocale } from "../../Helpers/UserPreferences.js";
|
|
10
|
+
import { EInputTypes } from "../../Interfaces/FormInterfaces.js";
|
|
11
|
+
import { Field } from "./Field.js";
|
|
12
|
+
import { NumberBox } from "./NumberBox.js";
|
|
13
|
+
/*
|
|
14
|
+
* An input worked out from others (`compute`), wherever it is drawn: a form's cell or a row's. It holds no value of its
|
|
15
|
+
* own; it reads the values it comes from through `read` and, when it can be typed in (`compute.solve`), writes the ones
|
|
16
|
+
* the typed figure gives through `write`, as the person's own change (`FormStore.setFromPerson`, `RowsStore.setFromPerson`).
|
|
17
|
+
*/
|
|
18
|
+
/** An input's name in messages: its `fieldName`, its label when it is text, or its name. */
|
|
19
|
+
export function formInputLabel(params, name = "") {
|
|
20
|
+
if (!params)
|
|
21
|
+
return name;
|
|
22
|
+
return inputFieldName(params) ?? (typeof params.label === "string" ? params.label : params.name);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* An input worked out from others (`compute`): the field's frame (label, hint, prefix, suffix, size, design) around an
|
|
26
|
+
* `<output>` that says the value as it is now, in the person's number format, on the read-only tint - never typed in, so
|
|
27
|
+
* no lock, no note, no steppers and no Tab stop. Blank while the values load, and for a value that is no figure. A Number
|
|
28
|
+
* input with `compute.solve` is a box that can be typed in instead (`SolvableField`).
|
|
29
|
+
*/
|
|
30
|
+
export function ComputedInput(props) {
|
|
31
|
+
const { params, id, read, withValue, sourceId, size, loading = false, cell } = props;
|
|
32
|
+
const locale = useNumberLocale();
|
|
33
|
+
const own = params;
|
|
34
|
+
const design = useInputDesign(cell ? "standard" : (own.design ?? props.design));
|
|
35
|
+
const disabled = Boolean(props.disabled) || Boolean(own.disabled);
|
|
36
|
+
const text = loading ? "" : computedText(params, computedValue(params, read(), withValue), locale);
|
|
37
|
+
if (params.compute?.solve && params.type === EInputTypes.Number) {
|
|
38
|
+
return _jsx(SolvableField, { ...props, text: text, design: design, disabled: disabled });
|
|
39
|
+
}
|
|
40
|
+
const messageId = `${id}-message`;
|
|
41
|
+
const hint = cell ? undefined : own.hint;
|
|
42
|
+
// The inputs it is worked out from, as an output names them.
|
|
43
|
+
const from = sourceId ? params.compute?.from.map(sourceId).join(" ") : "";
|
|
44
|
+
return (_jsx(Field, { controlId: id, messageId: messageId, label: cell ? undefined : params.label, hint: hint, design: design, size: own.size ?? size, icon: own.icon, iconEnd: own.iconEnd, prefix: own.prefix, suffix: own.suffix, readOnly: true, disabled: disabled, raiseLabel: true, className: own.className, children: _jsx("output", { id: id, htmlFor: from || undefined, className: cx("nx-field__control", "nx-field__control--output", params.type === EInputTypes.Number && "nx-field__control--number"), "aria-live": "polite", "aria-label": cell?.["aria-label"], "aria-describedby": hint ? messageId : undefined, "aria-disabled": disabled || undefined, children: _jsx("span", { className: "nx-field__output-text", children: text }) }) }));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A worked-out Number input that can be typed in (`compute.solve`): a NumberBox showing the figure worked forward, its
|
|
48
|
+
* own Tab stop, in the person's number format. Typing does nothing until the person leaves the box or presses Enter;
|
|
49
|
+
* then `solve` answers the inputs' values that give the figure, `write` takes them as the person's change, and the box
|
|
50
|
+
* shows the figure worked forward again, with a line under it that says what was set (or why nothing was). The hint and
|
|
51
|
+
* that line share one place, so nothing below moves when it comes and goes. Escape puts back what the box showed.
|
|
52
|
+
*/
|
|
53
|
+
function SolvableField(props) {
|
|
54
|
+
const { params, id, read, withValue, write, inputParams, size, loading = false, cell, onNote, readOnlyMessage, text, design, disabled } = props;
|
|
55
|
+
const own = params;
|
|
56
|
+
const preferred = useNumberLocale();
|
|
57
|
+
const [note, setNote] = useState(null);
|
|
58
|
+
const format = { decimals: own.decimals, fixedDecimals: own.fixedDecimals ?? own.decimals !== undefined, grouping: own.grouping ?? true, locale: own.locale ?? preferred };
|
|
59
|
+
const readOnly = Boolean(props.readOnly) || Boolean(own.readOnly);
|
|
60
|
+
const shownNote = note && note.at === text ? note : null;
|
|
61
|
+
const shownText = shownNote?.text ?? null;
|
|
62
|
+
const shownRefused = Boolean(shownNote?.refused);
|
|
63
|
+
// A table hears the note as it comes and goes, and that it went with the cell (its row scrolled away).
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
if (!onNote)
|
|
66
|
+
return;
|
|
67
|
+
onNote(shownText === null ? null : { text: shownText, refused: shownRefused });
|
|
68
|
+
}, [onNote, shownText, shownRefused]);
|
|
69
|
+
useLayoutEffect(() => (onNote ? () => onNote(null) : undefined), [onNote]);
|
|
70
|
+
/** The figure worked forward from the values as they are now: as a number, and as the box writes it. */
|
|
71
|
+
const forward = () => {
|
|
72
|
+
const value = computedValue(params, read(), withValue);
|
|
73
|
+
const number = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
|
|
74
|
+
return Number.isFinite(number) ? { number, text: formatNumber(number, format) } : { number: null, text: "" };
|
|
75
|
+
};
|
|
76
|
+
const number = loading ? null : forward().number;
|
|
77
|
+
/** The box shows the figure worked forward: without group marks while it is being typed in. */
|
|
78
|
+
function showForward(input) {
|
|
79
|
+
const now = forward();
|
|
80
|
+
input.value = now.number === null ? "" : formatNumber(now.number, document.activeElement === input ? { ...format, grouping: false } : format);
|
|
81
|
+
}
|
|
82
|
+
/** A source input's value as its box writes it, for the line under the box. */
|
|
83
|
+
function written(name, value) {
|
|
84
|
+
const sourceParams = inputParams(name);
|
|
85
|
+
if (typeof value === "number" && (!sourceParams || sourceParams.type === EInputTypes.Number)) {
|
|
86
|
+
const decimals = sourceParams?.decimals;
|
|
87
|
+
return formatNumber(value, { decimals, fixedDecimals: sourceParams?.fixedDecimals ?? decimals !== undefined, grouping: sourceParams?.grouping ?? true, locale: sourceParams?.locale ?? preferred });
|
|
88
|
+
}
|
|
89
|
+
return value === null || value === undefined ? "nothing" : String(value);
|
|
90
|
+
}
|
|
91
|
+
/** Works the typed figure back into the inputs it comes from. Answers whether the box had a new figure to take. */
|
|
92
|
+
function commit(input) {
|
|
93
|
+
const solve = params.compute?.solve;
|
|
94
|
+
const now = forward();
|
|
95
|
+
const parsed = parseNumber(input.value, format.locale);
|
|
96
|
+
if (!solve || loading || readOnly || disabled || parsed === null || Number.isNaN(parsed)) {
|
|
97
|
+
showForward(input);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const typed = own.decimals === undefined ? parsed : roundDecimal(parsed, own.decimals);
|
|
101
|
+
const typedText = formatNumber(typed, format);
|
|
102
|
+
if (typedText === now.text) {
|
|
103
|
+
showForward(input);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
let answer;
|
|
107
|
+
try {
|
|
108
|
+
answer = solve.call(params.compute, typed, read(), withValue);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
console.error(`Working "${params.name}" backwards failed:`, error);
|
|
112
|
+
answer = `${typedText} could not be worked back into the figures it comes from.`;
|
|
113
|
+
}
|
|
114
|
+
if (answer && typeof answer === "object") {
|
|
115
|
+
// A figure is never written into an input the person could not change themselves.
|
|
116
|
+
const locked = Object.keys(answer.values)
|
|
117
|
+
.map(name => inputParams(name))
|
|
118
|
+
.find(found => found && (found.readOnly || found.disabled));
|
|
119
|
+
if (locked)
|
|
120
|
+
answer = `${formInputLabel(locked, locked.name)} cannot be changed here, so ${typedText} cannot be set.`;
|
|
121
|
+
}
|
|
122
|
+
if (typeof answer === "string") {
|
|
123
|
+
setNote({ text: answer, refused: true, at: now.text });
|
|
124
|
+
showForward(input);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (!answer) {
|
|
128
|
+
showForward(input);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
const before = read();
|
|
132
|
+
write(answer.values);
|
|
133
|
+
const after = read();
|
|
134
|
+
const reached = forward();
|
|
135
|
+
const set = Object.keys(answer.values)
|
|
136
|
+
.filter(name => !Object.is(before[name], after[name]))
|
|
137
|
+
.map(name => `${formInputLabel(inputParams(name), name)} set to ${written(name, after[name])}`);
|
|
138
|
+
const said = set.length ? `${set.join(", ")}.` : "";
|
|
139
|
+
const words = answer.note ?? (reached.text === typedText ? said : `${typedText} cannot be reached exactly; nearest is ${reached.text}.${said ? ` ${said}` : ""}`);
|
|
140
|
+
setNote(words ? { text: words, refused: false, at: reached.text } : null);
|
|
141
|
+
showForward(input);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
const hint = own.hint;
|
|
145
|
+
// In a table the line goes under the rows (`onNote`); a hidden live region still says it from the cell.
|
|
146
|
+
const footer = cell ? undefined : (_jsxs("span", { className: "nx-field__notes", children: [_jsx("span", { className: cx(shownNote && "nx-field__note--off"), children: hint }), _jsxs("span", { className: cx("nx-field__note", shownNote?.refused && "nx-field__note--refused"), "aria-live": "polite", children: [shownNote?.refused && _jsx(InlineIcon, { icon: iconAlertCircle }), shownNote?.text] })] }));
|
|
147
|
+
const box = (_jsx(NumberBox, { id: id, label: cell ? undefined : params.label, "aria-label": cell?.["aria-label"], hint: footer, design: design, size: own.size ?? size, icon: own.icon, iconEnd: own.iconEnd, prefix: own.prefix, suffix: own.suffix, placeholder: own.placeholder, decimals: format.decimals, fixedDecimals: format.fixedDecimals, grouping: format.grouping, locale: own.locale, value: number, readOnly: readOnly, readOnlyMessage: readOnlyMessage, disabled: disabled || loading, className: own.className, onBlur: event => {
|
|
148
|
+
// Where the focus was going: a box the answer sets (a click into Price) mounts again with its value, so the
|
|
149
|
+
// focus would fall to the page; it goes to the box that took its place (the same id) instead.
|
|
150
|
+
const next = event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
|
|
151
|
+
if (!commit(event.currentTarget) || !next?.id)
|
|
152
|
+
return;
|
|
153
|
+
const nextId = next.id;
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
const active = document.activeElement;
|
|
156
|
+
if (next.isConnected || (active && active !== document.body))
|
|
157
|
+
return;
|
|
158
|
+
document.getElementById(nextId)?.focus();
|
|
159
|
+
});
|
|
160
|
+
}, onKeyDown: event => {
|
|
161
|
+
if (event.key !== "Enter" && event.key !== "Escape")
|
|
162
|
+
return;
|
|
163
|
+
const input = event.currentTarget;
|
|
164
|
+
const parsed = parseNumber(input.value, format.locale);
|
|
165
|
+
const shown = parsed === null || Number.isNaN(parsed) ? "" : formatNumber(own.decimals === undefined ? parsed : roundDecimal(parsed, own.decimals), format);
|
|
166
|
+
// Nothing new in the box: Enter submits the form (or moves down a table's column) and Escape closes a popup.
|
|
167
|
+
if (shown === forward().text)
|
|
168
|
+
return;
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
event.stopPropagation();
|
|
171
|
+
if (event.key === "Enter")
|
|
172
|
+
commit(input);
|
|
173
|
+
else
|
|
174
|
+
showForward(input);
|
|
175
|
+
} }));
|
|
176
|
+
if (!cell)
|
|
177
|
+
return box;
|
|
178
|
+
return (_jsxs(_Fragment, { children: [box, _jsx("span", { className: "nx-visually-hidden", "aria-live": "polite", children: shownNote?.text })] }));
|
|
179
|
+
}
|
|
@@ -305,7 +305,9 @@ export function Dropdown(props) {
|
|
|
305
305
|
const activeIndex = !open ? -1 : active >= 0 && active < rows.length ? active : fallbackActive();
|
|
306
306
|
const staleRows = Boolean(loadOptions) && server.status === "loading" && server.items.length > 0;
|
|
307
307
|
// An open server list that has not started its first request yet is loading too: its placeholder rows already show.
|
|
308
|
-
|
|
308
|
+
// The value itself is still coming (`loading`: a form asking a picker for a remembered value; or its items resolving).
|
|
309
|
+
const valueBusy = loading || pendingRef.current.size > 0;
|
|
310
|
+
const busy = valueBusy || (Boolean(loadOptions) && (server.status === "loading" || server.status === "more" || (open && server.status === "idle")));
|
|
309
311
|
// Select all and Clear act on every option the list holds for the search, rendered yet or not, and on the current
|
|
310
312
|
// values shown at its top. The options are split once per list and selection, not on every highlight move.
|
|
311
313
|
const showBulk = open && multiple && selectAll;
|
|
@@ -809,7 +811,7 @@ export function Dropdown(props) {
|
|
|
809
811
|
// The check column, the columns, and an empty last track whose gap keeps text off the row's end.
|
|
810
812
|
const gridColumns = columns ? `auto ${columns.map(column => columnTrack(column)).join(" ")} 0px` : undefined;
|
|
811
813
|
const panelStyle = panelWidth === undefined ? undefined : { width: typeof panelWidth === "number" ? `${panelWidth}px` : panelWidth };
|
|
812
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Field, { controlId: field.controlId, messageId: field.messageId, label: label, required: required, design: design, size: size, hint: hint, error: field.error, invalid: invalid, icon: icon, actions: _jsxs(_Fragment, { children: [busy && (_jsx("span", { className: "nx-dropdown__spinner", title: "Loading", "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) })), showClear && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__clear", tabIndex: -1, "aria-label": "Clear", title: "Clear", onMouseDown: event => event.preventDefault(), onClick: clear, children: _jsx(InlineIcon, { icon: iconX }) })), readOnlyNotice.lock, !readOnly && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__toggle", tabIndex: -1, "aria-label": open ? "Close the list" : "Open the list", "aria-expanded": open, "aria-controls": open ? listId : undefined, disabled: disabled, onMouseDown: event => event.preventDefault(), onClick: () => (open ? closePanel() : openPanel()), children: _jsx(InlineIcon, { icon: iconChevronDown }) }))] }), disabled: disabled, readOnly: readOnly, raiseLabel: values.length > 0 || Boolean(placeholder), className: cx("nx-field--dropdown", className), style: style, children: [_jsxs("div", { className: cx("nx-dropdown__control", multiple ? "nx-dropdown__control--multiple" : "nx-dropdown__control--single"), "data-searchable": searchable || undefined, "data-has-value": values.length > 0 || undefined, onMouseDown: handleControlMouseDown, children: [boxContent, _jsx("input", { autoComplete: "off", spellCheck: false, ...inputProps, ...field.controlProps, type: "text", role: "combobox", "aria-expanded": open, "aria-haspopup": "listbox", "aria-controls": open ? listId : undefined, "aria-activedescendant": activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined, "aria-autocomplete": searchable ? "list" : "none", "aria-busy":
|
|
814
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Field, { controlId: field.controlId, messageId: field.messageId, label: label, required: required, design: design, size: size, hint: hint, error: field.error, invalid: invalid, icon: icon, actions: _jsxs(_Fragment, { children: [busy && (_jsx("span", { className: "nx-dropdown__spinner", title: "Loading", "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) })), showClear && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__clear", tabIndex: -1, "aria-label": "Clear", title: "Clear", onMouseDown: event => event.preventDefault(), onClick: clear, children: _jsx(InlineIcon, { icon: iconX }) })), readOnlyNotice.lock, !readOnly && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__toggle", tabIndex: -1, "aria-label": open ? "Close the list" : "Open the list", "aria-expanded": open, "aria-controls": open ? listId : undefined, disabled: disabled, onMouseDown: event => event.preventDefault(), onClick: () => (open ? closePanel() : openPanel()), children: _jsx(InlineIcon, { icon: iconChevronDown }) }))] }), disabled: disabled, readOnly: readOnly, raiseLabel: values.length > 0 || Boolean(placeholder), className: cx("nx-field--dropdown", className), style: style, children: [_jsxs("div", { className: cx("nx-dropdown__control", multiple ? "nx-dropdown__control--multiple" : "nx-dropdown__control--single"), "data-searchable": searchable || undefined, "data-has-value": values.length > 0 || undefined, onMouseDown: handleControlMouseDown, children: [boxContent, _jsx("input", { autoComplete: "off", spellCheck: false, ...inputProps, ...field.controlProps, type: "text", role: "combobox", "aria-expanded": open, "aria-haspopup": "listbox", "aria-controls": open ? listId : undefined, "aria-activedescendant": activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined, "aria-autocomplete": searchable ? "list" : "none", "aria-busy": valueBusy || undefined, inputMode: searchable ? inputProps.inputMode : "none", name: undefined, form: form, className: cx("nx-field__control", "nx-dropdown__input", inputClassName), placeholder: values.length > 0 ? " " : placeholder || " ", disabled: disabled, readOnly: readOnly, onBlur: handleBlur, onFocus: onFocus, onKeyDown: handleKeyDown, onClick: handleClick }), _jsx("span", { id: selectionId, className: "nx-visually-hidden", children: values.length > 0 ? `Selected: ${namesOf(values)}` : valueBusy ? "Loading" : "" })] }), name &&
|
|
813
815
|
(multiple ? (values.map(value => _jsx("input", { type: "hidden", name: name, form: form, disabled: disabled, value: optionKey(value) }, optionKey(value)))) : (_jsx("input", { type: "hidden", name: name, form: form, disabled: disabled, value: optionKey(values[0]) })))] }), readOnlyNotice.notice, !readOnly && (_jsx("div", { ref: panelRef, popover: "manual", className: cx("nx-popover", "nx-dropdown-panel", `nx-dropdown-panel--${size}`), style: panelStyle, onKeyDown: handlePanelKeyDown, onBlur: handlePanelBlur,
|
|
814
816
|
// The box keeps focus while rows and buttons are pressed. A press on the list itself is left alone, so its
|
|
815
817
|
// scroll bar can be dragged.
|
|
@@ -63,7 +63,7 @@ const GroupSection = memo(function GroupSection({ store, rowKey, index, count, i
|
|
|
63
63
|
store.touch(rowKey, cell.dataset.nxCell ?? "");
|
|
64
64
|
}
|
|
65
65
|
const heading = (_jsxs(_Fragment, { children: [_jsx("span", { id: titleId, className: "nx-group-form__title", children: title }), tags.map(tag => (_jsx("span", { className: "nx-group-form__tag", children: tag }, tag))), summary && _jsx("span", { className: "nx-group-form__summary", children: summary }), problems > 0 && (_jsxs("span", { className: "nx-group-form__problems", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), problems, " to fix"] }))] }));
|
|
66
|
-
return (_jsxs("li", { className: "nx-group-form__section", "data-nx-row": rowKey, "data-open": open || undefined, "data-problem": problems > 0 || undefined, children: [_jsxs("div", { className: "nx-group-form__head", children: [context.collapsible ? (_jsxs("button", { type: "button", className: "nx-group-form__toggle", "aria-expanded": open, "aria-controls": open ? bodyId : undefined, "data-nx-row-actions": "", onClick: () => setCollapsed(open), children: [_jsx(InlineIcon, { icon: iconChevronDown, className: "nx-group-form__chevron" }), heading] })) : (_jsx("div", { className: "nx-group-form__heading", children: heading })), (context.sortable || context.canAdd || context.removable) && (_jsxs("div", { className: "nx-group-form__actions", children: [context.sortable && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconArrowUp }), "aria-label": `Move ${name} up`, title: "Move up", disabled: index === 0, onClick: () => onAction("move-up", rowKey) }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconArrowDown }), "aria-label": `Move ${name} down`, title: "Move down", disabled: index === count - 1, onClick: () => onAction("move-down", rowKey) })] })), context.canAdd && (_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconCopy }), "aria-label": `Duplicate ${name}`, title: "Duplicate", onClick: () => onAction("duplicate", rowKey) })), context.removable && (_jsx(Button, { variant: "ghost", size: "sm", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Remove ${name}`, title: "Remove", onClick: () => onAction("remove", rowKey) }))] }))] }), open && (_jsx("div", { id: bodyId, role: "group", "aria-labelledby": titleId, className: "nx-group-form__body", onBlur: onBlur, children: inputs.map(params => params.hidden ? null : (_jsx("div", { className: cx("nx-group-form__cell", isBooleanInput(params) && "nx-group-form__cell--choice"), style: { "--nx-span": Math.max(1, Math.min(12, params.span ?? 12)) }, "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: "group", idPrefix: context.idPrefix, size: context.size, design: context.design, readOnly: context.readOnly, disabled: context.disabled, lock: context.cellLock(rowKey, params.name, index) }, store.revision(rowKey, params.name)) }, params.name))) }))] }));
|
|
66
|
+
return (_jsxs("li", { className: "nx-group-form__section", "data-nx-row": rowKey, "data-open": open || undefined, "data-problem": problems > 0 || undefined, children: [_jsxs("div", { className: "nx-group-form__head", children: [context.collapsible ? (_jsxs("button", { type: "button", className: "nx-group-form__toggle", "aria-expanded": open, "aria-controls": open ? bodyId : undefined, "data-nx-row-actions": "", onClick: () => setCollapsed(open), children: [_jsx(InlineIcon, { icon: iconChevronDown, className: "nx-group-form__chevron" }), heading] })) : (_jsx("div", { className: "nx-group-form__heading", children: heading })), (context.sortable || context.canAdd || context.removable) && (_jsxs("div", { className: "nx-group-form__actions", children: [context.sortable && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconArrowUp }), "aria-label": `Move ${name} up`, title: "Move up", disabled: index === 0, onClick: () => onAction("move-up", rowKey) }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconArrowDown }), "aria-label": `Move ${name} down`, title: "Move down", disabled: index === count - 1, onClick: () => onAction("move-down", rowKey) })] })), context.canAdd && (_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconCopy }), "aria-label": `Duplicate ${name}`, title: "Duplicate", onClick: () => onAction("duplicate", rowKey) })), context.removable && (_jsx(Button, { variant: "ghost", size: "sm", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Remove ${name}`, title: "Remove", onClick: () => onAction("remove", rowKey) }))] }))] }), open && (_jsx("div", { id: bodyId, role: "group", "aria-labelledby": titleId, className: "nx-group-form__body", onBlur: onBlur, children: inputs.map(params => params.hidden ? null : (_jsx("div", { className: cx("nx-group-form__cell", isBooleanInput(params) && "nx-group-form__cell--choice"), style: { "--nx-span": Math.max(1, Math.min(12, params.span ?? 12)) }, "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: "group", idPrefix: context.idPrefix, size: context.size, design: context.design, readOnly: context.readOnly, disabled: context.disabled, lock: context.cellLock(rowKey, params.name, index), cells: context }, store.revision(rowKey, params.name)) }, params.name))) }))] }));
|
|
67
67
|
});
|
|
68
68
|
/**
|
|
69
69
|
* The rows of a `TabularForm` as sections: each row's inputs with their own labels, on a 12-column grid (`span` in
|
|
@@ -154,6 +154,7 @@ export function GroupForm(props) {
|
|
|
154
154
|
design,
|
|
155
155
|
cellLock: rows.cellLock,
|
|
156
156
|
cellParams: rows.cellParams,
|
|
157
|
+
computeWith: rows.computeWith,
|
|
157
158
|
readOnly,
|
|
158
159
|
disabled,
|
|
159
160
|
removable,
|
|
@@ -165,7 +166,7 @@ export function GroupForm(props) {
|
|
|
165
166
|
preferences,
|
|
166
167
|
sectionTitle,
|
|
167
168
|
summary,
|
|
168
|
-
}), [frame.id, size, design, rows.cellLock, rows.cellParams, readOnly, disabled, removable, sortable, rows.canAdd, collapsible, defaultCollapsed, snapshot.showAll, preferences, sectionTitle, summary]);
|
|
169
|
+
}), [frame.id, size, design, rows.cellLock, rows.cellParams, rows.computeWith, readOnly, disabled, removable, sortable, rows.canAdd, collapsible, defaultCollapsed, snapshot.showAll, preferences, sectionTitle, summary]);
|
|
169
170
|
const invalid = Boolean(error || snapshot.message || snapshot.invalidRows);
|
|
170
171
|
return (_jsxs("fieldset", { id: frame.id, className: cx("nx-rows", "nx-group-form", `nx-group-form--${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 }), count === 0 ? (_jsx("p", { className: "nx-group-form__empty", children: frame.emptyMessage })) : (_jsx("ol", { ref: listRef, className: "nx-group-form__list", children: snapshot.keys.map((key, index) => (_jsx(GroupSection, { store: store, rowKey: key, index: index, count: count, inputs: inputs, context: context, isNew: !loadedKeys.has(key), onAction: onAction }, `${snapshot.generation}:${key}`))) })), _jsx(RowsFooter, { messageId: frame.messageId, store: store, snapshot: snapshot, error: error, hint: hint, addLabel: frame.addLabel, addable: addable, canAdd: rows.canAdd, max: max, maxMessage: frame.maxMessage, size: size, onAdd: () => rows.actions.add(), onUndo: () => rows.actions.undo() })] }));
|
|
171
172
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import { type InputParams } from "../../Interfaces/FormInterfaces.ts";
|
|
2
|
+
import { type InputParams, type RowsInputProps } from "../../Interfaces/FormInterfaces.ts";
|
|
3
3
|
import type { InputDesign } from "../../Interfaces/InputInterfaces.ts";
|
|
4
4
|
import type { Size } from "../../Interfaces/TypeInterfaces.ts";
|
|
5
5
|
/** What a form sets on an input it renders from params. Keys given here replace the params' own, even when `undefined`. */
|
|
@@ -30,6 +30,10 @@ export interface InputOverrides {
|
|
|
30
30
|
fieldName?: string;
|
|
31
31
|
className?: string;
|
|
32
32
|
"aria-label"?: string;
|
|
33
|
+
/** A tabular or group form's values of the form around it that its rows follow (`RowsInputProps.formValues`). */
|
|
34
|
+
formValues?: RowsInputProps["formValues"];
|
|
35
|
+
/** A tabular or group form's `computeWith`: the form's own, for row inputs worked out from it (`RowsInputProps.computeWith`). */
|
|
36
|
+
computeWith?: unknown;
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
35
39
|
* Renders an input from its params: `{ type: EInputTypes.Email, name: "email", label: "Email", required: true }` gives a
|
|
@@ -23,6 +23,12 @@ export interface MarkdownEditorProps extends NativeTextAreaProps, TextRules {
|
|
|
23
23
|
modes?: readonly MarkdownEditorMode[];
|
|
24
24
|
/** Toolbar buttons in order, `"|"` for a divider. Default `MARKDOWN_TOOLBAR`; `[]` hides them. Shortcuts work for the tools listed. */
|
|
25
25
|
toolbar?: readonly MarkdownToolbarItem[];
|
|
26
|
+
/**
|
|
27
|
+
* What the buttons do when the bar is too narrow for them. `wrap` (the default): they take a row of their own and wrap.
|
|
28
|
+
* `menu`: the bar keeps one row and the buttons that do not fit go into a "More formatting" menu at its end, so a
|
|
29
|
+
* small editor (a chat's reply box on a phone) keeps its room for the text. Their shortcuts work either way.
|
|
30
|
+
*/
|
|
31
|
+
toolbarOverflow?: "wrap" | "menu";
|
|
26
32
|
/** Height of the text and preview, in pixels. Default 280. A double-click on the handle goes back to it. */
|
|
27
33
|
height?: number;
|
|
28
34
|
/** Smallest height the handle allows. Default 120. */
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { iconBlockquote, iconBold, iconCode, iconEye, iconHeading, iconItalic, iconLayoutColumns, iconLink, iconList, iconListCheck, iconListNumbers, iconMarkdown, iconPencil, iconPhoto, iconSeparatorHorizontal, iconSourceCode, iconStrikethrough, iconTable, InlineIcon, } from "nexus-icons";
|
|
3
|
+
import { iconBlockquote, iconBold, iconCode, iconDots, iconEye, iconHeading, iconItalic, iconLayoutColumns, iconLink, iconList, iconListCheck, iconListNumbers, iconMarkdown, iconPencil, iconPhoto, iconSeparatorHorizontal, iconSourceCode, iconStrikethrough, iconTable, InlineIcon, } from "nexus-icons";
|
|
4
4
|
import { startTransition, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react";
|
|
5
5
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
6
|
import { applyMarkdownTool, continueMarkdownList, linkPastedUrl, toggleMarkdownTask } from "../../Helpers/MarkdownEditing.js";
|
|
7
|
+
import { fitToolbar } from "../../Helpers/ToolbarFit.js";
|
|
8
|
+
import { Menu } from "../Documents/Menu.js";
|
|
7
9
|
import { TabButtons } from "../Documents/TabButtons.js";
|
|
8
10
|
import { MarkdownView } from "../Viewers/MarkdownView.js";
|
|
9
11
|
import { EditorCount, useEditorShell, writeEditorCount } from "./EditorShell.js";
|
|
@@ -61,7 +63,7 @@ const ALL_MODES = ["write", "split", "preview"];
|
|
|
61
63
|
* @example <MarkdownEditor name="description" label="Description" defaultMode="split" heightKey="room-description" maxLength={5000} />
|
|
62
64
|
*/
|
|
63
65
|
export function MarkdownEditor(props) {
|
|
64
|
-
const { label, hint, error, defaultMode = "write", mode: modeProp, onModeChange, modes = ALL_MODES, toolbar = MARKDOWN_TOOLBAR, height = 280, minHeight = 120, maxHeight = 960, resizable = true, heightKey, fullscreen: canFullscreen = true, breaks = true, monospace = false, showCount = true, emptyPreview = "Nothing to preview", readOnlyMessage = "Read-only: this text cannot be changed.", fieldName, validateOn, onValueChange, className, textAreaClassName, style, required, minLength, maxLength, pattern, patternMessage, validate, disabled, readOnly, ref, onChange, onBlur, onInvalid, onKeyDown, onClick, onPaste, onScroll, ...textAreaProps } = props;
|
|
66
|
+
const { label, hint, error, defaultMode = "write", mode: modeProp, onModeChange, modes = ALL_MODES, toolbar = MARKDOWN_TOOLBAR, toolbarOverflow = "wrap", height = 280, minHeight = 120, maxHeight = 960, resizable = true, heightKey, fullscreen: canFullscreen = true, breaks = true, monospace = false, showCount = true, emptyPreview = "Nothing to preview", readOnlyMessage = "Read-only: this text cannot be changed.", fieldName, validateOn, onValueChange, className, textAreaClassName, style, required, minLength, maxLength, pattern, patternMessage, validate, disabled, readOnly, ref, onChange, onBlur, onInvalid, onKeyDown, onClick, onPaste, onScroll, ...textAreaProps } = props;
|
|
65
67
|
const [modeState, setModeState] = useState(() => (modes.includes(defaultMode) ? defaultMode : (modes[0] ?? "write")));
|
|
66
68
|
const mode = modeProp ?? modeState;
|
|
67
69
|
const previewRef = useRef(null);
|
|
@@ -87,6 +89,30 @@ export function MarkdownEditor(props) {
|
|
|
87
89
|
const tools = new Set(toolbar.filter((item) => item !== "|"));
|
|
88
90
|
const firstTool = toolbar.findIndex(item => item !== "|");
|
|
89
91
|
const editable = !readOnly && !disabled;
|
|
92
|
+
// One row: the buttons that fit the room the bar leaves them, measured with the row's own sizes (a cozy page's are
|
|
93
|
+
// larger), the rest in the "More formatting" menu. The row's width comes from the bar, never from its buttons, so
|
|
94
|
+
// drawing fewer of them does not change what was measured.
|
|
95
|
+
const toolsRef = useRef(null);
|
|
96
|
+
const [fit, setFit] = useState(null);
|
|
97
|
+
const showsTools = editable && mode !== "preview" && firstTool >= 0;
|
|
98
|
+
useLayoutEffect(() => {
|
|
99
|
+
const row = toolsRef.current;
|
|
100
|
+
if (toolbarOverflow !== "menu" || !showsTools || !row) {
|
|
101
|
+
setFit(null);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const measure = () => {
|
|
105
|
+
const style = getComputedStyle(row);
|
|
106
|
+
const tool = parseFloat(style.getPropertyValue("--nx-control-sm")) || 28;
|
|
107
|
+
const next = fitToolbar(toolbar, row.clientWidth, { tool, divider: 11, gap: parseFloat(style.columnGap) || 2 });
|
|
108
|
+
setFit(held => (held && held.shown.length === next.shown.length && held.hidden.length === next.hidden.length ? held : next));
|
|
109
|
+
};
|
|
110
|
+
measure();
|
|
111
|
+
const observer = new ResizeObserver(measure);
|
|
112
|
+
observer.observe(row);
|
|
113
|
+
return () => observer.disconnect();
|
|
114
|
+
}, [toolbarOverflow, showsTools, toolbar]);
|
|
115
|
+
const shownTools = fit ? fit.shown : toolbar;
|
|
90
116
|
const shell = useEditorShell({
|
|
91
117
|
height,
|
|
92
118
|
minHeight,
|
|
@@ -210,11 +236,11 @@ export function MarkdownEditor(props) {
|
|
|
210
236
|
onScroll?.(event);
|
|
211
237
|
follow(event.currentTarget, previewRef.current?.element);
|
|
212
238
|
}
|
|
213
|
-
return (_jsxs(_Fragment, { children: [_jsx(Field, { controlId: field.controlId, messageId: field.messageId, label: label, required: required, hint: hint, error: field.error, disabled: disabled, readOnly: readOnly, multiline: true, className: cx("nx-editor", "nx-md", monospace && "nx-md--mono", className), style: style, children: _jsxs("div", { ref: shell.frameRef, className: "nx-editor__frame", "data-fullscreen": shell.fullscreen || undefined, onKeyDown: shell.onFrameKeyDown, children: [_jsxs("div", { className: "nx-editor__bar", children: [modes.length > 1 && (_jsx(TabButtons, { "aria-label": "View", size: "sm", labels: "selected", options: modes.map(option => ({ value: option, label: MODES[option].label, icon: _jsx(InlineIcon, { icon: MODES[option].icon }), disabled })), value: mode, onChange: changeMode,
|
|
239
|
+
return (_jsxs(_Fragment, { children: [_jsx(Field, { controlId: field.controlId, messageId: field.messageId, label: label, required: required, hint: hint, error: field.error, disabled: disabled, readOnly: readOnly, multiline: true, className: cx("nx-editor", "nx-md", monospace && "nx-md--mono", className), style: style, children: _jsxs("div", { ref: shell.frameRef, className: "nx-editor__frame", "data-fullscreen": shell.fullscreen || undefined, onKeyDown: shell.onFrameKeyDown, children: [_jsxs("div", { className: cx("nx-editor__bar", toolbarOverflow === "menu" && "nx-editor__bar--one-row"), children: [modes.length > 1 && (_jsx(TabButtons, { "aria-label": "View", size: "sm", labels: "selected", options: modes.map(option => ({ value: option, label: MODES[option].label, icon: _jsx(InlineIcon, { icon: MODES[option].icon }), disabled })), value: mode, onChange: changeMode,
|
|
214
240
|
// An id that matches no form: the view is never sent with the form around the editor.
|
|
215
|
-
form: `${field.controlId}-view`, className: "nx-editor__modes" })),
|
|
216
|
-
|
|
217
|
-
|
|
241
|
+
form: `${field.controlId}-view`, className: "nx-editor__modes" })), showsTools && (_jsxs("div", { ref: toolsRef, role: "toolbar", "aria-label": "Formatting", "aria-controls": field.controlId, className: "nx-editor__tools", onKeyDown: shell.moveTool, children: [shownTools.map((item, index) => item === "|" ? (_jsx("span", { className: "nx-editor__divider", "aria-hidden": "true" }, index)) : (_jsx("button", { type: "button", className: "nx-editor__tool", tabIndex: index === firstTool ? 0 : -1, "aria-label": TOOLS[item].label, "aria-keyshortcuts": TOOLS[item].keys, title: TOOLS[item].keys ? `${TOOLS[item].label} (${TOOLS[item].keys.replace("Control", "Ctrl")})` : TOOLS[item].label,
|
|
242
|
+
// The text area keeps focus and its selection.
|
|
243
|
+
onMouseDown: event => event.preventDefault(), onClick: () => runTool(item), children: _jsx(InlineIcon, { icon: TOOLS[item].icon }) }, index))), fit && fit.hidden.length > 0 && (_jsx(Menu, { items: fit.hidden.map(tool => ({ value: tool, label: TOOLS[tool].label, icon: _jsx(InlineIcon, { icon: TOOLS[tool].icon }), shortcut: TOOLS[tool].keys?.replace("Control", "Ctrl") })), align: "end", size: "sm", "aria-label": "More formatting", onSelect: tool => runTool(tool), children: _jsx("button", { type: "button", className: "nx-editor__tool", tabIndex: -1, "aria-label": "More formatting", title: "More formatting", children: _jsx(InlineIcon, { icon: iconDots }) }) }))] })), _jsxs("span", { className: "nx-editor__end", children: [readOnlyNotice.lock, shell.fullscreenButton] })] }), _jsxs("div", { ref: shell.bodyRef, className: "nx-editor__body", "data-mode": mode, style: shell.bodyStyle, children: [_jsx("textarea", { ...textAreaProps, ...field.controlProps, className: cx("nx-field__control", "nx-md__input", textAreaClassName), disabled: disabled, readOnly: readOnly, onClick: handleClick, onBlur: handleBlur, onKeyDown: handleKeyDown, onPaste: handlePaste, onInvalid: handleInvalid, onScroll: handleScroll }), mode !== "write" && (_jsx(MarkdownPreview, { ref: previewRef, initialText: initialText(), breaks: breaks, empty: emptyPreview, onTaskToggle: editable ? toggleTask : undefined, onScroll: event => follow(event.currentTarget, field.elementRef.current), onRender: () => {
|
|
218
244
|
if (field.elementRef.current)
|
|
219
245
|
follow(field.elementRef.current, previewRef.current?.element);
|
|
220
246
|
} }))] }), shell.footer(_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconMarkdown }), _jsx("span", { children: "Markdown" })] }), showCount && _jsx(EditorCount, { ref: countRef, text: initialText(), maxLength: maxLength }))] }) }), readOnlyNotice.notice] }));
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { iconPencil, iconPhoto, iconTrash, iconUpload, InlineIcon } from "nexus-icons";
|
|
4
4
|
import { useId, useRef, useState } from "react";
|
|
5
5
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
|
+
import { allowsFileChange } from "../../Helpers/FileAdapter.js";
|
|
6
7
|
import { getFileAdapter } from "../../Services/FileApi.js";
|
|
7
8
|
import { preloadImageEditor } from "../../Services/ImageEditorLoader.js";
|
|
8
9
|
import { Button } from "../Documents/Button.js";
|
|
@@ -28,7 +29,7 @@ export function PictureInput({ label, hint, size = "md", disabled = false, readO
|
|
|
28
29
|
const fieldId = id ?? generated;
|
|
29
30
|
const [failed, setFailed] = useState(null);
|
|
30
31
|
const files = useOwnerFiles({ ...params, readOnly: readOnly || disabled });
|
|
31
|
-
const { status, data, busy, canWrite, readOnlyReason, editorProblem, actions } = files;
|
|
32
|
+
const { status, data, busy, canWrite, readOnlyReason, changes, changesNote, offer, editorProblem, actions } = files;
|
|
32
33
|
// A keyboard's focus stays in the input through a change: on the button pressed, or the first left when it went.
|
|
33
34
|
const rootRef = useRef(null);
|
|
34
35
|
useFocusThroughChange(rootRef, busy);
|
|
@@ -41,6 +42,10 @@ export function PictureInput({ label, hint, size = "md", disabled = false, readO
|
|
|
41
42
|
// A category that keeps one file: a new picture takes its place, and Remove deletes it (there is nothing else to show).
|
|
42
43
|
const single = data?.category.maxCount === 1;
|
|
43
44
|
const kept = data?.files ?? [];
|
|
45
|
+
// What this input offers, within the changes allowed: its Upload makes the new picture the main one, so it needs both.
|
|
46
|
+
const offered = offer(primary);
|
|
47
|
+
const canUpload = offered.upload && (single || allowsFileChange(changes, "primary"));
|
|
48
|
+
const canRemove = Boolean(primary) && (single ? offered.remove : offered.clearPrimary);
|
|
44
49
|
const note = unsaved
|
|
45
50
|
? `Save the ${subject} first, then add its picture.`
|
|
46
51
|
: status.kind === "failed"
|
|
@@ -51,7 +56,7 @@ export function PictureInput({ label, hint, size = "md", disabled = false, readO
|
|
|
51
56
|
? // Why Upload or Edit did not open the editor, until a try opens it: never a button that does nothing.
|
|
52
57
|
editorProblem
|
|
53
58
|
: // Why it cannot be changed, when the page or the backend says (`lockReason`, a refused key): never a silent input.
|
|
54
|
-
(readOnlyReason ?? hint ?? (data && canWrite ? `${data.files.length} of ${data.category.maxCount} ${single ? "picture" : "pictures"} kept.` : undefined));
|
|
59
|
+
(readOnlyReason ?? changesNote ?? hint ?? (data && canWrite ? `${data.files.length} of ${data.category.maxCount} ${single ? "picture" : "pictures"} kept.` : undefined));
|
|
55
60
|
const problem = status.kind === "failed" || (!unsaved && !busy && Boolean(editorProblem));
|
|
56
61
|
const named = typeof label === "string" && label ? label : "Picture";
|
|
57
62
|
/** The pictures window, as All pictures opens it: what changes there is read again here. */
|
|
@@ -66,5 +71,5 @@ export function PictureInput({ label, hint, size = "md", disabled = false, readO
|
|
|
66
71
|
});
|
|
67
72
|
const pictureClass = cx("nx-picture", params.shape === "circle" && "nx-picture--round", !shown && "nx-picture--empty", status.kind === "loading" && "nx-picture--loading");
|
|
68
73
|
const face = shown ? _jsx("img", { src: url, alt: "", decoding: "async", onError: () => setFailed(url) }) : _jsx(InlineIcon, { icon: iconPhoto });
|
|
69
|
-
return (_jsxs("fieldset", { ref: rootRef, id: fieldId, className: cx("nx-choices", size !== "md" && `nx-choices--${size}`, "nx-picture-input", className), style: style, disabled: disabled, "aria-label": ariaLabel, "aria-busy": status.kind === "loading" || Boolean(busy) || undefined, "aria-describedby": note ? `${fieldId}-message` : undefined, "data-readonly": readOnly || undefined, children: [_jsx(ChoiceLegend, { label: label }), _jsxs("div", { className: "nx-picture-input__body", children: [!unsaved && data ? (_jsx("button", { type: "button", className: pictureClass, style: preview, "aria-label": primary ? `${named}: open the pictures` : `${named}: none yet, open the pictures`, "aria-haspopup": "dialog", title: "All pictures", ...busyProps(busy, openAll), onPointerEnter: preloadImageEditor, children: face })) : (_jsx("span", { className: pictureClass, style: preview, role: "img", "aria-label": primary ? `${named}: ${primary.name}` : "No picture", children: face })), !unsaved && data && (_jsxs("div", { className: "nx-picture-input__actions", role: "group", children: [
|
|
74
|
+
return (_jsxs("fieldset", { ref: rootRef, id: fieldId, className: cx("nx-choices", size !== "md" && `nx-choices--${size}`, "nx-picture-input", className), style: style, disabled: disabled, "aria-label": ariaLabel, "aria-busy": status.kind === "loading" || Boolean(busy) || undefined, "aria-describedby": note ? `${fieldId}-message` : undefined, "data-readonly": readOnly || undefined, children: [_jsx(ChoiceLegend, { label: label }), _jsxs("div", { className: "nx-picture-input__body", children: [!unsaved && data ? (_jsx("button", { type: "button", className: pictureClass, style: preview, "aria-label": primary ? `${named}: open the pictures` : `${named}: none yet, open the pictures`, "aria-haspopup": "dialog", title: "All pictures", ...busyProps(busy, openAll), onPointerEnter: preloadImageEditor, children: face })) : (_jsx("span", { className: pictureClass, style: preview, role: "img", "aria-label": primary ? `${named}: ${primary.name}` : "No picture", children: face })), !unsaved && data && (_jsxs("div", { className: "nx-picture-input__actions", role: "group", children: [canUpload && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconUpload }), ...busyProps(busy, () => void actions.replace()), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor, children: single && kept.length > 0 ? "Replace" : "Upload" })), primary && offered.edit && (_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), ...busyProps(busy, () => void actions.edit(primary)), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor, children: "Edit" })), canRemove && primary && (_jsx(Button, { size: "sm", variant: "ghost", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), ...busyProps(busy, () => void (single ? actions.remove(primary) : actions.makePrimary(null))), children: single ? "Delete" : "Remove" })), (data.category.maxCount > 1 || kept.length > 1 || (kept.length > 0 && !primary)) && (_jsxs(Button, { size: "sm", variant: "ghost", ...busyProps(busy, openAll), children: ["All pictures (", data.files.length, ")"] }))] }))] }), _jsx(GroupFooter, { id: `${fieldId}-message`, hint: problem ? undefined : note, error: problem ? note : undefined })] }));
|
|
70
75
|
}
|
|
@@ -3,6 +3,7 @@ import { RowsStore, type RowsSnapshot } from "../../Helpers/RowsStore.ts";
|
|
|
3
3
|
import { type InputParams, type RowsInputProps, type RowValue } from "../../Interfaces/FormInterfaces.ts";
|
|
4
4
|
import type { InputDesign } from "../../Interfaces/InputInterfaces.ts";
|
|
5
5
|
import type { Size } from "../../Interfaces/TypeInterfaces.ts";
|
|
6
|
+
import { type ComputedNote } from "./ComputedInput.tsx";
|
|
6
7
|
/** Form names of inputs inside rows that need one (radio and checkbox groups). They are taken out of the form's data. */
|
|
7
8
|
export declare const ROW_INPUT_NAME = "nx-rows:";
|
|
8
9
|
/** Where focus goes in a row once it is on the page: an input by name, the row's first input, or its actions button. */
|
|
@@ -37,6 +38,7 @@ export declare function useRowsInput<TRow extends RowValue>(props: RowsInputProp
|
|
|
37
38
|
cellLock: (key: string, name: string, index: number) => string | boolean | null | undefined;
|
|
38
39
|
rowLock: (key: string | null, index: number) => string | boolean | null | undefined;
|
|
39
40
|
cellParams: (key: string, params: InputParams, index: number) => InputParams;
|
|
41
|
+
computeWith: () => unknown;
|
|
40
42
|
};
|
|
41
43
|
/** What a layout asks for each cell: the reason it is generated, `true` for no reason, or nothing. */
|
|
42
44
|
export type CellLock = (key: string, name: string, index: number) => string | boolean | null | undefined;
|
|
@@ -44,6 +46,15 @@ export type CellLock = (key: string, name: string, index: number) => string | bo
|
|
|
44
46
|
export type CellParams = (key: string, params: InputParams, index: number) => InputParams;
|
|
45
47
|
/** What a layout asks for each row: the reason nobody may change it, `true` for no reason, or nothing. From `rowReadOnly`. */
|
|
46
48
|
export type RowLock = (key: string | null, index: number) => string | boolean | null | undefined;
|
|
49
|
+
/** What a row input worked out from others (`compute`) asks its layout: the other cells' params and locks, `computeWith`. */
|
|
50
|
+
export interface RowCellContext {
|
|
51
|
+
cellParams: CellParams;
|
|
52
|
+
cellLock: CellLock;
|
|
53
|
+
/** The rows' `computeWith` as it is now. */
|
|
54
|
+
computeWith?: () => unknown;
|
|
55
|
+
/** A typed-in figure's note, for a table to say under its rows (`null` once it no longer applies). */
|
|
56
|
+
onNote?: (key: string, name: string, note: ComputedNote | null) => void;
|
|
57
|
+
}
|
|
47
58
|
/** The first control of an element that takes focus. */
|
|
48
59
|
export declare function firstFocusable(element: Element | null): HTMLElement | null;
|
|
49
60
|
/** Subscribes a row's component to its row: it renders again only when the row's messages or code-set values change. */
|
|
@@ -66,13 +77,15 @@ export interface RowCellProps {
|
|
|
66
77
|
disabled: boolean;
|
|
67
78
|
/** This one cell holds a generated value: the reason, `true` for no reason, or nothing. From `cellReadOnly`. */
|
|
68
79
|
lock?: string | boolean | null;
|
|
80
|
+
/** The layout's own, for a cell worked out from the row's others (`compute`). */
|
|
81
|
+
cells?: RowCellContext;
|
|
69
82
|
}
|
|
70
83
|
/**
|
|
71
84
|
* One input of one row. It starts from the store's value when it mounts and writes every change back, so typing renders
|
|
72
85
|
* nothing; a value set from code mounts it again (its key holds the value's revision). In a table the column header is
|
|
73
86
|
* the label, so the input is named "City, row 2" for screen readers and its messages still say "City".
|
|
74
87
|
*/
|
|
75
|
-
export declare const RowCell: import("react").MemoExoticComponent<(
|
|
88
|
+
export declare const RowCell: import("react").MemoExoticComponent<(props: RowCellProps) => string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined>;
|
|
76
89
|
/** The legend of the rows, with the required mark and, when read-only, a lock. */
|
|
77
90
|
export declare function RowsLegend({ id, label, required, lock }: {
|
|
78
91
|
id: string;
|