nexus-shared 3.2.0 → 4.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.
- package/CHANGELOG.md +1564 -0
- package/README.md +1705 -1123
- package/dist/Client.Index.d.ts +14 -0
- package/dist/Client.Index.js +14 -0
- package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
- package/dist/Components/Documents/DocumentFrame.js +83 -0
- package/dist/Components/Documents/Menu.d.ts +4 -0
- package/dist/Components/Documents/Menu.js +40 -24
- package/dist/Components/Documents/PrintDocument.d.ts +95 -0
- package/dist/Components/Documents/PrintDocument.js +62 -0
- package/dist/Components/Documents/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Forms/ApiForm.js +19 -2
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +576 -243
- package/dist/Components/Forms/CrudActions.d.ts +136 -0
- package/dist/Components/Forms/CrudActions.js +184 -0
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +30 -2
- package/dist/Components/Forms/CrudParts.js +55 -5
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +114 -0
- package/dist/Components/Forms/CrudScopes.d.ts +28 -0
- package/dist/Components/Forms/CrudScopes.js +76 -0
- package/dist/Components/Forms/CrudTree.d.ts +90 -0
- package/dist/Components/Forms/CrudTree.js +363 -0
- package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
- package/dist/Components/Forms/CrudWorkspace.js +169 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +4 -4
- package/dist/Components/Forms/Form.js +123 -17
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -0
- package/dist/Components/Forms/RecordFlows.d.ts +50 -0
- package/dist/Components/Forms/RecordFlows.js +282 -0
- package/dist/Components/Forms/SubmitForm.d.ts +37 -2
- package/dist/Components/Forms/SubmitForm.js +195 -10
- package/dist/Components/Inputs/CheckBox.js +1 -1
- package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
- package/dist/Components/Inputs/ChoiceField.js +28 -2
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +5 -5
- package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
- package/dist/Components/Inputs/DropdownParts.js +16 -4
- package/dist/Components/Inputs/Field.d.ts +7 -1
- package/dist/Components/Inputs/Field.js +2 -2
- package/dist/Components/Inputs/GroupForm.d.ts +6 -6
- package/dist/Components/Inputs/GroupForm.js +10 -11
- package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
- package/dist/Components/Inputs/InputRenderer.js +37 -32
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +70 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
- package/dist/Components/Inputs/RadioGroup.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +1 -1
- package/dist/Components/Inputs/RowsInput.js +18 -3
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +29 -29
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +11 -2
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TreeView.d.ts +53 -2
- package/dist/Components/Inputs/TreeView.js +147 -13
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +13 -6
- package/dist/Components/Layouts/Popup.js +37 -9
- package/dist/Components/Layouts/PopupDock.d.ts +3 -1
- package/dist/Components/Layouts/PopupDock.js +2 -1
- package/dist/Components/Layouts/PopupHost.js +5 -4
- package/dist/Components/Viewers/DataTable.js +150 -48
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +21 -5
- package/dist/Components/Viewers/DataTableParts.js +26 -14
- package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
- package/dist/Components/Viewers/OwnerFiles.js +589 -0
- package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
- package/dist/Components/Viewers/QrCodeImage.js +15 -0
- package/dist/Components/Viewers/RecordCards.d.ts +16 -0
- package/dist/Components/Viewers/RecordCards.js +509 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/CrudBackend.d.ts +20 -2
- package/dist/Helpers/CrudBackend.js +46 -2
- package/dist/Helpers/CrudColumns.d.ts +53 -6
- package/dist/Helpers/CrudColumns.js +98 -7
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +81 -2
- package/dist/Helpers/CrudHelpers.js +195 -0
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudRowForms.d.ts +15 -0
- package/dist/Helpers/CrudRowForms.js +43 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -0
- package/dist/Helpers/DropdownHelpers.d.ts +3 -1
- package/dist/Helpers/DropdownHelpers.js +6 -2
- package/dist/Helpers/ExcelHelpers.d.ts +1 -1
- package/dist/Helpers/ExcelHelpers.js +12 -11
- package/dist/Helpers/FileAdapter.d.ts +78 -0
- package/dist/Helpers/FileAdapter.js +102 -0
- package/dist/Helpers/FilePrimaries.d.ts +48 -0
- package/dist/Helpers/FilePrimaries.js +138 -0
- package/dist/Helpers/FormConfirm.d.ts +32 -0
- package/dist/Helpers/FormConfirm.js +40 -0
- package/dist/Helpers/FormStore.d.ts +90 -5
- package/dist/Helpers/FormStore.js +318 -22
- package/dist/Helpers/InputConditions.d.ts +50 -0
- package/dist/Helpers/InputConditions.js +168 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +385 -0
- package/dist/Helpers/InputNarrowing.d.ts +18 -0
- package/dist/Helpers/InputNarrowing.js +67 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +157 -74
- package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
- package/dist/Helpers/NexusDriveAdapter.js +207 -0
- package/dist/Helpers/NumberHelpers.d.ts +95 -0
- package/dist/Helpers/NumberHelpers.js +126 -0
- package/dist/Helpers/Permissions.js +2 -1
- package/dist/Helpers/QrCode.d.ts +26 -0
- package/dist/Helpers/QrCode.js +463 -0
- package/dist/Helpers/RecordCards.d.ts +205 -0
- package/dist/Helpers/RecordCards.js +378 -0
- package/dist/Helpers/RequestCache.d.ts +14 -2
- package/dist/Helpers/RequestCache.js +22 -3
- package/dist/Helpers/RowsStore.d.ts +37 -2
- package/dist/Helpers/RowsStore.js +128 -10
- package/dist/Helpers/ServerPreview.d.ts +69 -0
- package/dist/Helpers/ServerPreview.js +99 -0
- package/dist/Helpers/TableColumns.js +4 -3
- package/dist/Helpers/TableExport.d.ts +7 -1
- package/dist/Helpers/TableExport.js +9 -3
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +23 -2
- package/dist/Helpers/TableHelpers.js +51 -10
- package/dist/Helpers/TableJoins.d.ts +13 -0
- package/dist/Helpers/TableJoins.js +34 -0
- package/dist/Helpers/TableStore.d.ts +21 -1
- package/dist/Helpers/TableStore.js +64 -7
- package/dist/Helpers/TreeStore.d.ts +81 -0
- package/dist/Helpers/TreeStore.js +365 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +465 -5
- package/dist/Interfaces/FileInterfaces.d.ts +162 -0
- package/dist/Interfaces/FileInterfaces.js +4 -0
- package/dist/Interfaces/FormInterfaces.d.ts +391 -30
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +15 -3
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +74 -1
- package/dist/Services/ExcelApi.js +5 -0
- package/dist/Services/FileApi.d.ts +13 -0
- package/dist/Services/FileApi.js +29 -0
- package/dist/Services/ImageEditorLoader.d.ts +14 -0
- package/dist/Services/ImageEditorLoader.js +51 -0
- package/dist/Services/JoinSources.d.ts +37 -5
- package/dist/Services/JoinSources.js +90 -14
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Shared.Index.d.ts +16 -0
- package/dist/Shared.Index.js +16 -0
- package/package.json +7 -2
- package/src/Styles/Nexus.Avatar.css +6 -5
- package/src/Styles/Nexus.Button.css +10 -4
- package/src/Styles/Nexus.Choice.css +16 -0
- package/src/Styles/Nexus.Crud.css +383 -9
- package/src/Styles/Nexus.Dropdown.css +8 -0
- package/src/Styles/Nexus.Files.css +410 -0
- package/src/Styles/Nexus.Form.css +5 -2
- package/src/Styles/Nexus.Index.css +4 -0
- package/src/Styles/Nexus.Input.css +42 -0
- package/src/Styles/Nexus.Menu.css +10 -1
- package/src/Styles/Nexus.Popup.css +77 -1
- package/src/Styles/Nexus.Print.css +288 -0
- package/src/Styles/Nexus.QrCode.css +12 -0
- package/src/Styles/Nexus.Record.css +430 -0
- package/src/Styles/Nexus.Tab.Buttons.css +87 -22
- package/src/Styles/Nexus.Table.css +67 -5
- package/src/Styles/Nexus.Themes.css +12 -6
- package/src/Styles/Nexus.Tokens.css +8 -3
- package/src/Styles/Nexus.Tree.css +31 -1
|
@@ -5,7 +5,7 @@ import { isValidElement, useEffect, useId, useImperativeHandle, useLayoutEffect,
|
|
|
5
5
|
import { afterAnimation } from "../../Helpers/AnimationHelpers.js";
|
|
6
6
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
7
|
import { isPromiseLike, REQUEST_CACHE_LIFETIME } from "../../Helpers/RequestCache.js";
|
|
8
|
-
import { expireTreePages, TREE_DEFAULT_KEYS, treePageKey, treePages, TreeStore, treeStatusKey, } from "../../Helpers/TreeStore.js";
|
|
8
|
+
import { expireTreePages, subscribeTreeChanges, TREE_DEFAULT_KEYS, treeKey, treePageKey, treePages, TreeStore, treeStatusKey, } from "../../Helpers/TreeStore.js";
|
|
9
9
|
import { Button } from "../Documents/Button.js";
|
|
10
10
|
import { ContextMenu } from "../Documents/Menu.js";
|
|
11
11
|
import { messageBox } from "../Layouts/MessageBox.js";
|
|
@@ -56,7 +56,7 @@ const sameItems = (a, b) => a === b || (a.length === b.length && a.every((item,
|
|
|
56
56
|
* loadChildren={({ parent, skip, take, signal }) => loadRegions(parent, skip, take, signal)} />
|
|
57
57
|
*/
|
|
58
58
|
export function TreeView(props) {
|
|
59
|
-
const { items: givenItems, loadChildren, childPageSize = CHILD_PAGE_SIZE, hasChildrenKey, cacheKey, cacheLifetime = REQUEST_CACHE_LIFETIME, partialMessage = "Its items are not all loaded: open it and load them all to check it as a whole.", valueKey = TREE_DEFAULT_KEYS.value, parentKey = TREE_DEFAULT_KEYS.parent, labelKey = TREE_DEFAULT_KEYS.label, descriptionKey = "description", iconKey = "icon", isItemDisabled, lockedMessage = "You do not have permission to change this item.", label, hint, error, size = "md", checkable = false, cascade = true, valueMode = "leaves", value, defaultValue, onValueChange, name, form, counts = true, defaultExpanded, creatable = false, editable = false, deletable = false, canChange, onCreate, onUpdate, onDelete, onItemsChange, confirmDelete = true, maxLabelLength = 100, onItemClick, loading = false, emptyMessage = "No items", toolbar = true, height, maxHeight = 420, readOnly = false, readOnlyMessage = "Read-only: this tree cannot be changed.", disabled = false, id, className, style, "aria-label": ariaLabel, ref, } = props;
|
|
59
|
+
const { items: givenItems, loadChildren, childPageSize = CHILD_PAGE_SIZE, hasChildrenKey, cacheKey, cacheLifetime = REQUEST_CACHE_LIFETIME, partialMessage = "Its items are not all loaded: open it and load them all to check it as a whole.", valueKey = TREE_DEFAULT_KEYS.value, parentKey = TREE_DEFAULT_KEYS.parent, labelKey = TREE_DEFAULT_KEYS.label, descriptionKey = "description", iconKey = "icon", isItemDisabled, lockedMessage = "You do not have permission to change this item.", label, hint, error, size = "md", checkable = false, cascade = true, valueMode = "leaves", value, defaultValue, onValueChange, name, form, counts = true, defaultExpanded, creatable = false, editable = false, deletable = false, canChange, onCreate, onUpdate, onDelete, onItemsChange, confirmDelete = true, rowActions, add, selectedValue, maxLabelLength = 100, onItemClick, loading = false, emptyMessage = "No items", toolbar = true, height, maxHeight = 420, fill = false, readOnly = false, readOnlyMessage = "Read-only: this tree cannot be changed.", disabled = false, id, className, style, "aria-label": ariaLabel, ref, } = props;
|
|
60
60
|
const generatedId = useId();
|
|
61
61
|
const treeId = id ?? generatedId;
|
|
62
62
|
const labelId = `${treeId}-label`;
|
|
@@ -142,6 +142,8 @@ export function TreeView(props) {
|
|
|
142
142
|
const pendingScrollRef = useRef(null);
|
|
143
143
|
const pendingFocusRef = useRef(null);
|
|
144
144
|
const typeaheadRef = useRef({ text: "", at: 0 });
|
|
145
|
+
// An item the handle was asked to reveal before it was loaded: revealed when the page that brings it comes.
|
|
146
|
+
const pendingRevealRef = useRef(null);
|
|
145
147
|
const blocked = readOnly || disabled;
|
|
146
148
|
const noticeAnchor = (target) => {
|
|
147
149
|
const row = target.closest(".nx-tree__row");
|
|
@@ -150,6 +152,10 @@ export function TreeView(props) {
|
|
|
150
152
|
const readOnlyNotice = useReadOnlyNotice(readOnly && !disabled, viewportRef, readOnlyMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
|
|
151
153
|
const lockedNotice = useReadOnlyNotice(!blocked, viewportRef, lockedMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
|
|
152
154
|
const partialNotice = useReadOnlyNotice(!blocked && lazy && checkable && cascade, viewportRef, partialMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
|
|
155
|
+
// Why the toolbar's own button cannot be pressed yet, under it, on a press.
|
|
156
|
+
const addNotice = useReadOnlyNotice(Boolean(add?.disabled && add.description), viewportRef, add?.description, { anchor: target => target.closest(".nx-btn"), align: "end", typing: false, lock: "label" });
|
|
157
|
+
// The row shown as selected, as a node key.
|
|
158
|
+
const selectedKey = checkable ? null : treeKey(selectedValue);
|
|
153
159
|
// The row a note points at: when it leaves the page (scrolled out of the window), the note goes too.
|
|
154
160
|
const noteRowRef = useRef(null);
|
|
155
161
|
function showNote(notice, target) {
|
|
@@ -319,6 +325,13 @@ export function TreeView(props) {
|
|
|
319
325
|
});
|
|
320
326
|
};
|
|
321
327
|
}, []);
|
|
328
|
+
// Changes to the source written elsewhere (`writeTreeChanges`) are written into this tree too.
|
|
329
|
+
const applyRef = useRef(() => undefined);
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (!lazy || !cacheKey)
|
|
332
|
+
return;
|
|
333
|
+
return subscribeTreeChanges(cacheKey, (changes, rules) => applyRef.current(changes, rules));
|
|
334
|
+
}, [lazy, cacheKey]);
|
|
322
335
|
// Open parents with rows in view that need pages: the top level at first, a parent a refresh made stale, one whose
|
|
323
336
|
// request a refresh forgot. A stale parent out of view waits until it scrolls in, and a closed one until it opens; a
|
|
324
337
|
// failed one waits for Retry, so a server that is down is not asked again and again.
|
|
@@ -345,6 +358,88 @@ export function TreeView(props) {
|
|
|
345
358
|
function onPanelFocus() {
|
|
346
359
|
hadFocusRef.current = true;
|
|
347
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Changes made elsewhere, written in (the handle's `apply`). The focused row keeps focus; when it leaves the page or
|
|
363
|
+
* moves in it, focus goes to the viewport first, while the DOM is whole, and comes back to it - or, when the item
|
|
364
|
+
* left, to the row now in its place - in the layout effect, as when the window moves (see `releaseFocusLeaving`).
|
|
365
|
+
*/
|
|
366
|
+
function applyChanges(changes, rules = {}) {
|
|
367
|
+
if (changes.length === 0)
|
|
368
|
+
return;
|
|
369
|
+
const viewport = viewportRef.current;
|
|
370
|
+
const active = document.activeElement;
|
|
371
|
+
const held = viewport && active instanceof HTMLElement && active !== viewport && viewport.contains(active) ? nodeOfTarget(active) : undefined;
|
|
372
|
+
const heldIndex = held ? store.indexOf(held) : -1;
|
|
373
|
+
const focused = focusKeyRef.current === null ? undefined : store.find(focusKeyRef.current);
|
|
374
|
+
const focusedIndex = focused ? store.indexOf(focused) : -1;
|
|
375
|
+
const valueBefore = valueSignature();
|
|
376
|
+
for (const change of changes) {
|
|
377
|
+
if (change.type === "put")
|
|
378
|
+
store.placeItem(change.item, change.parent, { before: change.before, compare: rules.compare });
|
|
379
|
+
else
|
|
380
|
+
store.removeItem(change.value, change.parent);
|
|
381
|
+
}
|
|
382
|
+
setMotion(null);
|
|
383
|
+
const stillThere = (node, index) => {
|
|
384
|
+
const found = store.find(node.key);
|
|
385
|
+
if (found && store.isVisible(found))
|
|
386
|
+
return found;
|
|
387
|
+
return store.rows.length > 0 ? store.rows[Math.max(0, Math.min(index, store.rows.length - 1))] : undefined;
|
|
388
|
+
};
|
|
389
|
+
if (held && viewport) {
|
|
390
|
+
const next = stillThere(held, heldIndex);
|
|
391
|
+
if (next !== held || store.indexOf(held) !== heldIndex) {
|
|
392
|
+
restoringRef.current = true;
|
|
393
|
+
viewport.focus({ preventScroll: true });
|
|
394
|
+
restoringRef.current = false;
|
|
395
|
+
if (next)
|
|
396
|
+
focusNode(next);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else if (focused && !(store.find(focused.key) === focused && store.isVisible(focused))) {
|
|
400
|
+
setFocusKey(stillThere(focused, focusedIndex)?.key ?? null);
|
|
401
|
+
}
|
|
402
|
+
emitValueIfChanged(valueBefore);
|
|
403
|
+
bump();
|
|
404
|
+
}
|
|
405
|
+
applyRef.current = applyChanges;
|
|
406
|
+
/** An item revealed before it was loaded, revealed now if the answer just in brought it. */
|
|
407
|
+
function revealPending() {
|
|
408
|
+
const pending = pendingRevealRef.current;
|
|
409
|
+
const node = pending ? store.get(pending.key) : undefined;
|
|
410
|
+
if (!pending || !node)
|
|
411
|
+
return;
|
|
412
|
+
pendingRevealRef.current = null;
|
|
413
|
+
if (pending.focus) {
|
|
414
|
+
focusNode(node);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (store.reveal(node))
|
|
418
|
+
setMotion(null);
|
|
419
|
+
pendingScrollRef.current = store.indexOf(node);
|
|
420
|
+
}
|
|
421
|
+
/** The page's own actions on a row, where they may be offered. */
|
|
422
|
+
function rowActionsOf(node) {
|
|
423
|
+
if (!rowActions || blocked || node.status || node.draft || node.disabledPath)
|
|
424
|
+
return [];
|
|
425
|
+
return rowActions(node.item);
|
|
426
|
+
}
|
|
427
|
+
/** Runs one of them. The row is busy, its actions waiting, while a promise the action returns runs. */
|
|
428
|
+
function runRowAction(node, action) {
|
|
429
|
+
if (blocked || action.disabled || busyKeys.has(node.key))
|
|
430
|
+
return;
|
|
431
|
+
pendingRevealRef.current = null;
|
|
432
|
+
const result = action.onSelect(node.item);
|
|
433
|
+
if (!isPromiseLike(result))
|
|
434
|
+
return;
|
|
435
|
+
const key = node.key;
|
|
436
|
+
setBusy(key, true);
|
|
437
|
+
const done = () => {
|
|
438
|
+
if (mountedRef.current)
|
|
439
|
+
setBusy(key, false);
|
|
440
|
+
};
|
|
441
|
+
result.then(done, done);
|
|
442
|
+
}
|
|
348
443
|
function onPanelBlur(event) {
|
|
349
444
|
const next = event.relatedTarget;
|
|
350
445
|
if (next instanceof Node && panelRef.current?.contains(next))
|
|
@@ -354,6 +449,7 @@ export function TreeView(props) {
|
|
|
354
449
|
readOnlyNotice.hide();
|
|
355
450
|
lockedNotice.hide();
|
|
356
451
|
partialNotice.hide();
|
|
452
|
+
addNotice.hide();
|
|
357
453
|
return;
|
|
358
454
|
}
|
|
359
455
|
// Focus went nowhere: a click on the page, or a focused row that left the DOM. The first is settled after this frame.
|
|
@@ -526,6 +622,7 @@ export function TreeView(props) {
|
|
|
526
622
|
if (result.change)
|
|
527
623
|
setMotion(openMotion(result.change));
|
|
528
624
|
followStatusFocus(current, result.added);
|
|
625
|
+
revealPending();
|
|
529
626
|
const now = store.pagesOf(current);
|
|
530
627
|
const count = result.added.length;
|
|
531
628
|
const of = now?.total !== undefined ? ` (${now.loaded.toLocaleString()} of ${now.total.toLocaleString()})` : "";
|
|
@@ -575,6 +672,7 @@ export function TreeView(props) {
|
|
|
575
672
|
return;
|
|
576
673
|
setMotion(null);
|
|
577
674
|
followStatusFocus(current, []);
|
|
675
|
+
revealPending();
|
|
578
676
|
setAnnouncement(`Items loaded again${where}.`);
|
|
579
677
|
bump();
|
|
580
678
|
};
|
|
@@ -881,6 +979,8 @@ export function TreeView(props) {
|
|
|
881
979
|
}
|
|
882
980
|
// Pointer and keys
|
|
883
981
|
function onViewportPointerDown(event) {
|
|
982
|
+
// The user moves on: an item waiting to be revealed is not brought into view under them later.
|
|
983
|
+
pendingRevealRef.current = null;
|
|
884
984
|
// The context menu opens on the row that was pressed, or at the top level on empty space or a status row.
|
|
885
985
|
const node = nodeOfTarget(event.target);
|
|
886
986
|
setContextKey(node && !node.status ? node.key : null);
|
|
@@ -922,6 +1022,7 @@ export function TreeView(props) {
|
|
|
922
1022
|
if (event.defaultPrevented || event.target.closest(".nx-tree__edit"))
|
|
923
1023
|
return;
|
|
924
1024
|
setContextKey(undefined);
|
|
1025
|
+
pendingRevealRef.current = null;
|
|
925
1026
|
if (event.key === "Escape" && noteRowRef.current) {
|
|
926
1027
|
noteRowRef.current = null;
|
|
927
1028
|
readOnlyNotice.hide();
|
|
@@ -990,12 +1091,18 @@ export function TreeView(props) {
|
|
|
990
1091
|
setMotion(null);
|
|
991
1092
|
bump();
|
|
992
1093
|
}
|
|
993
|
-
else if (key === "F2")
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1094
|
+
else if (key === "F2" || key === "Delete" || key === "Insert") {
|
|
1095
|
+
// The page's own action for the key, where it gave one; else the tree's own editing.
|
|
1096
|
+
const own = rowActionsOf(node).find(action => action.shortcut === key && !action.disabled);
|
|
1097
|
+
if (own)
|
|
1098
|
+
runRowAction(node, own);
|
|
1099
|
+
else if (key === "F2")
|
|
1100
|
+
startRename(node);
|
|
1101
|
+
else if (key === "Delete")
|
|
1102
|
+
void requestDelete(node);
|
|
1103
|
+
else
|
|
1104
|
+
startCreate(node);
|
|
1105
|
+
}
|
|
999
1106
|
else if (key.length === 1 && !ctrlKey && !metaKey && !altKey && key !== " ") {
|
|
1000
1107
|
const now = performance.now();
|
|
1001
1108
|
const typed = typeaheadRef.current;
|
|
@@ -1041,7 +1148,20 @@ export function TreeView(props) {
|
|
|
1041
1148
|
contextItems.push({ value: "delete", label: "Delete", icon: _jsx(InlineIcon, { icon: iconTrash }), shortcut: "Delete", tone: "danger", disabled: !allowed("delete", contextNode) });
|
|
1042
1149
|
}
|
|
1043
1150
|
}
|
|
1151
|
+
// The page's own actions on the row, after the tree's.
|
|
1152
|
+
const ownActions = contextNode ? rowActionsOf(contextNode) : [];
|
|
1153
|
+
if (ownActions.length > 0 && contextItems.length > 0)
|
|
1154
|
+
contextItems.push({ separator: true });
|
|
1155
|
+
for (const action of ownActions) {
|
|
1156
|
+
contextItems.push({ value: `row:${action.id}`, label: action.label, description: action.description, icon: action.icon, shortcut: action.shortcut, tone: action.tone, disabled: action.disabled });
|
|
1157
|
+
}
|
|
1044
1158
|
function onContextSelect(action) {
|
|
1159
|
+
if (action.startsWith("row:")) {
|
|
1160
|
+
const own = ownActions.find(each => `row:${each.id}` === action);
|
|
1161
|
+
if (own && contextNode)
|
|
1162
|
+
runRowAction(contextNode, own);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1045
1165
|
if (action === "create")
|
|
1046
1166
|
startCreate(contextNode ?? null);
|
|
1047
1167
|
else if (action === "update" && contextNode)
|
|
@@ -1076,8 +1196,12 @@ export function TreeView(props) {
|
|
|
1076
1196
|
},
|
|
1077
1197
|
reveal: (revealValue, focus = true) => {
|
|
1078
1198
|
const node = store.get(revealValue);
|
|
1079
|
-
if (!node)
|
|
1199
|
+
if (!node) {
|
|
1200
|
+
const key = treeKey(revealValue);
|
|
1201
|
+
pendingRevealRef.current = key === null ? null : { key, focus };
|
|
1080
1202
|
return;
|
|
1203
|
+
}
|
|
1204
|
+
pendingRevealRef.current = null;
|
|
1081
1205
|
if (focus) {
|
|
1082
1206
|
focusNode(node);
|
|
1083
1207
|
return;
|
|
@@ -1099,6 +1223,10 @@ export function TreeView(props) {
|
|
|
1099
1223
|
afterCheck();
|
|
1100
1224
|
},
|
|
1101
1225
|
getItems: () => store.items(),
|
|
1226
|
+
getItem: itemValue => {
|
|
1227
|
+
const node = store.get(itemValue);
|
|
1228
|
+
return node && !node.draft ? node.item : undefined;
|
|
1229
|
+
},
|
|
1102
1230
|
startCreate: parentValue => startCreate(parentValue === null ? null : (store.get(parentValue) ?? null)),
|
|
1103
1231
|
startRename: renameValue => {
|
|
1104
1232
|
const node = store.get(renameValue);
|
|
@@ -1126,6 +1254,7 @@ export function TreeView(props) {
|
|
|
1126
1254
|
setMotion(null);
|
|
1127
1255
|
bump();
|
|
1128
1256
|
},
|
|
1257
|
+
apply: (changes, rules) => applyChanges(changes, rules),
|
|
1129
1258
|
}));
|
|
1130
1259
|
// Rendering
|
|
1131
1260
|
const showCounts = checkable && cascade && counts;
|
|
@@ -1191,8 +1320,13 @@ export function TreeView(props) {
|
|
|
1191
1320
|
if (allowed("delete", node)) {
|
|
1192
1321
|
actions.push(_jsx(Button, { variant: "ghost", size: "sm", hoverTone: "danger", className: "nx-tree__action", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${node.label}`, title: "Delete (Delete)", tabIndex: -1, onMouseDown: keepEditorFocus, onClick: () => void requestDelete(node) }, "delete"));
|
|
1193
1322
|
}
|
|
1323
|
+
for (const action of rowActionsOf(node)) {
|
|
1324
|
+
if (!action.inline)
|
|
1325
|
+
continue;
|
|
1326
|
+
actions.push(_jsx(Button, { variant: "ghost", size: "sm", hoverTone: action.tone, className: "nx-tree__action", icon: action.icon, "aria-label": `${action.label}: ${node.label}`, title: action.shortcut ? `${action.label} (${action.shortcut})` : action.label, tabIndex: -1, disabled: action.disabled, "data-action": action.id, onMouseDown: keepEditorFocus, onClick: () => runRowAction(node, action) }, `row:${action.id}`));
|
|
1327
|
+
}
|
|
1194
1328
|
}
|
|
1195
|
-
return (_jsxs("div", { role: ghost ? undefined : "treeitem", className: cx("nx-tree__row", locked && "nx-tree__row--locked", edit && "nx-tree__row--editing"), style: { "--nx-tree-depth": node.depth }, "data-key": ghost ? undefined : node.key, "data-uid": ghost ? undefined : node.uid, "data-readonly": locked || undefined, tabIndex: ghost ? undefined : !disabled && tabbable === node ? 0 : -1, "aria-level": ghost ? undefined : node.depth + 1, "aria-setsize": ghost ? undefined : setSize(node), "aria-posinset": ghost ? undefined : node.position + 1, "aria-expanded": ghost || !expandable ? undefined : node.expanded, "aria-checked": ghost || !state ? undefined : state === "mixed" ? "mixed" : state === "checked", "aria-disabled": ghost ? undefined : locked || undefined, "aria-busy": busy || childrenLoading || undefined, "aria-labelledby": ghost || edit ? undefined : labelElementId, "aria-label": edit ? (edit.mode === "create" ? "New item" : `Renaming ${node.label}`) : undefined, "aria-describedby": ghost || edit ? undefined : descriptionElementId, children: [_jsx("span", { className: "nx-tree__toggle", "aria-hidden": "true", children: expandable && _jsx(InlineIcon, { icon: iconChevronRight }) }), checkable && (_jsx("span", { className: cx("nx-tree__check nx-choice", `nx-choice--${size}`), "aria-hidden": "true", children: _jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", tabIndex: -1, checked: state === "checked", disabled: disabled, readOnly: true, ref: input => {
|
|
1329
|
+
return (_jsxs("div", { role: ghost ? undefined : "treeitem", className: cx("nx-tree__row", locked && "nx-tree__row--locked", edit && "nx-tree__row--editing", node.key === selectedKey && "nx-tree__row--selected"), style: { "--nx-tree-depth": node.depth }, "data-key": ghost ? undefined : node.key, "data-uid": ghost ? undefined : node.uid, "data-readonly": locked || undefined, tabIndex: ghost ? undefined : !disabled && tabbable === node ? 0 : -1, "aria-level": ghost ? undefined : node.depth + 1, "aria-setsize": ghost ? undefined : setSize(node), "aria-posinset": ghost ? undefined : node.position + 1, "aria-expanded": ghost || !expandable ? undefined : node.expanded, "aria-checked": ghost || !state ? undefined : state === "mixed" ? "mixed" : state === "checked", "aria-selected": !ghost && node.key === selectedKey ? true : undefined, "aria-disabled": ghost ? undefined : locked || undefined, "aria-busy": busy || childrenLoading || undefined, "aria-labelledby": ghost || edit ? undefined : labelElementId, "aria-label": edit ? (edit.mode === "create" ? "New item" : `Renaming ${node.label}`) : undefined, "aria-describedby": ghost || edit ? undefined : descriptionElementId, children: [_jsx("span", { className: "nx-tree__toggle", "aria-hidden": "true", children: expandable && _jsx(InlineIcon, { icon: iconChevronRight }) }), checkable && (_jsx("span", { className: cx("nx-tree__check nx-choice", `nx-choice--${size}`), "aria-hidden": "true", children: _jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", tabIndex: -1, checked: state === "checked", disabled: disabled, readOnly: true, ref: input => {
|
|
1196
1330
|
if (input)
|
|
1197
1331
|
input.indeterminate = state === "mixed";
|
|
1198
1332
|
} }) })), icon && (_jsx("span", { className: "nx-tree__icon", "aria-hidden": "true", children: icon })), edit ? (_jsx(TreeEditor, { initial: editTextRef.current, select: edit.mode === "update", maxLength: maxLabelLength, saving: saving, label: edit.mode === "create" ? "Name of the new item" : `New name for ${node.label}`, invalid: message !== null, describedBy: message !== null ? messageId : undefined, onText: text => (editTextRef.current = text), onCommit: via => void commitEdit(via), onCancel: cancelEdit }, node.uid)) : (_jsxs("span", { className: "nx-tree__text", children: [_jsx("span", { id: ghost ? undefined : labelElementId, className: "nx-tree__label", title: node.label, children: node.label || "(no name)" }), hasDescription && (_jsx("span", { id: ghost ? undefined : descriptionElementId, className: "nx-tree__description", children: String(description) }))] })), showCounts && hasChildren && !edit && (_jsxs("span", { className: "nx-tree__count", "aria-hidden": "true", children: [node.checkedLeaves.toLocaleString(), "/", node.leaves.toLocaleString(), node.partial && "+"] })), busy && !edit && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin nx-tree__busy", "aria-hidden": "true" }), locked && !edit && (_jsx("span", { className: "nx-tree__lock", title: lockedMessage, "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconLock }) })), actions.length > 0 && _jsx("span", { className: "nx-tree__actions", children: actions })] }, node.uid));
|
|
@@ -1236,17 +1370,17 @@ export function TreeView(props) {
|
|
|
1236
1370
|
const rootWaiting = store.size === 0 && rootPages !== null && (rootPages.status === "idle" || rootPages.status === "loading");
|
|
1237
1371
|
const waiting = loading || rootWaiting;
|
|
1238
1372
|
const empty = total === 0 || rootWaiting;
|
|
1239
|
-
return (_jsxs("div", { className: cx("nx-field nx-field--standard", `nx-field--${size}`, "nx-tree-field", className), style: style, "data-invalid": shownMessage ? true : undefined, "data-readonly": readOnly || undefined, "data-disabled": disabled || undefined, children: [hasLabel && (_jsxs("span", { id: labelId, className: "nx-field__label", children: [label, readOnlyNotice.lock] })), _jsxs("div", { ref: panelRef, className: cx("nx-tree", `nx-tree--${size}`), onFocus: onPanelFocus, onBlur: onPanelBlur, children: [toolbar && (_jsxs("div", { className: "nx-tree__toolbar", children: [checkable && (_jsx(CheckBox, { size: "sm", className: "nx-tree__all", label: "Select all", checked: allState === "checked", indeterminate: allState === "mixed", disabled: disabled || allState === null || empty, readOnly: readOnly, readOnlyMessage: readOnlyMessage, onValueChange: toggleAll })), _jsxs("span", { className: "nx-tree__summary", "aria-live": "polite", children: [(loading || rootPages?.status === "loading") && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin", "aria-hidden": "true" }), waiting && empty
|
|
1373
|
+
return (_jsxs("div", { className: cx("nx-field nx-field--standard", `nx-field--${size}`, "nx-tree-field", fill && "nx-tree-field--fill", className), style: style, "data-invalid": shownMessage ? true : undefined, "data-readonly": readOnly || undefined, "data-disabled": disabled || undefined, children: [hasLabel && (_jsxs("span", { id: labelId, className: "nx-field__label", children: [label, readOnlyNotice.lock] })), _jsxs("div", { ref: panelRef, className: cx("nx-tree", `nx-tree--${size}`), onFocus: onPanelFocus, onBlur: onPanelBlur, children: [toolbar && (_jsxs("div", { className: "nx-tree__toolbar", children: [checkable && (_jsx(CheckBox, { size: "sm", className: "nx-tree__all", label: "Select all", checked: allState === "checked", indeterminate: allState === "mixed", disabled: disabled || allState === null || empty, readOnly: readOnly, readOnlyMessage: readOnlyMessage, onValueChange: toggleAll })), _jsxs("span", { className: "nx-tree__summary", "aria-live": "polite", children: [(loading || rootPages?.status === "loading") && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin", "aria-hidden": "true" }), waiting && empty
|
|
1240
1374
|
? "Loading…"
|
|
1241
1375
|
: checkable
|
|
1242
1376
|
? `${tally.checked.toLocaleString()} of ${tally.total.toLocaleString()} selected`
|
|
1243
|
-
: `${store.size.toLocaleString()} ${store.size === 1 ? "item" : "items"}${lazy ? " loaded" : ""}`, !hasLabel && readOnlyNotice.lock] }), _jsxs("span", { className: "nx-tree__tools", children: [_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsDown }), "aria-label": "Expand all", title: "Expand all", disabled: !hasParents, onClick: expandAll }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsUp }), "aria-label": "Collapse all", title: "Collapse all", disabled: !hasParents, onClick: collapseAll }), creatable && !blocked && (!canChange || canChange("create", null)) && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onMouseDown: keepEditorFocus, onClick: () => startCreate(null), children: "Add" }))] })] })), _jsx(ContextMenu, { items: contextItems, onSelect: onContextSelect, disabled: contextItems.length === 0, "aria-label": "Item actions", children: _jsx("div", { ref: viewportRef, className: "nx-tree__viewport", style: { height, maxHeight: height === undefined ? maxHeight : undefined }, tabIndex: !disabled && !empty && !(tabbable && rendered.has(tabbable.key)) ? 0 : -1, onScroll: event => syncRange(event.currentTarget), onFocus: onViewportFocus, onPointerDown: onViewportPointerDown, onClick: onViewportClick, onKeyDown: onViewportKeyDown, children: empty ? (waiting ? (_jsx("div", { className: "nx-tree__list", "aria-hidden": "true", children: SKELETON_WIDTHS.map((width, index) => (_jsxs("div", { className: "nx-tree__row nx-tree__row--skeleton", style: { "--nx-tree-depth": index % 3 === 0 ? 0 : 1 }, children: [_jsx("span", { className: "nx-tree__toggle" }), _jsx("span", { className: "nx-tree__skeleton", style: { width } })] }, index))) })) : (_jsx("p", { className: "nx-tree__empty", children: emptyMessage }))) : (_jsx("div", { role: "tree", className: "nx-tree__list", "aria-labelledby": hasLabel ? labelId : undefined, "aria-label": hasLabel ? undefined : (ariaLabel ?? "Tree"), "aria-describedby": shownMessage || hasContent(hint) ? messageId : undefined, "aria-disabled": disabled || undefined, "aria-readonly": readOnly || undefined, "aria-busy": waiting || undefined,
|
|
1377
|
+
: `${store.size.toLocaleString()} ${store.size === 1 ? "item" : "items"}${lazy ? " loaded" : ""}`, !hasLabel && readOnlyNotice.lock] }), _jsxs("span", { className: "nx-tree__tools", children: [_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsDown }), "aria-label": "Expand all", title: "Expand all", disabled: !hasParents, onClick: expandAll }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsUp }), "aria-label": "Collapse all", title: "Collapse all", disabled: !hasParents, onClick: collapseAll }), creatable && !blocked && (!canChange || canChange("create", null)) && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onMouseDown: keepEditorFocus, onClick: () => startCreate(null), children: "Add" })), add && !blocked && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), "aria-disabled": add.disabled || undefined, title: add.description, onMouseDown: keepEditorFocus, onKeyDown: addNotice.onKeyDown, onClick: event => (add.disabled ? addNotice.show(event.currentTarget) : add.onClick()), children: add.label }))] })] })), _jsx(ContextMenu, { items: contextItems, onSelect: onContextSelect, disabled: contextItems.length === 0, "aria-label": "Item actions", children: _jsx("div", { ref: viewportRef, className: "nx-tree__viewport", style: fill ? undefined : { height, maxHeight: height === undefined ? maxHeight : undefined }, tabIndex: !disabled && !empty && !(tabbable && rendered.has(tabbable.key)) ? 0 : -1, onScroll: event => syncRange(event.currentTarget), onFocus: onViewportFocus, onPointerDown: onViewportPointerDown, onClick: onViewportClick, onKeyDown: onViewportKeyDown, children: empty ? (waiting ? (_jsx("div", { className: "nx-tree__list", "aria-hidden": "true", children: SKELETON_WIDTHS.map((width, index) => (_jsxs("div", { className: "nx-tree__row nx-tree__row--skeleton", style: { "--nx-tree-depth": index % 3 === 0 ? 0 : 1 }, children: [_jsx("span", { className: "nx-tree__toggle" }), _jsx("span", { className: "nx-tree__skeleton", style: { width } })] }, index))) })) : (_jsx("p", { className: "nx-tree__empty", children: emptyMessage }))) : (_jsx("div", { role: "tree", className: "nx-tree__list", "aria-labelledby": hasLabel ? labelId : undefined, "aria-label": hasLabel ? undefined : (ariaLabel ?? "Tree"), "aria-describedby": shownMessage || hasContent(hint) ? messageId : undefined, "aria-disabled": disabled || undefined, "aria-readonly": readOnly || undefined, "aria-busy": waiting || undefined,
|
|
1244
1378
|
// As tall as all of its rows at every moment of a commit, not only once it has finished. A scroll that
|
|
1245
1379
|
// moves the window makes React take the rows that leave it out before it puts the new ones in and sets
|
|
1246
1380
|
// this padding, and a layout in between - Chrome lays the page out inside the removal of a focused row,
|
|
1247
1381
|
// and so may any focusout listener that measures - would find the list short of the scroll position and
|
|
1248
1382
|
// clamp it: a jump to the end came back up to 1,120px (nexus.web, 2026-10-09).
|
|
1249
|
-
style: { boxSizing: "border-box", minHeight: 2 * PAD + total * rowHeight, paddingTop: PAD + start * rowHeight, paddingBottom: PAD + bottom }, children: body })) }) })] }), (shownMessage || hasContent(hint)) && (_jsx("div", { className: "nx-field__footer", children: _jsxs("p", { id: messageId, className: cx("nx-field__message", shownMessage && "nx-field__message--error"), role: message ? "alert" : undefined, children: [shownMessage && _jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: shownMessage ?? hint })] }) })), name && formValues.map(item => _jsx("input", { type: "hidden", name: name, form: form, disabled: disabled, value: String(item) }, String(item))), lazy && (_jsx("span", { className: "nx-visually-hidden", role: "status", "aria-live": "polite", children: announcement })), readOnlyNotice.notice, lockedNotice.notice, partialNotice.notice] }));
|
|
1383
|
+
style: { boxSizing: "border-box", minHeight: 2 * PAD + total * rowHeight, paddingTop: PAD + start * rowHeight, paddingBottom: PAD + bottom }, children: body })) }) })] }), (shownMessage || hasContent(hint)) && (_jsx("div", { className: "nx-field__footer", children: _jsxs("p", { id: messageId, className: cx("nx-field__message", shownMessage && "nx-field__message--error"), role: message ? "alert" : undefined, children: [shownMessage && _jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: shownMessage ?? hint })] }) })), name && formValues.map(item => _jsx("input", { type: "hidden", name: name, form: form, disabled: disabled, value: String(item) }, String(item))), lazy && (_jsx("span", { className: "nx-visually-hidden", role: "status", "aria-live": "polite", children: announcement })), readOnlyNotice.notice, lockedNotice.notice, partialNotice.notice, addNotice.notice] }));
|
|
1250
1384
|
}
|
|
1251
1385
|
/** The name box of a row being added or renamed. Enter saves, Esc cancels, and leaving it saves. */
|
|
1252
1386
|
function TreeEditor({ initial, select, maxLength, saving, label, invalid, describedBy, onText, onCommit, onCancel }) {
|
|
@@ -5,16 +5,29 @@ import type { PopupSize } from "./Popup.tsx";
|
|
|
5
5
|
export type MessageTone = "neutral" | "info" | "success" | "warning" | "danger" | "question";
|
|
6
6
|
export interface MessageBoxOptions {
|
|
7
7
|
title: ReactNode;
|
|
8
|
-
/** Text under the title. */
|
|
8
|
+
/** Text under the title, in the box's muted text. */
|
|
9
9
|
message?: ReactNode;
|
|
10
|
+
/**
|
|
11
|
+
* What the question is about, in full, after the message and in the normal text colour: a record's details
|
|
12
|
+
* (`<CrudDetails row columns />`), a list. It scrolls with the box's body while the buttons stay in view; give the box a
|
|
13
|
+
* width that suits it (`size: "lg"` or `"xl"`).
|
|
14
|
+
*/
|
|
15
|
+
details?: ReactNode;
|
|
10
16
|
/** Colors the icon: `info`, `success`, `warning`, `danger`, `question`. `neutral` shows no icon. */
|
|
11
17
|
tone?: MessageTone;
|
|
12
18
|
/** Replaces the tone's icon; `null` shows none. */
|
|
13
19
|
icon?: ReactNode | null;
|
|
14
20
|
/** Default `sm`. */
|
|
15
21
|
size?: PopupSize;
|
|
16
|
-
/** The close button
|
|
22
|
+
/** The close button and Esc dismiss the box. Default true. */
|
|
17
23
|
closable?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* A click outside dismisses the box. Default: as `closable`. `false` keeps a box with much to read (a question with its
|
|
26
|
+
* `details`) open through a stray click, while Esc and the close button still dismiss it.
|
|
27
|
+
*/
|
|
28
|
+
closeOnBackdrop?: boolean;
|
|
29
|
+
/** Dismisses the box when it aborts, as Esc would: the form or the page that asked has gone. */
|
|
30
|
+
signal?: AbortSignal;
|
|
18
31
|
}
|
|
19
32
|
export interface MessageBoxButton<T extends string = string> {
|
|
20
33
|
value: T;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { iconAlertCircle, iconAlertTriangle, iconCircleCheck, iconHelpCircle, iconInfoCircle, InlineIcon } from "nexus-icons";
|
|
4
4
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
5
5
|
import { Button } from "../Documents/Button.js";
|
|
@@ -14,40 +14,85 @@ const TONE_ICONS = {
|
|
|
14
14
|
question: iconHelpCircle,
|
|
15
15
|
};
|
|
16
16
|
let promptCounter = 0;
|
|
17
|
+
let messageCounter = 0;
|
|
18
|
+
/*
|
|
19
|
+
* A held Enter, or one pressed again at once (a double press), never answers a box. The Enter that opened a question (in
|
|
20
|
+
* a form's box) would otherwise press the Confirm it gave the focus to - by the key's repeat, or by the second tap of a
|
|
21
|
+
* double press - before anything in the box was read. Any Enter within this long of the one before it is held.
|
|
22
|
+
*/
|
|
23
|
+
const HASTY_ENTER_MS = 300;
|
|
24
|
+
let lastEnterAt = Number.NEGATIVE_INFINITY;
|
|
25
|
+
function holdHastyEnter(event) {
|
|
26
|
+
if (event.key !== "Enter")
|
|
27
|
+
return;
|
|
28
|
+
const gap = event.timeStamp - lastEnterAt;
|
|
29
|
+
lastEnterAt = event.timeStamp;
|
|
30
|
+
if (!(event.target instanceof Element) || !event.target.closest(".nx-popup--message"))
|
|
31
|
+
return;
|
|
32
|
+
if (event.repeat || gap < HASTY_ENTER_MS)
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
}
|
|
35
|
+
// Listening from the start: the Enter that opens a box comes before the box.
|
|
36
|
+
if (typeof window !== "undefined")
|
|
37
|
+
window.addEventListener("keydown", holdHastyEnter, true);
|
|
17
38
|
function toneIcon(tone, icon) {
|
|
18
39
|
if (icon !== undefined)
|
|
19
40
|
return icon;
|
|
20
41
|
const data = TONE_ICONS[tone];
|
|
21
42
|
return data ? _jsx(InlineIcon, { icon: data }) : null;
|
|
22
43
|
}
|
|
44
|
+
/** The muted message, then the details in the normal text colour; each with an id the box is described by. */
|
|
45
|
+
function messageBody(message, details, id) {
|
|
46
|
+
return (_jsxs(_Fragment, { children: [message && (_jsx("div", { id: `${id}-text`, className: "nx-message__text", children: message })), details && (_jsx("div", { id: `${id}-details`, className: "nx-message__details", children: details }))] }));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* What describes a box to a screen reader: its message and its details, read with the title as the focus enters it, so
|
|
50
|
+
* a question's Confirm is never reached before what it confirms was heard.
|
|
51
|
+
*/
|
|
52
|
+
function describedBy(message, details, id) {
|
|
53
|
+
return cx(message ? `${id}-text` : undefined, details ? `${id}-details` : undefined) || undefined;
|
|
54
|
+
}
|
|
55
|
+
/** Dismisses an open box when the signal aborts; nothing is left listening once it has closed. */
|
|
56
|
+
function closeOnAbort(popup, signal) {
|
|
57
|
+
if (!signal)
|
|
58
|
+
return;
|
|
59
|
+
const dismiss = () => popup.close();
|
|
60
|
+
signal.addEventListener("abort", dismiss, { once: true });
|
|
61
|
+
void popup.closed.then(() => signal.removeEventListener("abort", dismiss));
|
|
62
|
+
}
|
|
23
63
|
/**
|
|
24
64
|
* A message box with the given buttons. Resolves with the clicked button's value, or `null` when the box was
|
|
25
65
|
* dismissed with the close button, Esc, or a click outside.
|
|
26
66
|
*/
|
|
27
67
|
export function showMessageBox(options) {
|
|
28
|
-
const { title, message, tone = "neutral", icon, size = "sm", closable = true, buttons } = options;
|
|
68
|
+
const { title, message, details, tone = "neutral", icon, size = "sm", closable = true, closeOnBackdrop = closable, signal, buttons } = options;
|
|
69
|
+
if (signal?.aborted)
|
|
70
|
+
return Promise.resolve(null);
|
|
29
71
|
const cautious = tone === "danger" || tone === "warning";
|
|
30
72
|
const explicit = buttons.findIndex(button => button.autoFocus);
|
|
31
73
|
const focusIndex = explicit >= 0 ? explicit : cautious ? 0 : buttons.length - 1;
|
|
32
74
|
let result = null;
|
|
75
|
+
const id = `nx-message-${++messageCounter}`;
|
|
33
76
|
const popup = openPopup({
|
|
34
77
|
title,
|
|
35
78
|
icon: toneIcon(tone, icon),
|
|
36
79
|
size,
|
|
37
80
|
closable,
|
|
38
|
-
closeOnBackdrop
|
|
81
|
+
closeOnBackdrop,
|
|
39
82
|
closeOnEscape: closable,
|
|
40
83
|
variant: "dialog",
|
|
41
84
|
minimizable: false,
|
|
42
85
|
maximizable: false,
|
|
43
86
|
className: cx("nx-popup--message", `nx-popup--tone-${tone}`),
|
|
44
87
|
initialFocus: "[data-nx-focus]",
|
|
45
|
-
|
|
88
|
+
"aria-describedby": describedBy(message, details, id),
|
|
89
|
+
content: message || details ? messageBody(message, details, id) : null,
|
|
46
90
|
footer: handle => buttons.map((button, index) => (_jsx(Button, { variant: button.variant ?? (index === buttons.length - 1 ? (tone === "danger" ? "danger" : "primary") : "secondary"), "data-nx-focus": index === focusIndex || undefined, onClick: () => {
|
|
47
91
|
result = button.value;
|
|
48
92
|
handle.close();
|
|
49
93
|
}, children: button.label }, button.value))),
|
|
50
94
|
});
|
|
95
|
+
closeOnAbort(popup, signal);
|
|
51
96
|
return popup.closed.then(() => result);
|
|
52
97
|
}
|
|
53
98
|
function options(input) {
|
|
@@ -72,7 +117,9 @@ function confirm(input) {
|
|
|
72
117
|
}
|
|
73
118
|
/** A text box with Cancel and OK. Resolves with the text, or `null` when cancelled. */
|
|
74
119
|
function prompt(input) {
|
|
75
|
-
const { title, message, tone = "neutral", icon, size = "sm", closable = true, label, defaultValue, placeholder, required = true, inputType = "text", confirmLabel, cancelLabel } = options(input);
|
|
120
|
+
const { title, message, details, tone = "neutral", icon, size = "sm", closable = true, closeOnBackdrop = closable, signal, label, defaultValue, placeholder, required = true, inputType = "text", confirmLabel, cancelLabel } = options(input);
|
|
121
|
+
if (signal?.aborted)
|
|
122
|
+
return Promise.resolve(null);
|
|
76
123
|
const formId = `nx-prompt-${++promptCounter}`;
|
|
77
124
|
let result = null;
|
|
78
125
|
const popup = openPopup({
|
|
@@ -80,20 +127,22 @@ function prompt(input) {
|
|
|
80
127
|
icon: toneIcon(tone, icon),
|
|
81
128
|
size,
|
|
82
129
|
closable,
|
|
83
|
-
closeOnBackdrop
|
|
130
|
+
closeOnBackdrop,
|
|
84
131
|
closeOnEscape: closable,
|
|
85
132
|
variant: "dialog",
|
|
86
133
|
minimizable: false,
|
|
87
134
|
maximizable: false,
|
|
88
135
|
className: cx("nx-popup--message", `nx-popup--tone-${tone}`),
|
|
89
136
|
initialFocus: `#${formId} input`,
|
|
137
|
+
"aria-describedby": describedBy(message, details, formId),
|
|
90
138
|
content: handle => (_jsxs("form", { id: formId, className: "nx-message__form", onSubmit: (event) => {
|
|
91
139
|
event.preventDefault();
|
|
92
140
|
result = String(new FormData(event.currentTarget).get("value") ?? "");
|
|
93
141
|
handle.close();
|
|
94
|
-
}, children: [message
|
|
142
|
+
}, children: [messageBody(message, details, formId), _jsx(TextBox, { name: "value", label: label, type: inputType, defaultValue: defaultValue, placeholder: placeholder, required: required })] })),
|
|
95
143
|
footer: handle => (_jsxs(_Fragment, { children: [_jsx(Button, { onClick: () => handle.close(), children: cancelLabel ?? "Cancel" }), _jsx(Button, { type: "submit", form: formId, variant: "primary", children: confirmLabel ?? "OK" })] })),
|
|
96
144
|
});
|
|
145
|
+
closeOnAbort(popup, signal);
|
|
97
146
|
return popup.closed.then(() => result);
|
|
98
147
|
}
|
|
99
148
|
/**
|
|
@@ -50,8 +50,9 @@ export interface PopupProps {
|
|
|
50
50
|
closeOnBackdrop?: boolean;
|
|
51
51
|
/**
|
|
52
52
|
* The minimize button: the popup steps aside to a bar at the bottom right, keeping everything typed in it, and the
|
|
53
|
-
* page can be used again. Default:
|
|
54
|
-
*
|
|
53
|
+
* page can be used again. Default false: only a popup that asks has it. A CRUD page's Add and Edit forms and its row
|
|
54
|
+
* forms ask (a form put aside while the list behind it is read); message boxes, viewers, galleries, histories and
|
|
55
|
+
* the rest do not. Needs `<PopupHost />`, which shows the bars.
|
|
55
56
|
*/
|
|
56
57
|
minimizable?: boolean;
|
|
57
58
|
/** The maximize button, and a double-click on the title bar: the popup fills the window. Default true. */
|
|
@@ -76,17 +77,23 @@ export interface PopupProps {
|
|
|
76
77
|
initialFocus?: string;
|
|
77
78
|
/** Name for screen readers when there is no title. */
|
|
78
79
|
"aria-label"?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Ids of elements inside the popup that describe it to screen readers, after its `description`: a message box names
|
|
82
|
+
* its message and its details, so they are read with the title as the focus enters.
|
|
83
|
+
*/
|
|
84
|
+
"aria-describedby"?: string;
|
|
79
85
|
className?: string;
|
|
80
86
|
children?: ReactNode;
|
|
81
87
|
}
|
|
82
88
|
/**
|
|
83
|
-
* A modal window on the native <dialog>, with the buttons of a desktop window:
|
|
84
|
-
*
|
|
85
|
-
*
|
|
89
|
+
* A modal window on the native <dialog>, with the buttons of a desktop window: maximize and close, and minimize when
|
|
90
|
+
* it asks (`minimizable`: a CRUD page's Add and Edit). It sits in the top layer above everything, dims and locks the
|
|
91
|
+
* page behind it, traps Tab, closes with Esc or a click outside, and returns focus to where it came from. The title
|
|
92
|
+
* bar moves it; a double-click there maximizes it. Minimized, it
|
|
86
93
|
* waits as a bar at the bottom right with its content kept, while the page can be used. The body scrolls while the
|
|
87
94
|
* title and the footer stay. The content mounts while the popup is open, so a form inside starts fresh every time.
|
|
88
95
|
*
|
|
89
96
|
* @example
|
|
90
97
|
* <Popup open={open} onClose={() => setOpen(false)} title="Edit booking" footer={<Button variant="primary">Save</Button>}>…</Popup>
|
|
91
98
|
*/
|
|
92
|
-
export declare function Popup({ open, onClose, onClosed, title, description, icon, variant, size, footer, closable, closeOnEscape, closeOnBackdrop, minimizable, maximizable, movable, reopen, windowState: windowStateProp, defaultWindowState, onWindowStateChange, flush, initialFocus, "aria-label": ariaLabel, className, children, }: PopupProps): import("react").JSX.Element;
|
|
99
|
+
export declare function Popup({ open, onClose, onClosed, title, description, icon, variant, size, footer, closable, closeOnEscape, closeOnBackdrop, minimizable, maximizable, movable, reopen, windowState: windowStateProp, defaultWindowState, onWindowStateChange, flush, initialFocus, "aria-label": ariaLabel, "aria-describedby": ariaDescribedBy, className, children, }: PopupProps): import("react").JSX.Element;
|