nexus-shared 3.1.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 +2268 -446
- package/LICENSE +21 -21
- package/README.md +1705 -1084
- package/dist/Client.Index.d.ts +17 -0
- package/dist/Client.Index.js +17 -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 +871 -377
- package/dist/Components/Forms/CrudActions.d.ts +136 -0
- package/dist/Components/Forms/CrudActions.js +184 -0
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -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 +56 -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 +125 -16
- 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/CodeBox.d.ts +69 -0
- package/dist/Components/Inputs/CodeBox.js +316 -0
- package/dist/Components/Inputs/DatePicker.js +4 -1
- package/dist/Components/Inputs/DateRangePicker.js +4 -1
- package/dist/Components/Inputs/DateTimePicker.js +4 -1
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +8 -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 +39 -31
- package/dist/Components/Inputs/NumberBox.js +4 -2
- 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 +14 -2
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +107 -6
- package/dist/Components/Inputs/TreeView.js +513 -30
- 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/Layouts/ThemePicker.d.ts +3 -2
- package/dist/Components/Layouts/ThemePicker.js +16 -4
- package/dist/Components/Viewers/DataTable.js +205 -61
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +46 -12
- package/dist/Components/Viewers/DataTableParts.js +62 -21
- 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/ApiModules.d.ts +3 -34
- package/dist/Helpers/ApiModules.js +3 -3
- package/dist/Helpers/ApiRoutes.d.ts +68 -26
- package/dist/Helpers/ApiRoutes.js +31 -27
- package/dist/Helpers/ArrayHelpers.d.ts +0 -0
- package/dist/Helpers/ArrayHelpers.js +2 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +114 -6
- package/dist/Helpers/CrudBackend.js +214 -11
- package/dist/Helpers/CrudColumns.d.ts +98 -0
- package/dist/Helpers/CrudColumns.js +160 -0
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +130 -6
- package/dist/Helpers/CrudHelpers.js +304 -12
- 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 +162 -71
- package/dist/Helpers/MessageBuilder.js +2 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
- package/dist/Helpers/NexusDriveAdapter.js +207 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -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 +47 -0
- package/dist/Helpers/RequestCache.js +132 -0
- 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 +21 -15
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +24 -3
- package/dist/Helpers/TableHelpers.js +58 -12
- package/dist/Helpers/TableJoins.d.ts +83 -0
- package/dist/Helpers/TableJoins.js +284 -0
- package/dist/Helpers/TableStore.d.ts +26 -1
- package/dist/Helpers/TableStore.js +73 -7
- package/dist/Helpers/TreeStore.d.ts +306 -11
- package/dist/Helpers/TreeStore.js +1048 -90
- package/dist/Helpers/UserPreferences.d.ts +8 -0
- package/dist/Helpers/UserPreferences.js +17 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +512 -14
- package/dist/Interfaces/FileInterfaces.d.ts +162 -0
- package/dist/Interfaces/FileInterfaces.js +4 -0
- package/dist/Interfaces/FormInterfaces.d.ts +393 -29
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +39 -3
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
- package/dist/Interfaces/PreferenceInterfaces.js +2 -5
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +160 -5
- package/dist/Services/ApiProxy.d.ts +36 -1
- package/dist/Services/ApiProxy.js +182 -52
- package/dist/Services/ApiProxyRules.d.ts +203 -0
- package/dist/Services/ApiProxyRules.js +426 -0
- package/dist/Services/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 +80 -0
- package/dist/Services/JoinSources.js +150 -0
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Shared.Index.d.ts +20 -0
- package/dist/Shared.Index.js +20 -0
- package/package.json +92 -85
- package/src/Styles/Nexus.Avatar.css +80 -79
- package/src/Styles/Nexus.Base.css +117 -117
- package/src/Styles/Nexus.Button.css +186 -180
- package/src/Styles/Nexus.Calendar.css +273 -273
- package/src/Styles/Nexus.Chat.css +1443 -1443
- package/src/Styles/Nexus.Choice.css +359 -343
- package/src/Styles/Nexus.Crud.css +542 -168
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +733 -725
- package/src/Styles/Nexus.Editor.css +295 -295
- package/src/Styles/Nexus.Excel.css +389 -389
- package/src/Styles/Nexus.Files.css +410 -0
- package/src/Styles/Nexus.Form.css +282 -279
- package/src/Styles/Nexus.Index.css +41 -37
- package/src/Styles/Nexus.Input.css +238 -4
- package/src/Styles/Nexus.Markdown.css +237 -237
- package/src/Styles/Nexus.Menu.css +246 -237
- package/src/Styles/Nexus.Popup.css +800 -724
- 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.Rows.css +637 -637
- package/src/Styles/Nexus.Slider.css +276 -276
- package/src/Styles/Nexus.Tab.Buttons.css +497 -432
- package/src/Styles/Nexus.Table.css +98 -35
- package/src/Styles/Nexus.Theme.Picker.css +59 -3
- package/src/Styles/Nexus.Themes.css +200 -194
- package/src/Styles/Nexus.Time.css +144 -144
- package/src/Styles/Nexus.Toast.css +263 -263
- package/src/Styles/Nexus.Tokens.css +94 -89
- package/src/Styles/Nexus.Tree.css +530 -431
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Icon, iconAlertCircle, iconCheck, iconChevronRight, iconChevronsDown, iconChevronsUp, iconLoader2, iconLock, iconPencil, iconPlus, iconTrash, iconX, InlineIcon, } from "nexus-icons";
|
|
4
|
-
import { isValidElement, useId, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from "react";
|
|
3
|
+
import { Icon, iconAlertCircle, iconCheck, iconChevronRight, iconChevronsDown, iconChevronsUp, iconDots, iconLoader2, iconLock, iconPencil, iconPlus, iconTrash, iconX, InlineIcon, } from "nexus-icons";
|
|
4
|
+
import { isValidElement, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from "react";
|
|
5
5
|
import { afterAnimation } from "../../Helpers/AnimationHelpers.js";
|
|
6
6
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
|
-
import {
|
|
7
|
+
import { isPromiseLike, REQUEST_CACHE_LIFETIME } from "../../Helpers/RequestCache.js";
|
|
8
|
+
import { expireTreePages, subscribeTreeChanges, TREE_DEFAULT_KEYS, treeKey, treePageKey, treePages, TreeStore, treeStatusKey, } from "../../Helpers/TreeStore.js";
|
|
8
9
|
import { Button } from "../Documents/Button.js";
|
|
9
10
|
import { ContextMenu } from "../Documents/Menu.js";
|
|
10
11
|
import { messageBox } from "../Layouts/MessageBox.js";
|
|
@@ -20,6 +21,9 @@ const OVERSCAN = 12;
|
|
|
20
21
|
/** Letters typed within this time, in milliseconds, add up to one search for a row. */
|
|
21
22
|
const TYPEAHEAD_DELAY = 600;
|
|
22
23
|
const SKELETON_WIDTHS = ["42%", "58%", "36%", "64%", "48%"];
|
|
24
|
+
/** Children a lazy tree asks for at a time, by default. */
|
|
25
|
+
const CHILD_PAGE_SIZE = 50;
|
|
26
|
+
const NO_ITEMS = [];
|
|
23
27
|
let draftCounter = 0;
|
|
24
28
|
/** A value for an item added in the browser, until a server gives it one. */
|
|
25
29
|
function newTreeValue() {
|
|
@@ -38,11 +42,21 @@ const sameItems = (a, b) => a === b || (a.length === b.length && a.every((item,
|
|
|
38
42
|
* Keys: arrows move and expand, Home and End jump, letters find a row, Space checks, Enter opens, `*` expands the
|
|
39
43
|
* siblings, F2 renames, Delete deletes, Insert adds under the focused row, and Shift+F10 opens the actions menu.
|
|
40
44
|
*
|
|
45
|
+
* With `loadChildren` the children come from a server, a page at a time, when an item is first expanded: a loading
|
|
46
|
+
* row while they come, a row that says the page failed with Retry, and Load more with how many are left. Those rows
|
|
47
|
+
* are rows like the others, reached with the arrows and pressed with Enter or Space. What is loaded stays (collapse
|
|
48
|
+
* and expand again asks nothing), `cacheKey` shares it with the next tree, and the handle's `refresh` loads again what
|
|
49
|
+
* is on screen. With `cascade`, an item whose items are not all loaded cannot be checked as a whole: the value can only
|
|
50
|
+
* hold items that were loaded, so its check would silently leave the rest out.
|
|
51
|
+
*
|
|
41
52
|
* @example
|
|
42
53
|
* <TreeView label="Permissions" items={permissions} checkable name="actionIds" defaultExpanded={1} isItemDisabled={item => !item.canAssign} />
|
|
54
|
+
*
|
|
55
|
+
* <TreeView label="Regions" items={countries} hasChildrenKey="childCount" cacheKey="regions"
|
|
56
|
+
* loadChildren={({ parent, skip, take, signal }) => loadRegions(parent, skip, take, signal)} />
|
|
43
57
|
*/
|
|
44
58
|
export function TreeView(props) {
|
|
45
|
-
const { items, 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;
|
|
46
60
|
const generatedId = useId();
|
|
47
61
|
const treeId = id ?? generatedId;
|
|
48
62
|
const labelId = `${treeId}-label`;
|
|
@@ -51,7 +65,13 @@ export function TreeView(props) {
|
|
|
51
65
|
// The store holds the tree outside React. It is built again only when a key or a checking rule changes.
|
|
52
66
|
const disabledRef = useRef(isItemDisabled);
|
|
53
67
|
disabledRef.current = isItemDisabled;
|
|
54
|
-
const
|
|
68
|
+
const items = givenItems ?? NO_ITEMS;
|
|
69
|
+
const lazy = loadChildren !== undefined;
|
|
70
|
+
const lazyRoots = lazy && givenItems === undefined;
|
|
71
|
+
const loadRef = useRef(loadChildren);
|
|
72
|
+
loadRef.current = loadChildren;
|
|
73
|
+
// A tree built again (another key or rule) loads its pages again: from the cache when it has a key, so nothing is asked.
|
|
74
|
+
const config = [valueKey, parentKey, labelKey, cascade, valueMode, lazy, lazyRoots, hasChildrenKey ?? ""].join("");
|
|
55
75
|
const holderRef = useRef(null);
|
|
56
76
|
// Whether the user (or the handle) changed the checks since the items came, so new items keep them.
|
|
57
77
|
const touchedRef = useRef(false);
|
|
@@ -64,6 +84,9 @@ export function TreeView(props) {
|
|
|
64
84
|
labelKey,
|
|
65
85
|
cascade,
|
|
66
86
|
valueMode,
|
|
87
|
+
lazy,
|
|
88
|
+
lazyRoots,
|
|
89
|
+
hasChildrenKey,
|
|
67
90
|
isItemDisabled: item => (disabledRef.current ? disabledRef.current(item) : read(item, "disabled") === true || read(item, "isDisabled") === true),
|
|
68
91
|
}, value ?? (previous && touchedRef.current ? previous.value() : defaultValue));
|
|
69
92
|
if (previous)
|
|
@@ -103,6 +126,14 @@ export function TreeView(props) {
|
|
|
103
126
|
const [busyKeys, setBusyKeys] = useState(() => new Set());
|
|
104
127
|
const [message, setMessage] = useState(null);
|
|
105
128
|
const [contextKey, setContextKey] = useState(undefined);
|
|
129
|
+
// What a screen reader hears about pages of children: on their way, in, or failed.
|
|
130
|
+
const [announcement, setAnnouncement] = useState("");
|
|
131
|
+
// The focused row as the answers to requests see it: they come after the render that sent them.
|
|
132
|
+
const focusKeyRef = useRef(focusKey);
|
|
133
|
+
focusKeyRef.current = focusKey;
|
|
134
|
+
const mountedRef = useRef(true);
|
|
135
|
+
// Requests made without a cache are aborted when the tree goes; kept ones are not, since the next tree wants them.
|
|
136
|
+
const abortRef = useRef(null);
|
|
106
137
|
const panelRef = useRef(null);
|
|
107
138
|
const viewportRef = useRef(null);
|
|
108
139
|
const motionRef = useRef(null);
|
|
@@ -111,6 +142,8 @@ export function TreeView(props) {
|
|
|
111
142
|
const pendingScrollRef = useRef(null);
|
|
112
143
|
const pendingFocusRef = useRef(null);
|
|
113
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);
|
|
114
147
|
const blocked = readOnly || disabled;
|
|
115
148
|
const noticeAnchor = (target) => {
|
|
116
149
|
const row = target.closest(".nx-tree__row");
|
|
@@ -118,6 +151,11 @@ export function TreeView(props) {
|
|
|
118
151
|
};
|
|
119
152
|
const readOnlyNotice = useReadOnlyNotice(readOnly && !disabled, viewportRef, readOnlyMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
|
|
120
153
|
const lockedNotice = useReadOnlyNotice(!blocked, viewportRef, lockedMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
|
|
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);
|
|
121
159
|
// The row a note points at: when it leaves the page (scrolled out of the window), the note goes too.
|
|
122
160
|
const noteRowRef = useRef(null);
|
|
123
161
|
function showNote(notice, target) {
|
|
@@ -129,15 +167,20 @@ export function TreeView(props) {
|
|
|
129
167
|
// The value for a form, read again only when something could have changed it.
|
|
130
168
|
const formValues = useMemo(() => (name ? store.value() : []), [name, store, version, items, value]);
|
|
131
169
|
function rowElement(key) {
|
|
132
|
-
const node = key === null ? undefined : store.
|
|
170
|
+
const node = key === null ? undefined : store.find(key);
|
|
133
171
|
return node ? (viewportRef.current?.querySelector(`[data-uid="${node.uid}"]`) ?? null) : null;
|
|
134
172
|
}
|
|
173
|
+
/** The row an event happened in: an item's, or a status row (loading, failed, Load more). */
|
|
135
174
|
function nodeOfTarget(target) {
|
|
136
|
-
const row = target instanceof Element ? target.closest(".nx-tree__row[data-key]") : null;
|
|
137
|
-
|
|
175
|
+
const row = target instanceof Element ? target.closest(".nx-tree__row[data-key], .nx-tree__row[data-status-of]") : null;
|
|
176
|
+
if (!row)
|
|
177
|
+
return undefined;
|
|
178
|
+
if (row.dataset.key !== undefined)
|
|
179
|
+
return store.get(row.dataset.key);
|
|
180
|
+
return row.dataset.statusOf === undefined ? undefined : store.find(treeStatusKey(row.dataset.statusOf || null));
|
|
138
181
|
}
|
|
139
182
|
function focusedNode() {
|
|
140
|
-
const node = focusKey === null ? undefined : store.
|
|
183
|
+
const node = focusKey === null ? undefined : store.find(focusKey);
|
|
141
184
|
return node && store.isVisible(node) ? node : undefined;
|
|
142
185
|
}
|
|
143
186
|
// Scrolling and focus
|
|
@@ -152,9 +195,33 @@ export function TreeView(props) {
|
|
|
152
195
|
const next = { start: Math.max(0, first - OVERSCAN), end: last + OVERSCAN };
|
|
153
196
|
if (next.start === current.start && next.end === current.end)
|
|
154
197
|
return;
|
|
198
|
+
releaseFocusLeaving(viewport, next);
|
|
155
199
|
rangeRef.current = next;
|
|
156
200
|
setRange(next);
|
|
157
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* A focused row that the new window leaves out hands focus to the viewport now, in the event that moves the window,
|
|
204
|
+
* rather than being taken out of the page still focused in the middle of React's commit. Chrome lays the page out
|
|
205
|
+
* inside the removal of a focused element, after blur and focusout - and so does any focusout listener that measures -
|
|
206
|
+
* at a moment when React has taken the leaving rows out but not yet put the new ones in or moved the padding: the list
|
|
207
|
+
* looked short and the scroll position was clamped, or scroll anchoring moved it to keep a row in place (nexus.web,
|
|
208
|
+
* 2026-10-09: a jump to the end came back 160-1,120px after a client-side navigation). A name box being edited in
|
|
209
|
+
* the row lets go too, without saving, as it did when its row unmounted: it comes back focused with its row.
|
|
210
|
+
*/
|
|
211
|
+
function releaseFocusLeaving(viewport, next) {
|
|
212
|
+
const active = document.activeElement;
|
|
213
|
+
if (!(active instanceof HTMLElement) || active === viewport || !viewport.contains(active))
|
|
214
|
+
return;
|
|
215
|
+
const node = nodeOfTarget(active);
|
|
216
|
+
if (!node)
|
|
217
|
+
return;
|
|
218
|
+
const index = store.indexOf(node);
|
|
219
|
+
if (index >= next.start && index < next.end)
|
|
220
|
+
return;
|
|
221
|
+
restoringRef.current = true;
|
|
222
|
+
viewport.focus({ preventScroll: true });
|
|
223
|
+
restoringRef.current = false;
|
|
224
|
+
}
|
|
158
225
|
function scrollToIndex(index) {
|
|
159
226
|
const viewport = viewportRef.current;
|
|
160
227
|
if (!viewport || index < 0)
|
|
@@ -230,6 +297,7 @@ export function TreeView(props) {
|
|
|
230
297
|
noteRowRef.current = null;
|
|
231
298
|
readOnlyNotice.hide();
|
|
232
299
|
lockedNotice.hide();
|
|
300
|
+
partialNotice.hide();
|
|
233
301
|
});
|
|
234
302
|
// An open or close ends when its animation does, or at once when none runs (reduced motion).
|
|
235
303
|
useLayoutEffect(() => {
|
|
@@ -242,9 +310,136 @@ export function TreeView(props) {
|
|
|
242
310
|
}
|
|
243
311
|
return afterAnimation(group, motion.type === "open" ? "nx-tree-open" : "nx-tree-close", () => setMotion(null));
|
|
244
312
|
}, [motion]);
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
mountedRef.current = true;
|
|
315
|
+
return () => {
|
|
316
|
+
mountedRef.current = false;
|
|
317
|
+
const controller = abortRef.current;
|
|
318
|
+
// React's development double run sets the tree up again at once: abort only when it stays gone.
|
|
319
|
+
queueMicrotask(() => {
|
|
320
|
+
if (mountedRef.current)
|
|
321
|
+
return;
|
|
322
|
+
controller?.abort();
|
|
323
|
+
if (abortRef.current === controller)
|
|
324
|
+
abortRef.current = null;
|
|
325
|
+
});
|
|
326
|
+
};
|
|
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]);
|
|
335
|
+
// Open parents with rows in view that need pages: the top level at first, a parent a refresh made stale, one whose
|
|
336
|
+
// request a refresh forgot. A stale parent out of view waits until it scrolls in, and a closed one until it opens; a
|
|
337
|
+
// failed one waits for Retry, so a server that is down is not asked again and again.
|
|
338
|
+
useLayoutEffect(() => {
|
|
339
|
+
if (!lazy)
|
|
340
|
+
return;
|
|
341
|
+
const wanted = new Set();
|
|
342
|
+
if (store.rootPages)
|
|
343
|
+
wanted.add(null);
|
|
344
|
+
for (const row of store.rows.slice(Math.min(range.start, store.rows.length), Math.min(range.end, store.rows.length))) {
|
|
345
|
+
for (let parent = row.parent; parent; parent = parent.parent)
|
|
346
|
+
wanted.add(parent);
|
|
347
|
+
}
|
|
348
|
+
for (const parent of wanted) {
|
|
349
|
+
const pages = store.pagesOf(parent);
|
|
350
|
+
if (!pages || pages.status === "loading" || pages.status === "failed" || (parent && !parent.expanded))
|
|
351
|
+
continue;
|
|
352
|
+
if (pages.stale)
|
|
353
|
+
reloadPages(parent);
|
|
354
|
+
else if (pages.status === "idle")
|
|
355
|
+
loadNext(parent);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
245
358
|
function onPanelFocus() {
|
|
246
359
|
hadFocusRef.current = true;
|
|
247
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
|
+
}
|
|
248
443
|
function onPanelBlur(event) {
|
|
249
444
|
const next = event.relatedTarget;
|
|
250
445
|
if (next instanceof Node && panelRef.current?.contains(next))
|
|
@@ -253,6 +448,8 @@ export function TreeView(props) {
|
|
|
253
448
|
hadFocusRef.current = false;
|
|
254
449
|
readOnlyNotice.hide();
|
|
255
450
|
lockedNotice.hide();
|
|
451
|
+
partialNotice.hide();
|
|
452
|
+
addNotice.hide();
|
|
256
453
|
return;
|
|
257
454
|
}
|
|
258
455
|
// Focus went nowhere: a click on the page, or a focused row that left the DOM. The first is settled after this frame.
|
|
@@ -329,6 +526,11 @@ export function TreeView(props) {
|
|
|
329
526
|
showNote(lockedNotice, target);
|
|
330
527
|
return;
|
|
331
528
|
}
|
|
529
|
+
// Its items are not all loaded: a check would stand for items the value cannot hold.
|
|
530
|
+
if (cascade && node.partial) {
|
|
531
|
+
showNote(partialNotice, target);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
332
534
|
if (store.toggle(node))
|
|
333
535
|
afterCheck();
|
|
334
536
|
}
|
|
@@ -339,17 +541,20 @@ export function TreeView(props) {
|
|
|
339
541
|
afterCheck();
|
|
340
542
|
}
|
|
341
543
|
function toggleExpand(node) {
|
|
342
|
-
if (node
|
|
544
|
+
if (!store.canExpand(node))
|
|
343
545
|
return;
|
|
344
546
|
const edit = editingRef.current;
|
|
345
547
|
if (edit && node.expanded && isInside(store.get(edit.key), node)) {
|
|
346
548
|
void runAfterEdit(() => toggleExpand(node));
|
|
347
549
|
return;
|
|
348
550
|
}
|
|
551
|
+
// Asked before it opens, so a page the cache has at once opens with its rows in one move.
|
|
552
|
+
if (!node.expanded)
|
|
553
|
+
ensureLoaded(node);
|
|
349
554
|
const change = node.expanded ? store.collapse(node) : store.expand(node);
|
|
350
555
|
setMotion(change ? (node.expanded ? openMotion(change) : closeMotion(change)) : null);
|
|
351
556
|
// Focus inside a collapsed item moves up to it.
|
|
352
|
-
const focused = focusKey === null ? undefined : store.
|
|
557
|
+
const focused = focusKey === null ? undefined : store.find(focusKey);
|
|
353
558
|
if (focused && !store.isVisible(focused) && hadFocusRef.current)
|
|
354
559
|
focusNode(node);
|
|
355
560
|
bump();
|
|
@@ -372,13 +577,178 @@ export function TreeView(props) {
|
|
|
372
577
|
}
|
|
373
578
|
bump();
|
|
374
579
|
}
|
|
580
|
+
// Children from a server (loadChildren)
|
|
581
|
+
/** The parent an answer is for, found again by its key, since the tree may have been built again meanwhile; `undefined` when it is gone. */
|
|
582
|
+
function parentByKey(parentKey) {
|
|
583
|
+
return parentKey === null ? null : store.get(parentKey);
|
|
584
|
+
}
|
|
585
|
+
function underName(parent) {
|
|
586
|
+
return parent ? ` under ${parent.label || "(no name)"}` : "";
|
|
587
|
+
}
|
|
588
|
+
function pageSize() {
|
|
589
|
+
return Math.max(1, Math.floor(childPageSize) || CHILD_PAGE_SIZE);
|
|
590
|
+
}
|
|
591
|
+
/** One page: a fresh one the cache keeps (at once), the same request already on its way, or a new one from the loader. */
|
|
592
|
+
function fetchPage(parent, skip, take) {
|
|
593
|
+
const load = loadRef.current;
|
|
594
|
+
const ask = (signal) => load({ parent: parent ? parent.item : null, parentValue: parent ? parent.value : null, skip, take, signal });
|
|
595
|
+
if (cacheKey) {
|
|
596
|
+
const key = treePageKey(cacheKey, parent ? parent.value : null, skip, take);
|
|
597
|
+
return treePages.get(key, ask, cacheLifetime);
|
|
598
|
+
}
|
|
599
|
+
abortRef.current ??= new AbortController();
|
|
600
|
+
return ask(abortRef.current.signal);
|
|
601
|
+
}
|
|
602
|
+
/** Asks for a parent's next page (`null`: the top level): the first when nothing is loaded, else the one after the last. */
|
|
603
|
+
function loadNext(parent) {
|
|
604
|
+
const pages = store.pagesOf(parent);
|
|
605
|
+
if (!loadRef.current || !pages || pages.status === "loading" || (pages.status === "ready" && !pages.hasMore))
|
|
606
|
+
return;
|
|
607
|
+
const parentKey = parent ? parent.key : null;
|
|
608
|
+
const skip = pages.loaded;
|
|
609
|
+
const take = pageSize();
|
|
610
|
+
const first = skip === 0;
|
|
611
|
+
const request = store.beginLoad(parent);
|
|
612
|
+
const where = underName(parent);
|
|
613
|
+
const done = (page) => {
|
|
614
|
+
if (!mountedRef.current)
|
|
615
|
+
return;
|
|
616
|
+
const current = parentByKey(parentKey);
|
|
617
|
+
if (current === undefined)
|
|
618
|
+
return;
|
|
619
|
+
const result = store.receivePage(current, request, page ?? { items: [] }, take);
|
|
620
|
+
if (!result)
|
|
621
|
+
return;
|
|
622
|
+
if (result.change)
|
|
623
|
+
setMotion(openMotion(result.change));
|
|
624
|
+
followStatusFocus(current, result.added);
|
|
625
|
+
revealPending();
|
|
626
|
+
const now = store.pagesOf(current);
|
|
627
|
+
const count = result.added.length;
|
|
628
|
+
const of = now?.total !== undefined ? ` (${now.loaded.toLocaleString()} of ${now.total.toLocaleString()})` : "";
|
|
629
|
+
setAnnouncement(count === 0 && first ? `No items${where}.` : `${count.toLocaleString()} ${count === 1 ? "item" : "items"} loaded${where}${of}.`);
|
|
630
|
+
bump();
|
|
631
|
+
};
|
|
632
|
+
const fail = () => {
|
|
633
|
+
if (!mountedRef.current)
|
|
634
|
+
return;
|
|
635
|
+
const current = parentByKey(parentKey);
|
|
636
|
+
if (current === undefined || !store.failLoad(current, request))
|
|
637
|
+
return;
|
|
638
|
+
setAnnouncement(`Could not load ${first ? "the" : "more"} items${where}.`);
|
|
639
|
+
bump();
|
|
640
|
+
};
|
|
641
|
+
let answer;
|
|
642
|
+
try {
|
|
643
|
+
answer = fetchPage(parent, skip, take);
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
fail();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (!isPromiseLike(answer)) {
|
|
650
|
+
done(answer);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
setAnnouncement(`Loading ${first ? "the" : "more"} items${where}…`);
|
|
654
|
+
bump();
|
|
655
|
+
answer.then(done, fail);
|
|
656
|
+
}
|
|
657
|
+
/** Loads again, after a refresh, the pages a parent had (`null`: the top level), and puts them in place of the old ones. */
|
|
658
|
+
function reloadPages(parent) {
|
|
659
|
+
const pages = store.pagesOf(parent);
|
|
660
|
+
if (!loadRef.current || !pages || pages.status === "loading")
|
|
661
|
+
return;
|
|
662
|
+
const parentKey = parent ? parent.key : null;
|
|
663
|
+
const take = pageSize();
|
|
664
|
+
const count = Math.max(1, Math.ceil(pages.loaded / take));
|
|
665
|
+
const request = store.beginLoad(parent);
|
|
666
|
+
const where = underName(parent);
|
|
667
|
+
const done = (answers) => {
|
|
668
|
+
if (!mountedRef.current)
|
|
669
|
+
return;
|
|
670
|
+
const current = parentByKey(parentKey);
|
|
671
|
+
if (current === undefined || !store.replacePages(current, request, answers, take))
|
|
672
|
+
return;
|
|
673
|
+
setMotion(null);
|
|
674
|
+
followStatusFocus(current, []);
|
|
675
|
+
revealPending();
|
|
676
|
+
setAnnouncement(`Items loaded again${where}.`);
|
|
677
|
+
bump();
|
|
678
|
+
};
|
|
679
|
+
const fail = () => {
|
|
680
|
+
if (!mountedRef.current)
|
|
681
|
+
return;
|
|
682
|
+
const current = parentByKey(parentKey);
|
|
683
|
+
if (current === undefined || !store.failLoad(current, request))
|
|
684
|
+
return;
|
|
685
|
+
setAnnouncement(`Could not load the items${where}.`);
|
|
686
|
+
bump();
|
|
687
|
+
};
|
|
688
|
+
let answers;
|
|
689
|
+
try {
|
|
690
|
+
answers = Array.from({ length: count }, (_, index) => fetchPage(parent, index * take, take));
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
fail();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (!answers.some(answer => isPromiseLike(answer))) {
|
|
697
|
+
done(answers);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
bump();
|
|
701
|
+
Promise.all(answers).then(done, fail);
|
|
702
|
+
}
|
|
703
|
+
/** What an open parent needs from the loader: its first page, a first page that failed asked again, or its pages again after a refresh. */
|
|
704
|
+
function ensureLoaded(parent) {
|
|
705
|
+
const pages = store.pagesOf(parent);
|
|
706
|
+
if (!pages || pages.status === "loading")
|
|
707
|
+
return;
|
|
708
|
+
if (pages.stale)
|
|
709
|
+
reloadPages(parent);
|
|
710
|
+
else if (pages.status === "idle" || (pages.status === "failed" && pages.loaded === 0))
|
|
711
|
+
loadNext(parent);
|
|
712
|
+
}
|
|
713
|
+
/** Enter, Space, or a click on a status row: Load more asks for the next page, and a failed row asks again. */
|
|
714
|
+
function pressStatus(row) {
|
|
715
|
+
if (disabled)
|
|
716
|
+
return;
|
|
717
|
+
const parent = row.parent;
|
|
718
|
+
const pages = store.pagesOf(parent);
|
|
719
|
+
if (row.status === "more")
|
|
720
|
+
loadNext(parent);
|
|
721
|
+
else if (row.status === "error") {
|
|
722
|
+
if (pages?.stale)
|
|
723
|
+
reloadPages(parent);
|
|
724
|
+
else
|
|
725
|
+
loadNext(parent);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Focus on a parent's status row (Load more, Retry, or loading) when its page came moves to the first item it brought,
|
|
730
|
+
* or back to the parent when nothing came and the row is gone. Focus anywhere else stays where it is.
|
|
731
|
+
*/
|
|
732
|
+
function followStatusFocus(parent, added) {
|
|
733
|
+
const key = treeStatusKey(parent ? parent.key : null);
|
|
734
|
+
if (focusKeyRef.current !== key)
|
|
735
|
+
return;
|
|
736
|
+
const status = store.find(key);
|
|
737
|
+
const next = added[0] ?? (status && store.isVisible(status) ? undefined : (parent ?? store.rows[store.rows.length - 1]));
|
|
738
|
+
if (!next)
|
|
739
|
+
return;
|
|
740
|
+
if (hadFocusRef.current)
|
|
741
|
+
focusNode(next);
|
|
742
|
+
else
|
|
743
|
+
setFocusKey(next.key);
|
|
744
|
+
}
|
|
375
745
|
// Adding, renaming, deleting
|
|
376
746
|
function allowed(action, node) {
|
|
377
747
|
if (blocked)
|
|
378
748
|
return false;
|
|
379
749
|
if ((action === "create" && !creatable) || (action === "update" && !editable) || (action === "delete" && !deletable))
|
|
380
750
|
return false;
|
|
381
|
-
if (node && (node.disabledPath || node.draft))
|
|
751
|
+
if (node && (node.disabledPath || node.draft || node.status))
|
|
382
752
|
return false;
|
|
383
753
|
return canChange ? canChange(action, node ? node.item : null) : true;
|
|
384
754
|
}
|
|
@@ -424,6 +794,9 @@ export function TreeView(props) {
|
|
|
424
794
|
const before = store.rows;
|
|
425
795
|
const draft = { [valueKey]: newTreeValue(), [parentKey]: parent ? parent.value : null, [labelKey]: "" };
|
|
426
796
|
const node = store.insert(parent, draft, { draft: true });
|
|
797
|
+
// A lazy parent opened by the new item shows its own children too, after it.
|
|
798
|
+
if (parent && lazy)
|
|
799
|
+
ensureLoaded(parent);
|
|
427
800
|
const index = store.indexOf(node);
|
|
428
801
|
setMotion(openMotion(wasOpen ? { at: index, count: 1 } : { at: parentIndex + 1, count: store.rows.length - before.length }));
|
|
429
802
|
openEditor({ key: node.key, mode: "create" }, "");
|
|
@@ -480,6 +853,9 @@ export function TreeView(props) {
|
|
|
480
853
|
const edit = editingRef.current;
|
|
481
854
|
if (!edit)
|
|
482
855
|
return true;
|
|
856
|
+
// The row scrolled out of the window: the box keeps its text, and comes back focused with its row.
|
|
857
|
+
if (via === "blur" && restoringRef.current)
|
|
858
|
+
return false;
|
|
483
859
|
if (savingRef.current)
|
|
484
860
|
return false;
|
|
485
861
|
const node = store.get(edit.key);
|
|
@@ -603,9 +979,11 @@ export function TreeView(props) {
|
|
|
603
979
|
}
|
|
604
980
|
// Pointer and keys
|
|
605
981
|
function onViewportPointerDown(event) {
|
|
606
|
-
// The
|
|
982
|
+
// The user moves on: an item waiting to be revealed is not brought into view under them later.
|
|
983
|
+
pendingRevealRef.current = null;
|
|
984
|
+
// The context menu opens on the row that was pressed, or at the top level on empty space or a status row.
|
|
607
985
|
const node = nodeOfTarget(event.target);
|
|
608
|
-
setContextKey(node ? node.key : null);
|
|
986
|
+
setContextKey(node && !node.status ? node.key : null);
|
|
609
987
|
}
|
|
610
988
|
function onViewportClick(event) {
|
|
611
989
|
const target = event.target;
|
|
@@ -617,6 +995,10 @@ export function TreeView(props) {
|
|
|
617
995
|
setFocusKey(node.key);
|
|
618
996
|
if (disabled)
|
|
619
997
|
return;
|
|
998
|
+
if (node.status) {
|
|
999
|
+
pressStatus(node);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
620
1002
|
if (target.closest(".nx-tree__toggle")) {
|
|
621
1003
|
toggleExpand(node);
|
|
622
1004
|
return;
|
|
@@ -632,7 +1014,7 @@ export function TreeView(props) {
|
|
|
632
1014
|
else
|
|
633
1015
|
onItemClick(node.item);
|
|
634
1016
|
}
|
|
635
|
-
else if (node
|
|
1017
|
+
else if (store.canExpand(node)) {
|
|
636
1018
|
toggleExpand(node);
|
|
637
1019
|
}
|
|
638
1020
|
}
|
|
@@ -640,10 +1022,12 @@ export function TreeView(props) {
|
|
|
640
1022
|
if (event.defaultPrevented || event.target.closest(".nx-tree__edit"))
|
|
641
1023
|
return;
|
|
642
1024
|
setContextKey(undefined);
|
|
1025
|
+
pendingRevealRef.current = null;
|
|
643
1026
|
if (event.key === "Escape" && noteRowRef.current) {
|
|
644
1027
|
noteRowRef.current = null;
|
|
645
1028
|
readOnlyNotice.hide();
|
|
646
1029
|
lockedNotice.hide();
|
|
1030
|
+
partialNotice.hide();
|
|
647
1031
|
event.preventDefault();
|
|
648
1032
|
return;
|
|
649
1033
|
}
|
|
@@ -654,6 +1038,12 @@ export function TreeView(props) {
|
|
|
654
1038
|
const target = rowElement(node.key) ?? event.currentTarget;
|
|
655
1039
|
const page = Math.max(1, Math.floor((viewportRef.current?.clientHeight ?? rowHeight) / rowHeight) - 1);
|
|
656
1040
|
const { key, ctrlKey, metaKey, altKey } = event;
|
|
1041
|
+
// A status row is a button: Enter or Space presses Load more or Retry.
|
|
1042
|
+
if (node.status && (key === "Enter" || (key === " " && !ctrlKey && !metaKey))) {
|
|
1043
|
+
pressStatus(node);
|
|
1044
|
+
event.preventDefault();
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
657
1047
|
let handled = true;
|
|
658
1048
|
if (key === "ArrowDown")
|
|
659
1049
|
moveTo(index + 1);
|
|
@@ -668,7 +1058,7 @@ export function TreeView(props) {
|
|
|
668
1058
|
else if (key === "PageUp")
|
|
669
1059
|
moveTo(index - page);
|
|
670
1060
|
else if (key === "ArrowRight") {
|
|
671
|
-
if (node
|
|
1061
|
+
if (!store.canExpand(node))
|
|
672
1062
|
handled = false;
|
|
673
1063
|
else if (!node.expanded)
|
|
674
1064
|
toggleExpand(node);
|
|
@@ -690,7 +1080,7 @@ export function TreeView(props) {
|
|
|
690
1080
|
else if (key === "Enter") {
|
|
691
1081
|
if (onItemClick && !checkable && !node.disabledPath)
|
|
692
1082
|
onItemClick(node.item);
|
|
693
|
-
else if (node
|
|
1083
|
+
else if (store.canExpand(node))
|
|
694
1084
|
toggleExpand(node);
|
|
695
1085
|
else if (checkable)
|
|
696
1086
|
toggleCheck(node, target);
|
|
@@ -701,12 +1091,18 @@ export function TreeView(props) {
|
|
|
701
1091
|
setMotion(null);
|
|
702
1092
|
bump();
|
|
703
1093
|
}
|
|
704
|
-
else if (key === "F2")
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
+
}
|
|
710
1106
|
else if (key.length === 1 && !ctrlKey && !metaKey && !altKey && key !== " ") {
|
|
711
1107
|
const now = performance.now();
|
|
712
1108
|
const typed = typeaheadRef.current;
|
|
@@ -752,7 +1148,20 @@ export function TreeView(props) {
|
|
|
752
1148
|
contextItems.push({ value: "delete", label: "Delete", icon: _jsx(InlineIcon, { icon: iconTrash }), shortcut: "Delete", tone: "danger", disabled: !allowed("delete", contextNode) });
|
|
753
1149
|
}
|
|
754
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
|
+
}
|
|
755
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
|
+
}
|
|
756
1165
|
if (action === "create")
|
|
757
1166
|
startCreate(contextNode ?? null);
|
|
758
1167
|
else if (action === "update" && contextNode)
|
|
@@ -772,6 +1181,8 @@ export function TreeView(props) {
|
|
|
772
1181
|
if (!node)
|
|
773
1182
|
return;
|
|
774
1183
|
store.reveal(node);
|
|
1184
|
+
if (!node.expanded)
|
|
1185
|
+
ensureLoaded(node);
|
|
775
1186
|
store.expand(node);
|
|
776
1187
|
setMotion(null);
|
|
777
1188
|
bump();
|
|
@@ -785,8 +1196,12 @@ export function TreeView(props) {
|
|
|
785
1196
|
},
|
|
786
1197
|
reveal: (revealValue, focus = true) => {
|
|
787
1198
|
const node = store.get(revealValue);
|
|
788
|
-
if (!node)
|
|
1199
|
+
if (!node) {
|
|
1200
|
+
const key = treeKey(revealValue);
|
|
1201
|
+
pendingRevealRef.current = key === null ? null : { key, focus };
|
|
789
1202
|
return;
|
|
1203
|
+
}
|
|
1204
|
+
pendingRevealRef.current = null;
|
|
790
1205
|
if (focus) {
|
|
791
1206
|
focusNode(node);
|
|
792
1207
|
return;
|
|
@@ -808,6 +1223,10 @@ export function TreeView(props) {
|
|
|
808
1223
|
afterCheck();
|
|
809
1224
|
},
|
|
810
1225
|
getItems: () => store.items(),
|
|
1226
|
+
getItem: itemValue => {
|
|
1227
|
+
const node = store.get(itemValue);
|
|
1228
|
+
return node && !node.draft ? node.item : undefined;
|
|
1229
|
+
},
|
|
811
1230
|
startCreate: parentValue => startCreate(parentValue === null ? null : (store.get(parentValue) ?? null)),
|
|
812
1231
|
startRename: renameValue => {
|
|
813
1232
|
const node = store.get(renameValue);
|
|
@@ -823,15 +1242,64 @@ export function TreeView(props) {
|
|
|
823
1242
|
if (node)
|
|
824
1243
|
focusNode(node);
|
|
825
1244
|
},
|
|
1245
|
+
refresh: refreshValue => {
|
|
1246
|
+
if (!lazy)
|
|
1247
|
+
return;
|
|
1248
|
+
const node = refreshValue === undefined ? null : store.get(refreshValue);
|
|
1249
|
+
if (refreshValue !== undefined && !node)
|
|
1250
|
+
return;
|
|
1251
|
+
if (cacheKey)
|
|
1252
|
+
expireTreePages(cacheKey, node ? store.subtree(node).map(each => each.value) : undefined);
|
|
1253
|
+
store.markStale(node);
|
|
1254
|
+
setMotion(null);
|
|
1255
|
+
bump();
|
|
1256
|
+
},
|
|
1257
|
+
apply: (changes, rules) => applyChanges(changes, rules),
|
|
826
1258
|
}));
|
|
827
1259
|
// Rendering
|
|
828
1260
|
const showCounts = checkable && cascade && counts;
|
|
829
1261
|
const tabbable = focusedNode() ?? rows[0];
|
|
830
1262
|
const rendered = new Set();
|
|
1263
|
+
/** How many siblings a row has: all of them on the server when the loader said, loaded or not. */
|
|
1264
|
+
function setSize(node) {
|
|
1265
|
+
const loaded = node.parent ? node.parent.children.length : store.roots.length;
|
|
1266
|
+
const total = store.pagesOf(node.parent)?.total;
|
|
1267
|
+
return total === undefined ? loaded : Math.max(total, loaded);
|
|
1268
|
+
}
|
|
1269
|
+
/** A row that is not an item: a parent's page on its way, a page that failed (Retry), or Load more. The whole row is the button. */
|
|
1270
|
+
function renderStatusRow(row, ghost) {
|
|
1271
|
+
if (!ghost)
|
|
1272
|
+
rendered.add(row.key);
|
|
1273
|
+
const parent = row.parent;
|
|
1274
|
+
const pages = store.pagesOf(parent);
|
|
1275
|
+
const first = !pages || pages.loaded === 0;
|
|
1276
|
+
const where = underName(parent);
|
|
1277
|
+
const left = row.status === "more" && pages?.total !== undefined ? Math.max(0, pages.total - pages.loaded) : undefined;
|
|
1278
|
+
const leftText = left === undefined ? null : `${left.toLocaleString()} left`;
|
|
1279
|
+
let text;
|
|
1280
|
+
let name;
|
|
1281
|
+
if (row.status === "loading") {
|
|
1282
|
+
text = first ? "Loading…" : "Loading more…";
|
|
1283
|
+
name = `Loading ${first ? "the" : "more"} items${where}`;
|
|
1284
|
+
}
|
|
1285
|
+
else if (row.status === "error") {
|
|
1286
|
+
text = first ? "Could not load the items." : "Could not load more items.";
|
|
1287
|
+
name = `Could not load ${first ? "the" : "more"} items${where}. Retry`;
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
text = "Load more";
|
|
1291
|
+
name = `Load more${where}${leftText ? `, ${leftText}` : ""}`;
|
|
1292
|
+
}
|
|
1293
|
+
return (_jsxs("div", { role: ghost ? undefined : "treeitem", className: cx("nx-tree__row nx-tree__row--status", row.status === "loading" && "nx-tree__row--loading", row.status === "error" && "nx-tree__row--error", row.status === "more" && "nx-tree__row--more"), style: { "--nx-tree-depth": row.depth }, "data-status-of": ghost ? undefined : parent ? parent.key : "", "data-uid": ghost ? undefined : row.uid, tabIndex: ghost ? undefined : !disabled && tabbable === row ? 0 : -1, "aria-level": ghost ? undefined : row.depth + 1, "aria-busy": ghost ? undefined : row.status === "loading" || undefined, "aria-label": ghost ? undefined : name, children: [_jsx("span", { className: "nx-tree__toggle", "aria-hidden": "true" }), _jsx(InlineIcon, { icon: row.status === "loading" ? iconLoader2 : row.status === "error" ? iconAlertCircle : iconDots, className: cx("nx-tree__status-icon", row.status === "loading" && "nx-spin"), "aria-hidden": "true" }), _jsxs("span", { className: "nx-tree__text", "aria-hidden": "true", children: [_jsx("span", { className: "nx-tree__status-text", children: text }), leftText && _jsx("span", { className: "nx-tree__description", children: leftText })] }), row.status === "error" && (_jsx("span", { className: "nx-btn nx-btn--secondary nx-btn--sm nx-tree__retry", "aria-hidden": "true", children: _jsx("span", { className: "nx-btn__label", children: "Retry" }) }))] }, row.uid));
|
|
1294
|
+
}
|
|
831
1295
|
function renderRow(node, ghost = false) {
|
|
1296
|
+
if (node.status)
|
|
1297
|
+
return renderStatusRow(node, ghost);
|
|
832
1298
|
if (!ghost)
|
|
833
1299
|
rendered.add(node.key);
|
|
834
1300
|
const hasChildren = node.children.length > 0;
|
|
1301
|
+
const expandable = hasChildren || store.canExpand(node);
|
|
1302
|
+
const childrenLoading = node.pages?.status === "loading";
|
|
835
1303
|
const state = checkable ? store.state(node) : null;
|
|
836
1304
|
const edit = !ghost && editing?.key === node.key ? editing : null;
|
|
837
1305
|
const busy = busyKeys.has(node.key);
|
|
@@ -852,11 +1320,16 @@ export function TreeView(props) {
|
|
|
852
1320
|
if (allowed("delete", node)) {
|
|
853
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"));
|
|
854
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
|
+
}
|
|
855
1328
|
}
|
|
856
|
-
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 : (node
|
|
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 => {
|
|
857
1330
|
if (input)
|
|
858
1331
|
input.indeterminate = state === "mixed";
|
|
859
|
-
} }) })), 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()] })), 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));
|
|
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));
|
|
860
1333
|
}
|
|
861
1334
|
const end = Math.min(range.end, total);
|
|
862
1335
|
const start = Math.min(range.start, end);
|
|
@@ -889,15 +1362,25 @@ export function TreeView(props) {
|
|
|
889
1362
|
}
|
|
890
1363
|
const allState = checkable ? store.allState() : null;
|
|
891
1364
|
const tally = store.counts();
|
|
892
|
-
const hasParents = store.roots.some(root => root.children.length > 0);
|
|
1365
|
+
const hasParents = store.roots.some(root => root.children.length > 0 || root.expanded);
|
|
893
1366
|
const hasLabel = hasContent(label);
|
|
894
1367
|
const shownMessage = message ?? (hasContent(error) ? error : null);
|
|
895
|
-
|
|
896
|
-
|
|
1368
|
+
// A lazy top level on its way shows the same placeholder rows as `loading`; one that failed shows its row with Retry.
|
|
1369
|
+
const rootPages = store.rootPages;
|
|
1370
|
+
const rootWaiting = store.size === 0 && rootPages !== null && (rootPages.status === "idle" || rootPages.status === "loading");
|
|
1371
|
+
const waiting = loading || rootWaiting;
|
|
1372
|
+
const empty = total === 0 || rootWaiting;
|
|
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
|
|
897
1374
|
? "Loading…"
|
|
898
1375
|
: checkable
|
|
899
1376
|
? `${tally.checked.toLocaleString()} of ${tally.total.toLocaleString()} selected`
|
|
900
|
-
: `${store.size.toLocaleString()} ${store.size === 1 ? "item" : "items"}`, !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 ? (
|
|
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,
|
|
1378
|
+
// As tall as all of its rows at every moment of a commit, not only once it has finished. A scroll that
|
|
1379
|
+
// moves the window makes React take the rows that leave it out before it puts the new ones in and sets
|
|
1380
|
+
// this padding, and a layout in between - Chrome lays the page out inside the removal of a focused row,
|
|
1381
|
+
// and so may any focusout listener that measures - would find the list short of the scroll position and
|
|
1382
|
+
// clamp it: a jump to the end came back up to 1,120px (nexus.web, 2026-10-09).
|
|
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] }));
|
|
901
1384
|
}
|
|
902
1385
|
/** The name box of a row being added or renamed. Enter saves, Esc cancels, and leaving it saves. */
|
|
903
1386
|
function TreeEditor({ initial, select, maxLength, saving, label, invalid, describedBy, onText, onCommit, onCancel }) {
|