nexus-shared 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +122 -25
  3. package/dist/Client.Index.d.ts +11 -0
  4. package/dist/Client.Index.js +11 -0
  5. package/dist/Components/Chats/Chat.d.ts +28 -0
  6. package/dist/Components/Chats/Chat.js +19 -0
  7. package/dist/Components/Chats/ChatButton.d.ts +26 -0
  8. package/dist/Components/Chats/ChatButton.js +41 -0
  9. package/dist/Components/Chats/ChatComposer.d.ts +41 -0
  10. package/dist/Components/Chats/ChatComposer.js +177 -0
  11. package/dist/Components/Chats/ChatConversations.d.ts +35 -0
  12. package/dist/Components/Chats/ChatConversations.js +38 -0
  13. package/dist/Components/Chats/ChatPanel.d.ts +66 -0
  14. package/dist/Components/Chats/ChatPanel.js +94 -0
  15. package/dist/Components/Chats/ChatParts.d.ts +87 -0
  16. package/dist/Components/Chats/ChatParts.js +100 -0
  17. package/dist/Components/Chats/ChatThread.d.ts +22 -0
  18. package/dist/Components/Chats/ChatThread.js +96 -0
  19. package/dist/Components/Documents/Menu.js +24 -20
  20. package/dist/Components/Documents/SplitButton.js +5 -3
  21. package/dist/Components/Documents/TabButtons.d.ts +24 -6
  22. package/dist/Components/Documents/TabButtons.js +23 -4
  23. package/dist/Components/Forms/ApiForm.d.ts +6 -4
  24. package/dist/Components/Forms/ApiForm.js +15 -14
  25. package/dist/Components/Forms/Crud.js +202 -50
  26. package/dist/Components/Forms/ExcelImport.d.ts +42 -0
  27. package/dist/Components/Forms/ExcelImport.js +190 -0
  28. package/dist/Components/Forms/Form.js +5 -1
  29. package/dist/Components/Inputs/DateTimePicker.js +2 -1
  30. package/dist/Components/Inputs/GroupForm.js +4 -2
  31. package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
  32. package/dist/Components/Inputs/InputRenderer.js +1 -1
  33. package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
  34. package/dist/Components/Inputs/RowsInput.d.ts +12 -1
  35. package/dist/Components/Inputs/RowsInput.js +53 -4
  36. package/dist/Components/Inputs/TabularForm.d.ts +7 -3
  37. package/dist/Components/Inputs/TabularForm.js +85 -20
  38. package/dist/Components/Inputs/TimePicker.js +2 -1
  39. package/dist/Components/Layouts/ThemeSwitcher.d.ts +2 -2
  40. package/dist/Components/Layouts/ThemeSwitcher.js +35 -15
  41. package/dist/Components/Viewers/DataTable.js +88 -34
  42. package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
  43. package/dist/Components/Viewers/DataTableColumns.js +243 -0
  44. package/dist/Components/Viewers/DataTableParts.d.ts +6 -2
  45. package/dist/Components/Viewers/DataTableParts.js +21 -9
  46. package/dist/Helpers/ApiClient.d.ts +22 -0
  47. package/dist/Helpers/ApiClient.js +4 -1
  48. package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
  49. package/dist/Helpers/ApiFormHelpers.js +73 -9
  50. package/dist/Helpers/ApiModules.d.ts +0 -4
  51. package/dist/Helpers/ApiModules.js +1 -0
  52. package/dist/Helpers/ApiResponses.d.ts +14 -4
  53. package/dist/Helpers/ApiResponses.js +116 -25
  54. package/dist/Helpers/ApiRoutes.d.ts +0 -10
  55. package/dist/Helpers/ApiRoutes.js +2 -10
  56. package/dist/Helpers/ChatBackend.d.ts +134 -0
  57. package/dist/Helpers/ChatBackend.js +332 -0
  58. package/dist/Helpers/ChatHelpers.d.ts +189 -0
  59. package/dist/Helpers/ChatHelpers.js +486 -0
  60. package/dist/Helpers/ChatHooks.d.ts +81 -0
  61. package/dist/Helpers/ChatHooks.js +175 -0
  62. package/dist/Helpers/ChatStore.d.ts +132 -0
  63. package/dist/Helpers/ChatStore.js +394 -0
  64. package/dist/Helpers/ChatSummaries.d.ts +39 -0
  65. package/dist/Helpers/ChatSummaries.js +118 -0
  66. package/dist/Helpers/CrudBackend.d.ts +47 -5
  67. package/dist/Helpers/CrudBackend.js +99 -4
  68. package/dist/Helpers/CrudHelpers.d.ts +39 -4
  69. package/dist/Helpers/CrudHelpers.js +76 -11
  70. package/dist/Helpers/ExcelBackend.d.ts +106 -0
  71. package/dist/Helpers/ExcelBackend.js +290 -0
  72. package/dist/Helpers/ExcelHelpers.d.ts +98 -0
  73. package/dist/Helpers/ExcelHelpers.js +302 -0
  74. package/dist/Helpers/FormStore.d.ts +2 -0
  75. package/dist/Helpers/FormStore.js +19 -1
  76. package/dist/Helpers/MessageBuilder.js +0 -2
  77. package/dist/Helpers/PopoverHelpers.d.ts +31 -6
  78. package/dist/Helpers/PopoverHelpers.js +116 -12
  79. package/dist/Helpers/RowsStore.d.ts +7 -1
  80. package/dist/Helpers/RowsStore.js +22 -0
  81. package/dist/Helpers/TableColumns.d.ts +7 -0
  82. package/dist/Helpers/TableColumns.js +41 -13
  83. package/dist/Helpers/TableHelpers.d.ts +9 -1
  84. package/dist/Helpers/TableHelpers.js +20 -0
  85. package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
  86. package/dist/Interfaces/ApiInterfaces.js +16 -10
  87. package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
  88. package/dist/Interfaces/ChatInterfaces.js +1 -0
  89. package/dist/Interfaces/CrudInterfaces.d.ts +73 -12
  90. package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
  91. package/dist/Interfaces/ExcelInterfaces.js +45 -0
  92. package/dist/Interfaces/FormInterfaces.d.ts +52 -2
  93. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  94. package/dist/Interfaces/TableInterfaces.d.ts +62 -2
  95. package/dist/Services/BrowserApi.d.ts +18 -1
  96. package/dist/Services/BrowserApi.js +18 -0
  97. package/dist/Services/ChatApi.d.ts +37 -0
  98. package/dist/Services/ChatApi.js +98 -0
  99. package/dist/Services/ExcelApi.d.ts +46 -0
  100. package/dist/Services/ExcelApi.js +196 -0
  101. package/dist/Services/ServerApi.d.ts +6 -1
  102. package/dist/Services/ServerApi.js +3 -0
  103. package/dist/Shared.Index.d.ts +8 -0
  104. package/dist/Shared.Index.js +8 -0
  105. package/package.json +6 -3
  106. package/src/Styles/Nexus.Button.css +2 -0
  107. package/src/Styles/Nexus.Chat.css +1489 -0
  108. package/src/Styles/Nexus.Crud.css +77 -0
  109. package/src/Styles/Nexus.Excel.css +370 -0
  110. package/src/Styles/Nexus.Form.css +23 -0
  111. package/src/Styles/Nexus.Index.css +2 -0
  112. package/src/Styles/Nexus.Menu.css +12 -2
  113. package/src/Styles/Nexus.Rows.css +78 -48
  114. package/src/Styles/Nexus.Tab.Buttons.css +55 -0
  115. package/src/Styles/Nexus.Table.css +286 -4
  116. package/src/Styles/Nexus.Theme.Picker.css +75 -2
@@ -0,0 +1,190 @@
1
+ "use client";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { iconAlertTriangle, iconChevronDown, iconCircleCheck, iconCopy, iconDownload, iconFileImport, iconTable, iconUpload, InlineIcon } from "nexus-icons";
4
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { EXCEL_FILE_ACCEPT, excelColumnInputs, excelCount, excelEditorRows, excelPlural, excelProblemPlace, excelProblems, excelRowsOfKind, excelSheetRows, excelText, excelWritableRows, isExcelRowEdited, } from "../../Helpers/ExcelHelpers.js";
7
+ import { ExcelRowKind } from "../../Interfaces/ExcelInterfaces.js";
8
+ import { downloadExcelTemplate, excelSettings, importExcelRows, verifyExcelFile } from "../../Services/ExcelApi.js";
9
+ import { toast } from "../Layouts/Toaster.js";
10
+ import { openPopup } from "../Layouts/PopupHost.js";
11
+ import { Button } from "../Documents/Button.js";
12
+ import { TabularForm } from "../Inputs/TabularForm.js";
13
+ const DEFAULT_MAX_HEIGHT = 320;
14
+ /**
15
+ * The three steps of an import in one panel: the template, the file, and the review.
16
+ *
17
+ * @example
18
+ * <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={() => table.reload()} />
19
+ */
20
+ export function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight = DEFAULT_MAX_HEIGHT, className }) {
21
+ const id = useId();
22
+ const fileRef = useRef(null);
23
+ const newRef = useRef(null);
24
+ const sameRef = useRef(null);
25
+ const [settings, setSettings] = useState(null);
26
+ const [file, setFile] = useState(null);
27
+ const [verify, setVerify] = useState(null);
28
+ const [imported, setImported] = useState(null);
29
+ const [failure, setFailure] = useState(null);
30
+ const [busy, setBusy] = useState("settings");
31
+ /** Bumped on every answer: the editors take their rows again instead of being told to change them. */
32
+ const [generation, setGeneration] = useState(0);
33
+ // A page may well write its endpoints and its params as literals, which are a new object every render. What they
34
+ // say is what matters, so the settings are asked for again only when that changes.
35
+ const sheetKey = `${String(endpoints.settings.module)}|${endpoints.settings.path}|${JSON.stringify(params ?? null)}`;
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ const callOptions = useMemo(() => ({ params }), [sheetKey]);
38
+ useEffect(() => {
39
+ let left = false;
40
+ void (async () => {
41
+ const answer = await excelSettings(endpoints, callOptions);
42
+ if (left)
43
+ return;
44
+ if (answer.ok && answer.result)
45
+ setSettings(answer.result);
46
+ setBusy(null);
47
+ })();
48
+ return () => {
49
+ left = true;
50
+ };
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, [sheetKey]);
53
+ const subject = given ?? verify?.subject ?? settings?.subject ?? "row";
54
+ const columns = verify?.columns ?? settings?.columns ?? [];
55
+ const inputs = useMemo(() => excelColumnInputs(columns), [columns]);
56
+ const fresh = useMemo(() => (verify ? excelWritableRows(verify.rows) : []), [verify]);
57
+ const same = useMemo(() => (verify ? excelRowsOfKind(verify.rows, ExcelRowKind.Duplicate) : []), [verify]);
58
+ const freshRows = useMemo(() => excelEditorRows(fresh, columns), [fresh, columns]);
59
+ const sameRows = useMemo(() => excelEditorRows(same, columns), [same, columns]);
60
+ function reset() {
61
+ setFile(null);
62
+ setVerify(null);
63
+ setImported(null);
64
+ setFailure(null);
65
+ setGeneration(value => value + 1);
66
+ if (fileRef.current)
67
+ fileRef.current.value = "";
68
+ }
69
+ async function onTemplate() {
70
+ setBusy("template");
71
+ await downloadExcelTemplate(endpoints, callOptions);
72
+ setBusy(null);
73
+ }
74
+ async function onPicked(picked) {
75
+ if (!picked)
76
+ return;
77
+ setFile(picked);
78
+ setVerify(null);
79
+ setImported(null);
80
+ setFailure(null);
81
+ setBusy("checking");
82
+ const answer = await verifyExcelFile(endpoints, picked, callOptions);
83
+ setBusy(null);
84
+ // Clearing the input is what lets the same file be picked again after a fix in the spreadsheet.
85
+ if (fileRef.current)
86
+ fileRef.current.value = "";
87
+ if (!answer.ok || !answer.result) {
88
+ setFailure(answer.message ?? excelText("verifyFailed"));
89
+ return;
90
+ }
91
+ setVerify(answer.result);
92
+ setGeneration(value => value + 1);
93
+ }
94
+ async function onImport() {
95
+ if (!verify)
96
+ return;
97
+ const newValues = newRef.current?.getValue() ?? freshRows;
98
+ const sameValues = sameRef.current?.getValue() ?? sameRows;
99
+ // A duplicate is never updated, so one the person left alone is not sent at all; one they edited is a new record.
100
+ const edited = sameValues.filter(row => isExcelRowEdited(row, columns));
101
+ if (newValues.length && newRef.current && !newRef.current.validate())
102
+ return;
103
+ if (edited.length && sameRef.current && !sameRef.current.validate())
104
+ return;
105
+ const rows = excelSheetRows([...newValues, ...edited], columns);
106
+ if (!rows.length) {
107
+ setFailure(excelText("importNothing"));
108
+ return;
109
+ }
110
+ setBusy("importing");
111
+ const answer = await importExcelRows(endpoints, rows, callOptions);
112
+ setBusy(null);
113
+ if (!answer.ok) {
114
+ setFailure(answer.message ?? excelText("importedNone"));
115
+ if (answer.result)
116
+ setImported(answer.result);
117
+ return;
118
+ }
119
+ const written = answer.result;
120
+ if (!written)
121
+ return;
122
+ setFailure(null);
123
+ setImported(written);
124
+ toast.success(`${excelCount(written.created, subject)} imported`, { message: written.note });
125
+ // What was written is gone from the file's point of view: only what was refused stays to be fixed and sent again,
126
+ // and the counts are what those rows are now - not what the verify said about a file that has been half written.
127
+ const left = written.refused ?? [];
128
+ setVerify(current => current
129
+ ? {
130
+ ...current,
131
+ rows: left,
132
+ read: left.length,
133
+ new: left.filter(row => row.kind === ExcelRowKind.New).length,
134
+ duplicates: left.filter(row => row.kind === ExcelRowKind.Duplicate).length,
135
+ invalid: left.filter(row => row.kind === ExcelRowKind.Invalid).length,
136
+ isReady: false,
137
+ }
138
+ : current);
139
+ setGeneration(value => value + 1);
140
+ onImported?.(written);
141
+ }
142
+ /** A problem names a cell: clicking it brings that row into view and puts the caret in it. */
143
+ const goTo = (editor, list) => (problem) => {
144
+ const index = list.findIndex(row => row.number === problem.row);
145
+ if (index >= 0)
146
+ editor.current?.focusRow(index, problem.field);
147
+ };
148
+ const checking = busy === "checking";
149
+ const importing = busy === "importing";
150
+ const canImport = Boolean(verify) && !busy && (fresh.length > 0 || same.length > 0);
151
+ return (_jsxs("section", { className: cx("nx-excel", className), "aria-label": `Import ${excelPlural(subject)} from a spreadsheet`, "data-nx-excel": "", children: [_jsxs("ol", { className: "nx-excel__steps", children: [_jsxs("li", { children: ["Download the template", templateDownload === false ? "" : " below", ": every list in it is a dropdown, and the ids beside a choice fill themselves."] }), _jsxs("li", { children: ["Fill in one row per ", subject, ". Leave the locked columns alone."] }), _jsx("li", { children: "Send it here to be checked. Nothing is written until you import." })] }), _jsxs("div", { className: "nx-excel__pick", children: [templateDownload !== false && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconDownload }), loading: busy === "template", onClick: onTemplate, children: excelText("template") })), _jsx("input", { ref: fileRef, id: `${id}-file`, className: "nx-visually-hidden", type: "file", accept: EXCEL_FILE_ACCEPT, onChange: event => void onPicked(event.currentTarget.files?.[0]) }), _jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconFileImport }), loading: checking, onClick: () => fileRef.current?.click(), children: file ? excelText("change") : excelText("choose") }), _jsx("p", { className: "nx-excel__file", "data-nx-excel-file": "", children: checking ? (_jsxs(_Fragment, { children: [excelText("checking"), " ", _jsx("strong", { children: file?.name }), String.fromCharCode(8230)] })) : file ? (_jsx("strong", { children: file.name })) : (_jsx("span", { className: "nx-excel__muted", children: excelText("nothingPicked") })) }), settings && (_jsxs("p", { className: "nx-excel__limits", children: [excelCount(settings.maxImportRows, "row"), " at most per file"] }))] }), failure && (_jsxs("p", { className: "nx-excel__failure", role: "alert", "data-nx-excel-failure": "", children: [_jsx(InlineIcon, { icon: iconAlertTriangle, "aria-hidden": "true" }), _jsx("span", { children: failure })] })), imported && _jsx(ImportedReport, { answer: imported, subject: subject }), verify && (_jsxs("div", { className: "nx-excel__review", "data-nx-excel-review": "", children: [!imported && _jsx("p", { className: "nx-excel__note", children: verify.note }), _jsx(ReviewSection, { id: `${id}-new`, title: excelText("newRows"), tone: verify.invalid > 0 ? "warning" : "neutral", count: fresh.length, hint: verify.invalid > 0 ? `${excelCount(verify.invalid, "row")} to fix before anything is written.` : undefined, rows: fresh, onGo: goTo(newRef, fresh), open: true, children: _jsx(TabularForm, { ref: newRef, "aria-label": excelText("newRows"), inputs: inputs, itemLabel: subject, defaultValue: freshRows, addable: false, sortable: false, maxHeight: maxHeight, size: "sm" }, `new-${generation}`) }), _jsx(ReviewSection, { id: `${id}-same`, title: excelText("duplicates"), tone: "muted", count: same.length, hint: excelText("duplicateHint"), rows: same, onGo: goTo(sameRef, same), children: _jsx(TabularForm, { ref: sameRef, "aria-label": excelText("duplicates"), inputs: inputs, itemLabel: subject, defaultValue: sameRows, addable: false, removable: false, sortable: false, maxHeight: maxHeight, size: "sm" }, `same-${generation}`) })] })), _jsxs("footer", { className: "nx-excel__actions", children: [verify && (fresh.length > 0 || same.length > 0) && (_jsxs("p", { className: "nx-excel__sending", "data-nx-excel-sending": "", children: [fresh.length > 0 ? `${excelCount(fresh.length, subject)} to send.` : "Nothing to send yet.", same.length > 0 ? ` Edit ${same.length === 1 ? `the one ${subject}` : `one of the ${excelCount(same.length, subject)}`} already there to send it too.` : ""] })), _jsxs("div", { className: "nx-excel__buttons", children: [file && !busy && (_jsx(Button, { variant: "ghost", onClick: reset, children: excelText("startOver") })), onClose && (_jsx(Button, { variant: "secondary", onClick: onClose, children: excelText("close") })), _jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconUpload }), loading: importing, disabled: !canImport, onClick: () => void onImport(), children: excelText("importLabel") })] })] })] }));
152
+ }
153
+ /**
154
+ * One half of the review: a line that says what these rows are and how many, and the editor under it. Collapsible,
155
+ * because a person with twenty duplicates and three new rows is looking at the three.
156
+ */
157
+ function ReviewSection({ id, title, count, tone, hint, rows, onGo, open: openByDefault = false, children }) {
158
+ const [open, setOpen] = useState(openByDefault);
159
+ const problems = useMemo(() => excelProblems(rows), [rows]);
160
+ const duplicates = useMemo(() => rows.filter(row => row.duplicate), [rows]);
161
+ if (count === 0)
162
+ return null;
163
+ return (_jsxs("section", { className: "nx-excel__section", "data-open": open || undefined, "data-tone": tone, "data-nx-excel-section": title, children: [_jsx("h3", { className: "nx-excel__section-head", children: _jsxs("button", { type: "button", className: "nx-excel__toggle", "aria-expanded": open, "aria-controls": open ? id : undefined, onClick: () => setOpen(!open), children: [_jsx(InlineIcon, { icon: iconChevronDown, className: "nx-excel__chevron", "aria-hidden": "true" }), _jsx("span", { className: "nx-excel__title", children: title }), _jsx("span", { className: "nx-excel__tag", children: count.toLocaleString("en-US") }), hint && _jsx("span", { className: "nx-excel__hint", children: hint })] }) }), open && (_jsxs("div", { className: "nx-excel__section-body", id: id, children: [children, problems.length > 0 && _jsx(ProblemList, { problems: problems, onGo: onGo }), duplicates.length > 0 && (_jsxs("div", { className: "nx-excel__problems nx-excel__problems--same", "data-nx-excel-duplicates": "", children: [_jsxs("p", { className: "nx-excel__problems-head", children: [_jsx(InlineIcon, { icon: iconCopy, "aria-hidden": "true" }), _jsx("span", { children: excelText("whyDuplicate") })] }), _jsx("ul", { children: duplicates.map(row => (_jsxs("li", { children: [_jsxs("span", { className: "nx-excel__place", children: ["Row ", row.number] }), _jsx("span", { children: row.duplicate })] }, row.number))) })] }))] }))] }));
164
+ }
165
+ /** What the server found wrong, each naming the cell the person reads, and taking them to it. */
166
+ function ProblemList({ problems, onGo }) {
167
+ return (_jsxs("div", { className: "nx-excel__problems", "data-nx-excel-problems": "", children: [_jsxs("p", { className: "nx-excel__problems-head", children: [_jsx(InlineIcon, { icon: iconAlertTriangle, "aria-hidden": "true" }), _jsx("span", { children: excelText("toFix") })] }), _jsx("ul", { children: problems.map(problem => (_jsxs("li", { children: [onGo ? (_jsx("button", { type: "button", className: "nx-excel__place nx-excel__go", onClick: () => onGo(problem), children: excelProblemPlace(problem) })) : (_jsx("span", { className: "nx-excel__place", children: excelProblemPlace(problem) })), _jsx("span", { children: problem.message })] }, `${problem.row}:${problem.column}:${problem.message}`))) })] }));
168
+ }
169
+ /** What the import did, in the backend's own words, and what it did not write. */
170
+ function ImportedReport({ answer, subject }) {
171
+ const wrote = answer.created > 0;
172
+ return (_jsxs("div", { className: cx("nx-excel__result", wrote ? "nx-excel__result--ok" : "nx-excel__result--none"), role: "status", "data-nx-excel-result": "", children: [_jsxs("p", { className: "nx-excel__result-head", children: [_jsx(InlineIcon, { icon: wrote ? iconCircleCheck : iconAlertTriangle, "aria-hidden": "true" }), _jsx("strong", { children: wrote ? `${excelCount(answer.created, subject)} added` : excelText("importedNone") })] }), _jsx("p", { className: "nx-excel__result-note", children: answer.note }), (answer.refused?.length ?? 0) > 0 && (_jsx("ul", { className: "nx-excel__refused", children: answer.refused?.map(row => (_jsxs("li", { children: [_jsxs("span", { className: "nx-excel__place", children: ["Row ", row.number] }), _jsx("span", { children: row.duplicate ?? row.problems?.[0]?.message ?? excelText("refused") })] }, row.number))) }))] }));
173
+ }
174
+ /**
175
+ * Opens the import panel as a window. This is the one line a data table's Import entry needs.
176
+ *
177
+ * @example onImport={() => openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload })}
178
+ */
179
+ export function openExcelImport({ title, popupKey, ...props }) {
180
+ const subject = props.subject ?? "row";
181
+ return openPopup({
182
+ key: popupKey ?? `nx-excel:${String(props.endpoints.import.module)}:${props.endpoints.import.path}`,
183
+ title: title ?? `Import ${excelPlural(subject)} from a spreadsheet`,
184
+ icon: _jsx(InlineIcon, { icon: iconTable }),
185
+ size: "xl",
186
+ closeOnBackdrop: false,
187
+ // No Close of its own: the window's title bar already has one, and two ways out of one panel is one too many.
188
+ content: _jsx(ExcelImport, { ...props }),
189
+ });
190
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Fragment } from "react";
3
4
  import { iconAlertCircle, iconHistory, iconX, InlineIcon } from "nexus-icons";
4
5
  import { memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
5
6
  import { cx } from "../../Helpers/ClassHelpers.js";
@@ -124,6 +125,9 @@ const FormCell = memo(function FormCell({ store, params, context }) {
124
125
  useSyncExternalStore(subscribe, version, version);
125
126
  const stamp = store.stamp(name);
126
127
  const span = spanOf(params);
128
+ // An input that follows others is asked whether it shows each time it draws: it comes and goes with their values.
129
+ if (params.dependsOn?.length && params.hidden)
130
+ return null;
127
131
  // Leaving the input shows the form's own rule message on it, such as "Passwords do not match".
128
132
  function onBlur(event) {
129
133
  if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))
@@ -315,5 +319,5 @@ export function Form(props) {
315
319
  const context = useMemo(() => ({ idPrefix, size, design, readOnly, disabled, scopeNames: !inForm }), [idPrefix, size, design, readOnly, disabled, inForm]);
316
320
  // With FormButtons on the page, the form's message is in their status line.
317
321
  const ownMessage = status.buttons === 0 ? status.message : null;
318
- return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-form", `nx-form--${size}`, className), style: style, "data-nx-form": "", "aria-busy": loading || undefined, children: [status.restored && !loading && _jsx(DraftNotice, { store: anyStore, restored: status.restored, inputs: inputs, preferences: preferences }), _jsx("div", { className: "nx-form__grid", children: inputs.map(params => params.hidden ? null : loading ? (_jsx(SkeletonCell, { params: params, design: design }, params.name)) : (_jsx(FormCell, { store: anyStore, params: params, context: context }, params.name))) }), loading && (_jsx("p", { className: "nx-visually-hidden", role: "status", children: "Loading\u2026" })), _jsx("input", { ref: proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", disabled: locked, onInvalid: onProxyInvalid }), ownMessage && (_jsxs("p", { className: "nx-form__message nx-field__message nx-field__message--error", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: ownMessage })] }))] }));
322
+ return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-form", `nx-form--${size}`, className), style: style, "data-nx-form": "", "aria-busy": loading || undefined, children: [status.restored && !loading && _jsx(DraftNotice, { store: anyStore, restored: status.restored, inputs: inputs, preferences: preferences }), _jsx("div", { className: "nx-form__grid", children: inputs.filter(params => params.dependsOn?.length || !params.hidden).map((params, index, visible) => (_jsxs(Fragment, { children: [params.section && (index === 0 || visible[index - 1]?.section !== params.section) && _jsx("h3", { className: "nx-form__section", children: params.section }), loading ? _jsx(SkeletonCell, { params: params, design: design }) : _jsx(FormCell, { store: anyStore, params: params, context: context })] }, params.name))) }), loading && (_jsx("p", { className: "nx-visually-hidden", role: "status", children: "Loading\u2026" })), _jsx("input", { ref: proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", disabled: locked, onInvalid: onProxyInvalid }), ownMessage && (_jsxs("p", { className: "nx-form__message nx-field__message nx-field__message--error", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: ownMessage })] }))] }));
319
323
  }
@@ -188,7 +188,8 @@ export function DateTimePicker(props) {
188
188
  const cap = natural > room ? `${Math.max(0, room)}px` : "";
189
189
  if (panel.style.maxHeight !== cap)
190
190
  panel.style.maxHeight = cap;
191
- placeUnderAnchor(panel, anchor, { gap: PANEL_GAP, margin: PANEL_MARGIN });
191
+ // No arrow: the panel opens flush under the box and is read as the field's own calendar.
192
+ placeUnderAnchor(panel, anchor, { gap: PANEL_GAP, margin: PANEL_MARGIN, arrow: false });
192
193
  };
193
194
  place();
194
195
  if (focusPanelRef.current) {
@@ -64,7 +64,7 @@ const GroupSection = memo(function GroupSection({ store, rowKey, index, count, i
64
64
  store.touch(rowKey, cell.dataset.nxCell ?? "");
65
65
  }
66
66
  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"] }))] }));
67
- 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", CHOICE_TYPES.has(params.type) && "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: params, 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 }, store.revision(rowKey, params.name)) }, params.name))) }))] }));
67
+ 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", CHOICE_TYPES.has(params.type) && "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))) }))] }));
68
68
  });
69
69
  /**
70
70
  * The rows of a `TabularForm` as sections: each row's inputs with their own labels, on a 12-column grid (`span` in
@@ -153,6 +153,8 @@ export function GroupForm(props) {
153
153
  idPrefix: frame.id,
154
154
  size,
155
155
  design,
156
+ cellLock: rows.cellLock,
157
+ cellParams: rows.cellParams,
156
158
  readOnly,
157
159
  disabled,
158
160
  removable,
@@ -164,7 +166,7 @@ export function GroupForm(props) {
164
166
  preferences,
165
167
  sectionTitle,
166
168
  summary,
167
- }), [frame.id, size, design, readOnly, disabled, removable, sortable, rows.canAdd, collapsible, defaultCollapsed, snapshot.showAll, preferences, sectionTitle, summary]);
169
+ }), [frame.id, size, design, rows.cellLock, rows.cellParams, readOnly, disabled, removable, sortable, rows.canAdd, collapsible, defaultCollapsed, snapshot.showAll, preferences, sectionTitle, summary]);
168
170
  const invalid = Boolean(error || snapshot.message || snapshot.invalidRows);
169
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() })] }));
170
172
  }
@@ -21,6 +21,8 @@ export interface InputOverrides {
21
21
  design?: InputDesign;
22
22
  /** On top of the params: `true` makes the input read-only even when its params do not. */
23
23
  readOnly?: boolean;
24
+ /** Replaces the params' own read-only note, for a cell locked by `cellReadOnly` rather than by its params. */
25
+ readOnlyMessage?: string;
24
26
  /** On top of the params, like `readOnly`. */
25
27
  disabled?: boolean;
26
28
  fieldName?: string;
@@ -25,7 +25,7 @@ import { TreeView } from "./TreeView.js";
25
25
  export function renderInput(params, overrides = {}) {
26
26
  if (params.hidden)
27
27
  return null;
28
- const { type, span: _span, width: _width, grow: _grow, hidden: _hidden, onValueChange: ownChange, ...rest } = params;
28
+ const { type, section: _section, span: _span, width: _width, grow: _grow, hidden: _hidden, dependsOn: _dependsOn, onValueChange: ownChange, ...rest } = params;
29
29
  const { defaultValue, onValueChange, readOnly, disabled, ...set } = overrides;
30
30
  const props = { ...rest, ...set };
31
31
  if (readOnly)
@@ -88,7 +88,8 @@ function ReadOnlyNote({ ref, message }) {
88
88
  return;
89
89
  if (!isPopoverOpen(note))
90
90
  note.showPopover?.();
91
- const place = () => placeUnderAnchor(note, shown.anchor.getBoundingClientRect(), { align: shown.align });
91
+ // No arrow: the note is not a panel, and it answers the whole box rather than one button in it.
92
+ const place = () => placeUnderAnchor(note, shown.anchor.getBoundingClientRect(), { align: shown.align, arrow: false });
92
93
  place();
93
94
  return followAnchor(place);
94
95
  }, [shown]);
@@ -34,7 +34,16 @@ export declare function useRowsInput<TRow extends RowValue>(props: RowsInputProp
34
34
  move(key: string, delta: number): void;
35
35
  undo(): void;
36
36
  };
37
+ cellLock: (key: string, name: string, index: number) => string | boolean | null | undefined;
38
+ rowLock: (key: string | null, index: number) => string | boolean | null | undefined;
39
+ cellParams: (key: string, params: InputParams, index: number) => InputParams;
37
40
  };
41
+ /** What a layout asks for each cell: the reason it is generated, `true` for no reason, or nothing. */
42
+ export type CellLock = (key: string, name: string, index: number) => string | boolean | null | undefined;
43
+ /** What a layout asks for each cell: its own params, which are the column's unless `cellParams` answers for its row. */
44
+ export type CellParams = (key: string, params: InputParams, index: number) => InputParams;
45
+ /** What a layout asks for each row: the reason nobody may change it, `true` for no reason, or nothing. From `rowReadOnly`. */
46
+ export type RowLock = (key: string | null, index: number) => string | boolean | null | undefined;
38
47
  /** The first control of an element that takes focus. */
39
48
  export declare function firstFocusable(element: Element | null): HTMLElement | null;
40
49
  /** Subscribes a row's component to its row: it renders again only when the row's messages or code-set values change. */
@@ -55,13 +64,15 @@ export interface RowCellProps {
55
64
  design?: InputDesign;
56
65
  readOnly: boolean;
57
66
  disabled: boolean;
67
+ /** This one cell holds a generated value: the reason, `true` for no reason, or nothing. From `cellReadOnly`. */
68
+ lock?: string | boolean | null;
58
69
  }
59
70
  /**
60
71
  * One input of one row. It starts from the store's value when it mounts and writes every change back, so typing renders
61
72
  * nothing; a value set from code mounts it again (its key holds the value's revision). In a table the column header is
62
73
  * the label, so the input is named "City, row 2" for screen readers and its messages still say "City".
63
74
  */
64
- export declare const RowCell: import("react").MemoExoticComponent<({ store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled }: RowCellProps) => ReactNode>;
75
+ export declare const RowCell: import("react").MemoExoticComponent<({ store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled, lock }: RowCellProps) => ReactNode>;
65
76
  /** The legend of the rows, with the required mark and, when read-only, a lock. */
66
77
  export declare function RowsLegend({ id, label, required, lock }: {
67
78
  id: string;
@@ -26,9 +26,9 @@ function sameRows(a, b) {
26
26
  * listener, a parent form's data and reset, and the handle given to `ref`.
27
27
  */
28
28
  export function useRowsInput(props, fallbackItem, layoutRef) {
29
- const { inputs, required, min, max, unique, validate, rowKey, newRow, onRowChange, itemLabel, itemLabelPlural, value, defaultValue, name, readOnly = false, disabled = false, ref } = props;
29
+ const { inputs, required, min, max, unique, validate, rowKey, newRow, onRowChange, deriveRows, itemLabel, itemLabelPlural, value, defaultValue, name, readOnly = false, disabled = false, ref } = props;
30
30
  const preferences = useDatePreferences();
31
- const options = { inputs, required, min, max, unique, validate, rowKey, newRow, onRowChange, itemLabel, itemLabelPlural, fallbackItem, context: { preferences } };
31
+ const options = { inputs, required, min, max, unique, validate, rowKey, newRow, onRowChange, deriveRows, itemLabel, itemLabelPlural, fallbackItem, context: { preferences } };
32
32
  const [store] = useState(() => new RowsStore(options, value ?? defaultValue ?? []));
33
33
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
34
34
  const proxyRef = useRef(null);
@@ -160,7 +160,49 @@ export function useRowsInput(props, fallbackItem, layoutRef) {
160
160
  }),
161
161
  // eslint-disable-next-line react-hooks/exhaustive-deps
162
162
  [store]);
163
- return { store, snapshot, proxyRef, onInvalid, canAdd, actions };
163
+ // Stable, so a sheet of thousands of rows does not render again because the parent handed a new function.
164
+ const rowLock = useCallback((key, index) => {
165
+ const ask = latest.current.props.rowReadOnly;
166
+ if (!ask || !key)
167
+ return undefined;
168
+ const row = store.row(key);
169
+ return row ? ask(row, index) : undefined;
170
+ }, [store]);
171
+ const cellLock = useCallback((key, name, index) => {
172
+ // A row nobody may change locks every one of its cells, with the row's reason.
173
+ const whole = rowLock(key, index);
174
+ if (whole)
175
+ return whole;
176
+ const ask = latest.current.props.cellReadOnly;
177
+ if (!ask)
178
+ return undefined;
179
+ const row = store.row(key);
180
+ return row ? ask(row, name, index) : undefined;
181
+ }, [store, rowLock]);
182
+ // A cell's own params, merged once per answer: the same answer gives the same object, so the cell does not draw again.
183
+ const merged = useRef(new WeakMap());
184
+ const cellParams = useCallback((key, params, index) => {
185
+ const ask = latest.current.props.cellParams;
186
+ if (!ask)
187
+ return params;
188
+ const row = store.row(key);
189
+ const own = row ? ask(row, params.name, index) : null;
190
+ if (!own)
191
+ return params;
192
+ let byColumn = merged.current.get(own);
193
+ if (!byColumn)
194
+ merged.current.set(own, (byColumn = new WeakMap()));
195
+ let result = byColumn.get(params);
196
+ if (!result) {
197
+ // Descriptors rather than a spread, so a getter on either side is still read as the cell draws, as it would be
198
+ // on the column's own params; the column keeps its type and its name whatever the answer holds.
199
+ const { type: _type, name: _name, ...rest } = Object.getOwnPropertyDescriptors(own);
200
+ result = Object.defineProperties({}, { ...Object.getOwnPropertyDescriptors(params), ...rest });
201
+ byColumn.set(params, result);
202
+ }
203
+ return result;
204
+ }, [store]);
205
+ return { store, snapshot, proxyRef, onInvalid, canAdd, actions, cellLock, rowLock, cellParams };
164
206
  }
165
207
  function addButtonOf(proxy) {
166
208
  return proxy?.closest(".nx-rows")?.querySelector(":scope > .nx-rows__footer .nx-rows__add:not(:disabled)") ?? null;
@@ -191,7 +233,7 @@ export function labelText(params) {
191
233
  * nothing; a value set from code mounts it again (its key holds the value's revision). In a table the column header is
192
234
  * the label, so the input is named "City, row 2" for screen readers and its messages still say "City".
193
235
  */
194
- export const RowCell = memo(function RowCell({ store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled }) {
236
+ export const RowCell = memo(function RowCell({ store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled, lock }) {
195
237
  const [initial] = useState(() => store.get(rowKey, params.name));
196
238
  const onValueChange = useCallback((next) => store.set(rowKey, params.name, next, "input"), [store, rowKey, params.name]);
197
239
  const needsName = params.type === "radio-group" || params.type === "checkbox-group";
@@ -205,6 +247,13 @@ export const RowCell = memo(function RowCell({ store, rowKey, params, index, err
205
247
  readOnly,
206
248
  disabled,
207
249
  };
250
+ // A generated cell: read-only however its params have it, and saying why, since a box that ignores typing without a
251
+ // word reads as broken. Code still writes it — that is what makes it generated rather than merely locked.
252
+ if (lock) {
253
+ overrides.readOnly = true;
254
+ if (typeof lock === "string")
255
+ overrides.readOnlyMessage = lock;
256
+ }
208
257
  if (error !== undefined)
209
258
  overrides.error = error;
210
259
  // A required radio means one row has it on, which the rows check; each radio alone is not required.
@@ -5,9 +5,13 @@ import type { RowValue, TabularFormProps } from "../../Interfaces/FormInterfaces
5
5
  * come as params, the same objects a form takes, so the same list can show as sections in a `GroupForm`.
6
6
  *
7
7
  * Only the rows in view are on the page, so a sheet of thousands of rows opens and types as fast as a short one; the
8
- * values live in a store outside React, and typing renders nothing. The header, the row numbers, and the remove buttons
9
- * stay in place while the table scrolls. A row number opens the row's menu (insert, duplicate, move, remove). Enter in
10
- * a text or number cell moves down the column, and on the last row adds a row.
8
+ * values live in a store outside React, and typing renders nothing. The header and the numbered column stay in place
9
+ * while the table scrolls. Each row has one control, beside its number and never in its place: a menu of insert,
10
+ * duplicate, move and remove. Enter in a text or number cell moves down the column, and on the last row adds a row.
11
+ *
12
+ * Every row is one line high, whatever it holds, so nothing shifts as the table is used; a column generated for every
13
+ * row (`cellReadOnly`) says so once on its header rather than on each of its cells; and `maxHeight` is brought down
14
+ * to whole rows, because a scroller that cuts the last one in half reads as broken rather than as scrollable.
11
15
  *
12
16
  * Inside a `<form>`, the rows are sent as JSON under `name`; a row with a problem blocks submit, and the first one
13
17
  * scrolls into view with focus.