nexus-shared 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +442 -0
- package/README.md +1123 -1000
- package/dist/Client.Index.d.ts +6 -0
- package/dist/Client.Index.js +6 -0
- package/dist/Components/Chats/ChatParts.d.ts +2 -2
- package/dist/Components/Chats/ChatParts.js +3 -7
- package/dist/Components/Chats/ChatThread.js +3 -3
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/Menu.js +21 -9
- package/dist/Components/Documents/SplitButton.d.ts +3 -1
- package/dist/Components/Documents/SplitButton.js +7 -5
- package/dist/Components/Documents/TabButtons.d.ts +5 -2
- package/dist/Components/Documents/TabButtons.js +2 -2
- package/dist/Components/Forms/ApiForm.d.ts +1 -1
- package/dist/Components/Forms/ApiForm.js +1 -1
- package/dist/Components/Forms/Crud.js +486 -206
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +38 -5
- package/dist/Components/Forms/ExcelImport.d.ts +23 -7
- package/dist/Components/Forms/ExcelImport.js +189 -60
- package/dist/Components/Forms/Form.js +9 -14
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/CodeBox.d.ts +69 -0
- package/dist/Components/Inputs/CodeBox.js +316 -0
- package/dist/Components/Inputs/DatePicker.js +4 -1
- package/dist/Components/Inputs/DateRangePicker.js +4 -1
- package/dist/Components/Inputs/DateTimePicker.js +4 -1
- package/dist/Components/Inputs/Dropdown.js +4 -1
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/InputRenderer.js +3 -0
- package/dist/Components/Inputs/NumberBox.js +26 -2
- package/dist/Components/Inputs/RowsInput.js +2 -2
- package/dist/Components/Inputs/Slider.js +4 -1
- package/dist/Components/Inputs/TabularForm.js +42 -10
- package/dist/Components/Inputs/TextArea.js +4 -1
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TimePanel.js +4 -3
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +55 -5
- package/dist/Components/Inputs/TreeView.js +372 -23
- package/dist/Components/Layouts/Popup.js +1 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
- package/dist/Components/Layouts/ThemePicker.js +89 -6
- package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
- package/dist/Components/Layouts/ThemeScript.js +4 -2
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
- package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
- package/dist/Components/Viewers/Avatar.d.ts +21 -0
- package/dist/Components/Viewers/Avatar.js +21 -0
- package/dist/Components/Viewers/DataTable.js +252 -62
- package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
- package/dist/Components/Viewers/DataTableColumns.js +25 -68
- package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
- package/dist/Components/Viewers/DataTableFilters.js +68 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
- package/dist/Components/Viewers/DataTableParts.js +42 -13
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- package/dist/Helpers/ApiModules.d.ts +3 -34
- package/dist/Helpers/ApiModules.js +3 -3
- package/dist/Helpers/ApiRoutes.d.ts +68 -26
- package/dist/Helpers/ApiRoutes.js +31 -27
- package/dist/Helpers/ArrayHelpers.d.ts +0 -0
- package/dist/Helpers/ArrayHelpers.js +2 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +101 -8
- package/dist/Helpers/CrudBackend.js +172 -11
- package/dist/Helpers/CrudColumns.d.ts +51 -0
- package/dist/Helpers/CrudColumns.js +69 -0
- package/dist/Helpers/CrudHelpers.d.ts +50 -5
- package/dist/Helpers/CrudHelpers.js +109 -12
- package/dist/Helpers/ExcelBackend.d.ts +3 -3
- package/dist/Helpers/ExcelBackend.js +8 -2
- package/dist/Helpers/ExcelHelpers.d.ts +36 -8
- package/dist/Helpers/ExcelHelpers.js +55 -13
- package/dist/Helpers/FormStore.d.ts +9 -5
- package/dist/Helpers/FormStore.js +25 -5
- package/dist/Helpers/InputParamsHelpers.js +8 -0
- package/dist/Helpers/MessageBuilder.js +3 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/NumberHelpers.d.ts +7 -1
- package/dist/Helpers/NumberHelpers.js +41 -3
- package/dist/Helpers/PopoverHelpers.d.ts +17 -5
- package/dist/Helpers/PopoverHelpers.js +52 -17
- package/dist/Helpers/PopoverHooks.d.ts +24 -0
- package/dist/Helpers/PopoverHooks.js +62 -0
- package/dist/Helpers/RequestCache.d.ts +35 -0
- package/dist/Helpers/RequestCache.js +113 -0
- package/dist/Helpers/RowsStore.d.ts +7 -2
- package/dist/Helpers/RowsStore.js +15 -3
- package/dist/Helpers/TableFilters.d.ts +25 -0
- package/dist/Helpers/TableFilters.js +162 -0
- package/dist/Helpers/TableHelpers.d.ts +7 -4
- package/dist/Helpers/TableHelpers.js +15 -6
- package/dist/Helpers/TableJoins.d.ts +70 -0
- package/dist/Helpers/TableJoins.js +250 -0
- package/dist/Helpers/TableStore.d.ts +16 -3
- package/dist/Helpers/TableStore.js +25 -5
- package/dist/Helpers/ThemeHelpers.d.ts +21 -4
- package/dist/Helpers/ThemeHelpers.js +46 -9
- package/dist/Helpers/TreeStore.d.ts +225 -11
- package/dist/Helpers/TreeStore.js +683 -90
- package/dist/Helpers/UserPreferences.d.ts +22 -0
- package/dist/Helpers/UserPreferences.js +116 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
- package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
- package/dist/Interfaces/FormInterfaces.d.ts +37 -6
- package/dist/Interfaces/InputInterfaces.d.ts +24 -0
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
- package/dist/Interfaces/PreferenceInterfaces.js +25 -0
- package/dist/Interfaces/TableInterfaces.d.ts +145 -16
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- 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.d.ts +3 -1
- package/dist/Services/ExcelApi.js +1 -1
- package/dist/Services/JoinSources.d.ts +48 -0
- package/dist/Services/JoinSources.js +74 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +6 -0
- package/dist/Shared.Index.js +6 -0
- package/package.json +4 -2
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +51 -4
- package/src/Styles/Nexus.Button.css +27 -0
- package/src/Styles/Nexus.Calendar.css +4 -6
- package/src/Styles/Nexus.Chat.css +5 -51
- package/src/Styles/Nexus.Choice.css +4 -6
- package/src/Styles/Nexus.Crud.css +16 -9
- package/src/Styles/Nexus.Dropdown.css +12 -61
- package/src/Styles/Nexus.Editor.css +4 -6
- package/src/Styles/Nexus.Excel.css +34 -15
- package/src/Styles/Nexus.Form.css +25 -67
- package/src/Styles/Nexus.Index.css +1 -0
- package/src/Styles/Nexus.Input.css +794 -637
- package/src/Styles/Nexus.Markdown.css +0 -28
- package/src/Styles/Nexus.Menu.css +8 -30
- package/src/Styles/Nexus.Popup.css +22 -29
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +51 -10
- package/src/Styles/Nexus.Slider.css +3 -5
- package/src/Styles/Nexus.Tab.Buttons.css +20 -7
- package/src/Styles/Nexus.Table.css +1387 -1296
- package/src/Styles/Nexus.Theme.Picker.css +342 -232
- package/src/Styles/Nexus.Time.css +9 -12
- package/src/Styles/Nexus.Toast.css +6 -8
- package/src/Styles/Nexus.Tokens.css +17 -0
- package/src/Styles/Nexus.Tree.css +76 -11
|
@@ -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, TREE_DEFAULT_KEYS, 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, 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;
|
|
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);
|
|
@@ -118,6 +149,7 @@ export function TreeView(props) {
|
|
|
118
149
|
};
|
|
119
150
|
const readOnlyNotice = useReadOnlyNotice(readOnly && !disabled, viewportRef, readOnlyMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
|
|
120
151
|
const lockedNotice = useReadOnlyNotice(!blocked, viewportRef, lockedMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
|
|
152
|
+
const partialNotice = useReadOnlyNotice(!blocked && lazy && checkable && cascade, viewportRef, partialMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
|
|
121
153
|
// The row a note points at: when it leaves the page (scrolled out of the window), the note goes too.
|
|
122
154
|
const noteRowRef = useRef(null);
|
|
123
155
|
function showNote(notice, target) {
|
|
@@ -129,15 +161,20 @@ export function TreeView(props) {
|
|
|
129
161
|
// The value for a form, read again only when something could have changed it.
|
|
130
162
|
const formValues = useMemo(() => (name ? store.value() : []), [name, store, version, items, value]);
|
|
131
163
|
function rowElement(key) {
|
|
132
|
-
const node = key === null ? undefined : store.
|
|
164
|
+
const node = key === null ? undefined : store.find(key);
|
|
133
165
|
return node ? (viewportRef.current?.querySelector(`[data-uid="${node.uid}"]`) ?? null) : null;
|
|
134
166
|
}
|
|
167
|
+
/** The row an event happened in: an item's, or a status row (loading, failed, Load more). */
|
|
135
168
|
function nodeOfTarget(target) {
|
|
136
|
-
const row = target instanceof Element ? target.closest(".nx-tree__row[data-key]") : null;
|
|
137
|
-
|
|
169
|
+
const row = target instanceof Element ? target.closest(".nx-tree__row[data-key], .nx-tree__row[data-status-of]") : null;
|
|
170
|
+
if (!row)
|
|
171
|
+
return undefined;
|
|
172
|
+
if (row.dataset.key !== undefined)
|
|
173
|
+
return store.get(row.dataset.key);
|
|
174
|
+
return row.dataset.statusOf === undefined ? undefined : store.find(treeStatusKey(row.dataset.statusOf || null));
|
|
138
175
|
}
|
|
139
176
|
function focusedNode() {
|
|
140
|
-
const node = focusKey === null ? undefined : store.
|
|
177
|
+
const node = focusKey === null ? undefined : store.find(focusKey);
|
|
141
178
|
return node && store.isVisible(node) ? node : undefined;
|
|
142
179
|
}
|
|
143
180
|
// Scrolling and focus
|
|
@@ -152,9 +189,33 @@ export function TreeView(props) {
|
|
|
152
189
|
const next = { start: Math.max(0, first - OVERSCAN), end: last + OVERSCAN };
|
|
153
190
|
if (next.start === current.start && next.end === current.end)
|
|
154
191
|
return;
|
|
192
|
+
releaseFocusLeaving(viewport, next);
|
|
155
193
|
rangeRef.current = next;
|
|
156
194
|
setRange(next);
|
|
157
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* A focused row that the new window leaves out hands focus to the viewport now, in the event that moves the window,
|
|
198
|
+
* rather than being taken out of the page still focused in the middle of React's commit. Chrome lays the page out
|
|
199
|
+
* inside the removal of a focused element, after blur and focusout - and so does any focusout listener that measures -
|
|
200
|
+
* 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
|
|
201
|
+
* looked short and the scroll position was clamped, or scroll anchoring moved it to keep a row in place (nexus.web,
|
|
202
|
+
* 2026-10-09: a jump to the end came back 160-1,120px after a client-side navigation). A name box being edited in
|
|
203
|
+
* the row lets go too, without saving, as it did when its row unmounted: it comes back focused with its row.
|
|
204
|
+
*/
|
|
205
|
+
function releaseFocusLeaving(viewport, next) {
|
|
206
|
+
const active = document.activeElement;
|
|
207
|
+
if (!(active instanceof HTMLElement) || active === viewport || !viewport.contains(active))
|
|
208
|
+
return;
|
|
209
|
+
const node = nodeOfTarget(active);
|
|
210
|
+
if (!node)
|
|
211
|
+
return;
|
|
212
|
+
const index = store.indexOf(node);
|
|
213
|
+
if (index >= next.start && index < next.end)
|
|
214
|
+
return;
|
|
215
|
+
restoringRef.current = true;
|
|
216
|
+
viewport.focus({ preventScroll: true });
|
|
217
|
+
restoringRef.current = false;
|
|
218
|
+
}
|
|
158
219
|
function scrollToIndex(index) {
|
|
159
220
|
const viewport = viewportRef.current;
|
|
160
221
|
if (!viewport || index < 0)
|
|
@@ -230,6 +291,7 @@ export function TreeView(props) {
|
|
|
230
291
|
noteRowRef.current = null;
|
|
231
292
|
readOnlyNotice.hide();
|
|
232
293
|
lockedNotice.hide();
|
|
294
|
+
partialNotice.hide();
|
|
233
295
|
});
|
|
234
296
|
// An open or close ends when its animation does, or at once when none runs (reduced motion).
|
|
235
297
|
useLayoutEffect(() => {
|
|
@@ -242,6 +304,44 @@ export function TreeView(props) {
|
|
|
242
304
|
}
|
|
243
305
|
return afterAnimation(group, motion.type === "open" ? "nx-tree-open" : "nx-tree-close", () => setMotion(null));
|
|
244
306
|
}, [motion]);
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
mountedRef.current = true;
|
|
309
|
+
return () => {
|
|
310
|
+
mountedRef.current = false;
|
|
311
|
+
const controller = abortRef.current;
|
|
312
|
+
// React's development double run sets the tree up again at once: abort only when it stays gone.
|
|
313
|
+
queueMicrotask(() => {
|
|
314
|
+
if (mountedRef.current)
|
|
315
|
+
return;
|
|
316
|
+
controller?.abort();
|
|
317
|
+
if (abortRef.current === controller)
|
|
318
|
+
abortRef.current = null;
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
}, []);
|
|
322
|
+
// Open parents with rows in view that need pages: the top level at first, a parent a refresh made stale, one whose
|
|
323
|
+
// request a refresh forgot. A stale parent out of view waits until it scrolls in, and a closed one until it opens; a
|
|
324
|
+
// failed one waits for Retry, so a server that is down is not asked again and again.
|
|
325
|
+
useLayoutEffect(() => {
|
|
326
|
+
if (!lazy)
|
|
327
|
+
return;
|
|
328
|
+
const wanted = new Set();
|
|
329
|
+
if (store.rootPages)
|
|
330
|
+
wanted.add(null);
|
|
331
|
+
for (const row of store.rows.slice(Math.min(range.start, store.rows.length), Math.min(range.end, store.rows.length))) {
|
|
332
|
+
for (let parent = row.parent; parent; parent = parent.parent)
|
|
333
|
+
wanted.add(parent);
|
|
334
|
+
}
|
|
335
|
+
for (const parent of wanted) {
|
|
336
|
+
const pages = store.pagesOf(parent);
|
|
337
|
+
if (!pages || pages.status === "loading" || pages.status === "failed" || (parent && !parent.expanded))
|
|
338
|
+
continue;
|
|
339
|
+
if (pages.stale)
|
|
340
|
+
reloadPages(parent);
|
|
341
|
+
else if (pages.status === "idle")
|
|
342
|
+
loadNext(parent);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
245
345
|
function onPanelFocus() {
|
|
246
346
|
hadFocusRef.current = true;
|
|
247
347
|
}
|
|
@@ -253,6 +353,7 @@ export function TreeView(props) {
|
|
|
253
353
|
hadFocusRef.current = false;
|
|
254
354
|
readOnlyNotice.hide();
|
|
255
355
|
lockedNotice.hide();
|
|
356
|
+
partialNotice.hide();
|
|
256
357
|
return;
|
|
257
358
|
}
|
|
258
359
|
// 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 +430,11 @@ export function TreeView(props) {
|
|
|
329
430
|
showNote(lockedNotice, target);
|
|
330
431
|
return;
|
|
331
432
|
}
|
|
433
|
+
// Its items are not all loaded: a check would stand for items the value cannot hold.
|
|
434
|
+
if (cascade && node.partial) {
|
|
435
|
+
showNote(partialNotice, target);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
332
438
|
if (store.toggle(node))
|
|
333
439
|
afterCheck();
|
|
334
440
|
}
|
|
@@ -339,17 +445,20 @@ export function TreeView(props) {
|
|
|
339
445
|
afterCheck();
|
|
340
446
|
}
|
|
341
447
|
function toggleExpand(node) {
|
|
342
|
-
if (node
|
|
448
|
+
if (!store.canExpand(node))
|
|
343
449
|
return;
|
|
344
450
|
const edit = editingRef.current;
|
|
345
451
|
if (edit && node.expanded && isInside(store.get(edit.key), node)) {
|
|
346
452
|
void runAfterEdit(() => toggleExpand(node));
|
|
347
453
|
return;
|
|
348
454
|
}
|
|
455
|
+
// Asked before it opens, so a page the cache has at once opens with its rows in one move.
|
|
456
|
+
if (!node.expanded)
|
|
457
|
+
ensureLoaded(node);
|
|
349
458
|
const change = node.expanded ? store.collapse(node) : store.expand(node);
|
|
350
459
|
setMotion(change ? (node.expanded ? openMotion(change) : closeMotion(change)) : null);
|
|
351
460
|
// Focus inside a collapsed item moves up to it.
|
|
352
|
-
const focused = focusKey === null ? undefined : store.
|
|
461
|
+
const focused = focusKey === null ? undefined : store.find(focusKey);
|
|
353
462
|
if (focused && !store.isVisible(focused) && hadFocusRef.current)
|
|
354
463
|
focusNode(node);
|
|
355
464
|
bump();
|
|
@@ -372,13 +481,176 @@ export function TreeView(props) {
|
|
|
372
481
|
}
|
|
373
482
|
bump();
|
|
374
483
|
}
|
|
484
|
+
// Children from a server (loadChildren)
|
|
485
|
+
/** 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. */
|
|
486
|
+
function parentByKey(parentKey) {
|
|
487
|
+
return parentKey === null ? null : store.get(parentKey);
|
|
488
|
+
}
|
|
489
|
+
function underName(parent) {
|
|
490
|
+
return parent ? ` under ${parent.label || "(no name)"}` : "";
|
|
491
|
+
}
|
|
492
|
+
function pageSize() {
|
|
493
|
+
return Math.max(1, Math.floor(childPageSize) || CHILD_PAGE_SIZE);
|
|
494
|
+
}
|
|
495
|
+
/** One page: a fresh one the cache keeps (at once), the same request already on its way, or a new one from the loader. */
|
|
496
|
+
function fetchPage(parent, skip, take) {
|
|
497
|
+
const load = loadRef.current;
|
|
498
|
+
const ask = (signal) => load({ parent: parent ? parent.item : null, parentValue: parent ? parent.value : null, skip, take, signal });
|
|
499
|
+
if (cacheKey) {
|
|
500
|
+
const key = treePageKey(cacheKey, parent ? parent.value : null, skip, take);
|
|
501
|
+
return treePages.get(key, ask, cacheLifetime);
|
|
502
|
+
}
|
|
503
|
+
abortRef.current ??= new AbortController();
|
|
504
|
+
return ask(abortRef.current.signal);
|
|
505
|
+
}
|
|
506
|
+
/** Asks for a parent's next page (`null`: the top level): the first when nothing is loaded, else the one after the last. */
|
|
507
|
+
function loadNext(parent) {
|
|
508
|
+
const pages = store.pagesOf(parent);
|
|
509
|
+
if (!loadRef.current || !pages || pages.status === "loading" || (pages.status === "ready" && !pages.hasMore))
|
|
510
|
+
return;
|
|
511
|
+
const parentKey = parent ? parent.key : null;
|
|
512
|
+
const skip = pages.loaded;
|
|
513
|
+
const take = pageSize();
|
|
514
|
+
const first = skip === 0;
|
|
515
|
+
const request = store.beginLoad(parent);
|
|
516
|
+
const where = underName(parent);
|
|
517
|
+
const done = (page) => {
|
|
518
|
+
if (!mountedRef.current)
|
|
519
|
+
return;
|
|
520
|
+
const current = parentByKey(parentKey);
|
|
521
|
+
if (current === undefined)
|
|
522
|
+
return;
|
|
523
|
+
const result = store.receivePage(current, request, page ?? { items: [] }, take);
|
|
524
|
+
if (!result)
|
|
525
|
+
return;
|
|
526
|
+
if (result.change)
|
|
527
|
+
setMotion(openMotion(result.change));
|
|
528
|
+
followStatusFocus(current, result.added);
|
|
529
|
+
const now = store.pagesOf(current);
|
|
530
|
+
const count = result.added.length;
|
|
531
|
+
const of = now?.total !== undefined ? ` (${now.loaded.toLocaleString()} of ${now.total.toLocaleString()})` : "";
|
|
532
|
+
setAnnouncement(count === 0 && first ? `No items${where}.` : `${count.toLocaleString()} ${count === 1 ? "item" : "items"} loaded${where}${of}.`);
|
|
533
|
+
bump();
|
|
534
|
+
};
|
|
535
|
+
const fail = () => {
|
|
536
|
+
if (!mountedRef.current)
|
|
537
|
+
return;
|
|
538
|
+
const current = parentByKey(parentKey);
|
|
539
|
+
if (current === undefined || !store.failLoad(current, request))
|
|
540
|
+
return;
|
|
541
|
+
setAnnouncement(`Could not load ${first ? "the" : "more"} items${where}.`);
|
|
542
|
+
bump();
|
|
543
|
+
};
|
|
544
|
+
let answer;
|
|
545
|
+
try {
|
|
546
|
+
answer = fetchPage(parent, skip, take);
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
fail();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (!isPromiseLike(answer)) {
|
|
553
|
+
done(answer);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
setAnnouncement(`Loading ${first ? "the" : "more"} items${where}…`);
|
|
557
|
+
bump();
|
|
558
|
+
answer.then(done, fail);
|
|
559
|
+
}
|
|
560
|
+
/** Loads again, after a refresh, the pages a parent had (`null`: the top level), and puts them in place of the old ones. */
|
|
561
|
+
function reloadPages(parent) {
|
|
562
|
+
const pages = store.pagesOf(parent);
|
|
563
|
+
if (!loadRef.current || !pages || pages.status === "loading")
|
|
564
|
+
return;
|
|
565
|
+
const parentKey = parent ? parent.key : null;
|
|
566
|
+
const take = pageSize();
|
|
567
|
+
const count = Math.max(1, Math.ceil(pages.loaded / take));
|
|
568
|
+
const request = store.beginLoad(parent);
|
|
569
|
+
const where = underName(parent);
|
|
570
|
+
const done = (answers) => {
|
|
571
|
+
if (!mountedRef.current)
|
|
572
|
+
return;
|
|
573
|
+
const current = parentByKey(parentKey);
|
|
574
|
+
if (current === undefined || !store.replacePages(current, request, answers, take))
|
|
575
|
+
return;
|
|
576
|
+
setMotion(null);
|
|
577
|
+
followStatusFocus(current, []);
|
|
578
|
+
setAnnouncement(`Items loaded again${where}.`);
|
|
579
|
+
bump();
|
|
580
|
+
};
|
|
581
|
+
const fail = () => {
|
|
582
|
+
if (!mountedRef.current)
|
|
583
|
+
return;
|
|
584
|
+
const current = parentByKey(parentKey);
|
|
585
|
+
if (current === undefined || !store.failLoad(current, request))
|
|
586
|
+
return;
|
|
587
|
+
setAnnouncement(`Could not load the items${where}.`);
|
|
588
|
+
bump();
|
|
589
|
+
};
|
|
590
|
+
let answers;
|
|
591
|
+
try {
|
|
592
|
+
answers = Array.from({ length: count }, (_, index) => fetchPage(parent, index * take, take));
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
fail();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (!answers.some(answer => isPromiseLike(answer))) {
|
|
599
|
+
done(answers);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
bump();
|
|
603
|
+
Promise.all(answers).then(done, fail);
|
|
604
|
+
}
|
|
605
|
+
/** 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. */
|
|
606
|
+
function ensureLoaded(parent) {
|
|
607
|
+
const pages = store.pagesOf(parent);
|
|
608
|
+
if (!pages || pages.status === "loading")
|
|
609
|
+
return;
|
|
610
|
+
if (pages.stale)
|
|
611
|
+
reloadPages(parent);
|
|
612
|
+
else if (pages.status === "idle" || (pages.status === "failed" && pages.loaded === 0))
|
|
613
|
+
loadNext(parent);
|
|
614
|
+
}
|
|
615
|
+
/** Enter, Space, or a click on a status row: Load more asks for the next page, and a failed row asks again. */
|
|
616
|
+
function pressStatus(row) {
|
|
617
|
+
if (disabled)
|
|
618
|
+
return;
|
|
619
|
+
const parent = row.parent;
|
|
620
|
+
const pages = store.pagesOf(parent);
|
|
621
|
+
if (row.status === "more")
|
|
622
|
+
loadNext(parent);
|
|
623
|
+
else if (row.status === "error") {
|
|
624
|
+
if (pages?.stale)
|
|
625
|
+
reloadPages(parent);
|
|
626
|
+
else
|
|
627
|
+
loadNext(parent);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Focus on a parent's status row (Load more, Retry, or loading) when its page came moves to the first item it brought,
|
|
632
|
+
* or back to the parent when nothing came and the row is gone. Focus anywhere else stays where it is.
|
|
633
|
+
*/
|
|
634
|
+
function followStatusFocus(parent, added) {
|
|
635
|
+
const key = treeStatusKey(parent ? parent.key : null);
|
|
636
|
+
if (focusKeyRef.current !== key)
|
|
637
|
+
return;
|
|
638
|
+
const status = store.find(key);
|
|
639
|
+
const next = added[0] ?? (status && store.isVisible(status) ? undefined : (parent ?? store.rows[store.rows.length - 1]));
|
|
640
|
+
if (!next)
|
|
641
|
+
return;
|
|
642
|
+
if (hadFocusRef.current)
|
|
643
|
+
focusNode(next);
|
|
644
|
+
else
|
|
645
|
+
setFocusKey(next.key);
|
|
646
|
+
}
|
|
375
647
|
// Adding, renaming, deleting
|
|
376
648
|
function allowed(action, node) {
|
|
377
649
|
if (blocked)
|
|
378
650
|
return false;
|
|
379
651
|
if ((action === "create" && !creatable) || (action === "update" && !editable) || (action === "delete" && !deletable))
|
|
380
652
|
return false;
|
|
381
|
-
if (node && (node.disabledPath || node.draft))
|
|
653
|
+
if (node && (node.disabledPath || node.draft || node.status))
|
|
382
654
|
return false;
|
|
383
655
|
return canChange ? canChange(action, node ? node.item : null) : true;
|
|
384
656
|
}
|
|
@@ -424,6 +696,9 @@ export function TreeView(props) {
|
|
|
424
696
|
const before = store.rows;
|
|
425
697
|
const draft = { [valueKey]: newTreeValue(), [parentKey]: parent ? parent.value : null, [labelKey]: "" };
|
|
426
698
|
const node = store.insert(parent, draft, { draft: true });
|
|
699
|
+
// A lazy parent opened by the new item shows its own children too, after it.
|
|
700
|
+
if (parent && lazy)
|
|
701
|
+
ensureLoaded(parent);
|
|
427
702
|
const index = store.indexOf(node);
|
|
428
703
|
setMotion(openMotion(wasOpen ? { at: index, count: 1 } : { at: parentIndex + 1, count: store.rows.length - before.length }));
|
|
429
704
|
openEditor({ key: node.key, mode: "create" }, "");
|
|
@@ -480,6 +755,9 @@ export function TreeView(props) {
|
|
|
480
755
|
const edit = editingRef.current;
|
|
481
756
|
if (!edit)
|
|
482
757
|
return true;
|
|
758
|
+
// The row scrolled out of the window: the box keeps its text, and comes back focused with its row.
|
|
759
|
+
if (via === "blur" && restoringRef.current)
|
|
760
|
+
return false;
|
|
483
761
|
if (savingRef.current)
|
|
484
762
|
return false;
|
|
485
763
|
const node = store.get(edit.key);
|
|
@@ -603,9 +881,9 @@ export function TreeView(props) {
|
|
|
603
881
|
}
|
|
604
882
|
// Pointer and keys
|
|
605
883
|
function onViewportPointerDown(event) {
|
|
606
|
-
// The context menu opens on the row that was pressed, or at the top level on empty space.
|
|
884
|
+
// The context menu opens on the row that was pressed, or at the top level on empty space or a status row.
|
|
607
885
|
const node = nodeOfTarget(event.target);
|
|
608
|
-
setContextKey(node ? node.key : null);
|
|
886
|
+
setContextKey(node && !node.status ? node.key : null);
|
|
609
887
|
}
|
|
610
888
|
function onViewportClick(event) {
|
|
611
889
|
const target = event.target;
|
|
@@ -617,6 +895,10 @@ export function TreeView(props) {
|
|
|
617
895
|
setFocusKey(node.key);
|
|
618
896
|
if (disabled)
|
|
619
897
|
return;
|
|
898
|
+
if (node.status) {
|
|
899
|
+
pressStatus(node);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
620
902
|
if (target.closest(".nx-tree__toggle")) {
|
|
621
903
|
toggleExpand(node);
|
|
622
904
|
return;
|
|
@@ -632,7 +914,7 @@ export function TreeView(props) {
|
|
|
632
914
|
else
|
|
633
915
|
onItemClick(node.item);
|
|
634
916
|
}
|
|
635
|
-
else if (node
|
|
917
|
+
else if (store.canExpand(node)) {
|
|
636
918
|
toggleExpand(node);
|
|
637
919
|
}
|
|
638
920
|
}
|
|
@@ -644,6 +926,7 @@ export function TreeView(props) {
|
|
|
644
926
|
noteRowRef.current = null;
|
|
645
927
|
readOnlyNotice.hide();
|
|
646
928
|
lockedNotice.hide();
|
|
929
|
+
partialNotice.hide();
|
|
647
930
|
event.preventDefault();
|
|
648
931
|
return;
|
|
649
932
|
}
|
|
@@ -654,6 +937,12 @@ export function TreeView(props) {
|
|
|
654
937
|
const target = rowElement(node.key) ?? event.currentTarget;
|
|
655
938
|
const page = Math.max(1, Math.floor((viewportRef.current?.clientHeight ?? rowHeight) / rowHeight) - 1);
|
|
656
939
|
const { key, ctrlKey, metaKey, altKey } = event;
|
|
940
|
+
// A status row is a button: Enter or Space presses Load more or Retry.
|
|
941
|
+
if (node.status && (key === "Enter" || (key === " " && !ctrlKey && !metaKey))) {
|
|
942
|
+
pressStatus(node);
|
|
943
|
+
event.preventDefault();
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
657
946
|
let handled = true;
|
|
658
947
|
if (key === "ArrowDown")
|
|
659
948
|
moveTo(index + 1);
|
|
@@ -668,7 +957,7 @@ export function TreeView(props) {
|
|
|
668
957
|
else if (key === "PageUp")
|
|
669
958
|
moveTo(index - page);
|
|
670
959
|
else if (key === "ArrowRight") {
|
|
671
|
-
if (node
|
|
960
|
+
if (!store.canExpand(node))
|
|
672
961
|
handled = false;
|
|
673
962
|
else if (!node.expanded)
|
|
674
963
|
toggleExpand(node);
|
|
@@ -690,7 +979,7 @@ export function TreeView(props) {
|
|
|
690
979
|
else if (key === "Enter") {
|
|
691
980
|
if (onItemClick && !checkable && !node.disabledPath)
|
|
692
981
|
onItemClick(node.item);
|
|
693
|
-
else if (node
|
|
982
|
+
else if (store.canExpand(node))
|
|
694
983
|
toggleExpand(node);
|
|
695
984
|
else if (checkable)
|
|
696
985
|
toggleCheck(node, target);
|
|
@@ -772,6 +1061,8 @@ export function TreeView(props) {
|
|
|
772
1061
|
if (!node)
|
|
773
1062
|
return;
|
|
774
1063
|
store.reveal(node);
|
|
1064
|
+
if (!node.expanded)
|
|
1065
|
+
ensureLoaded(node);
|
|
775
1066
|
store.expand(node);
|
|
776
1067
|
setMotion(null);
|
|
777
1068
|
bump();
|
|
@@ -823,15 +1114,63 @@ export function TreeView(props) {
|
|
|
823
1114
|
if (node)
|
|
824
1115
|
focusNode(node);
|
|
825
1116
|
},
|
|
1117
|
+
refresh: refreshValue => {
|
|
1118
|
+
if (!lazy)
|
|
1119
|
+
return;
|
|
1120
|
+
const node = refreshValue === undefined ? null : store.get(refreshValue);
|
|
1121
|
+
if (refreshValue !== undefined && !node)
|
|
1122
|
+
return;
|
|
1123
|
+
if (cacheKey)
|
|
1124
|
+
expireTreePages(cacheKey, node ? store.subtree(node).map(each => each.value) : undefined);
|
|
1125
|
+
store.markStale(node);
|
|
1126
|
+
setMotion(null);
|
|
1127
|
+
bump();
|
|
1128
|
+
},
|
|
826
1129
|
}));
|
|
827
1130
|
// Rendering
|
|
828
1131
|
const showCounts = checkable && cascade && counts;
|
|
829
1132
|
const tabbable = focusedNode() ?? rows[0];
|
|
830
1133
|
const rendered = new Set();
|
|
1134
|
+
/** How many siblings a row has: all of them on the server when the loader said, loaded or not. */
|
|
1135
|
+
function setSize(node) {
|
|
1136
|
+
const loaded = node.parent ? node.parent.children.length : store.roots.length;
|
|
1137
|
+
const total = store.pagesOf(node.parent)?.total;
|
|
1138
|
+
return total === undefined ? loaded : Math.max(total, loaded);
|
|
1139
|
+
}
|
|
1140
|
+
/** 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. */
|
|
1141
|
+
function renderStatusRow(row, ghost) {
|
|
1142
|
+
if (!ghost)
|
|
1143
|
+
rendered.add(row.key);
|
|
1144
|
+
const parent = row.parent;
|
|
1145
|
+
const pages = store.pagesOf(parent);
|
|
1146
|
+
const first = !pages || pages.loaded === 0;
|
|
1147
|
+
const where = underName(parent);
|
|
1148
|
+
const left = row.status === "more" && pages?.total !== undefined ? Math.max(0, pages.total - pages.loaded) : undefined;
|
|
1149
|
+
const leftText = left === undefined ? null : `${left.toLocaleString()} left`;
|
|
1150
|
+
let text;
|
|
1151
|
+
let name;
|
|
1152
|
+
if (row.status === "loading") {
|
|
1153
|
+
text = first ? "Loading…" : "Loading more…";
|
|
1154
|
+
name = `Loading ${first ? "the" : "more"} items${where}`;
|
|
1155
|
+
}
|
|
1156
|
+
else if (row.status === "error") {
|
|
1157
|
+
text = first ? "Could not load the items." : "Could not load more items.";
|
|
1158
|
+
name = `Could not load ${first ? "the" : "more"} items${where}. Retry`;
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
text = "Load more";
|
|
1162
|
+
name = `Load more${where}${leftText ? `, ${leftText}` : ""}`;
|
|
1163
|
+
}
|
|
1164
|
+
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));
|
|
1165
|
+
}
|
|
831
1166
|
function renderRow(node, ghost = false) {
|
|
1167
|
+
if (node.status)
|
|
1168
|
+
return renderStatusRow(node, ghost);
|
|
832
1169
|
if (!ghost)
|
|
833
1170
|
rendered.add(node.key);
|
|
834
1171
|
const hasChildren = node.children.length > 0;
|
|
1172
|
+
const expandable = hasChildren || store.canExpand(node);
|
|
1173
|
+
const childrenLoading = node.pages?.status === "loading";
|
|
835
1174
|
const state = checkable ? store.state(node) : null;
|
|
836
1175
|
const edit = !ghost && editing?.key === node.key ? editing : null;
|
|
837
1176
|
const busy = busyKeys.has(node.key);
|
|
@@ -853,10 +1192,10 @@ export function TreeView(props) {
|
|
|
853
1192
|
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
1193
|
}
|
|
855
1194
|
}
|
|
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
|
|
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 => {
|
|
857
1196
|
if (input)
|
|
858
1197
|
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));
|
|
1198
|
+
} }) })), 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
1199
|
}
|
|
861
1200
|
const end = Math.min(range.end, total);
|
|
862
1201
|
const start = Math.min(range.start, end);
|
|
@@ -889,15 +1228,25 @@ export function TreeView(props) {
|
|
|
889
1228
|
}
|
|
890
1229
|
const allState = checkable ? store.allState() : null;
|
|
891
1230
|
const tally = store.counts();
|
|
892
|
-
const hasParents = store.roots.some(root => root.children.length > 0);
|
|
1231
|
+
const hasParents = store.roots.some(root => root.children.length > 0 || root.expanded);
|
|
893
1232
|
const hasLabel = hasContent(label);
|
|
894
1233
|
const shownMessage = message ?? (hasContent(error) ? error : null);
|
|
895
|
-
|
|
896
|
-
|
|
1234
|
+
// A lazy top level on its way shows the same placeholder rows as `loading`; one that failed shows its row with Retry.
|
|
1235
|
+
const rootPages = store.rootPages;
|
|
1236
|
+
const rootWaiting = store.size === 0 && rootPages !== null && (rootPages.status === "idle" || rootPages.status === "loading");
|
|
1237
|
+
const waiting = loading || rootWaiting;
|
|
1238
|
+
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
|
|
897
1240
|
? "Loading…"
|
|
898
1241
|
: checkable
|
|
899
1242
|
? `${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 ? (
|
|
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,
|
|
1244
|
+
// As tall as all of its rows at every moment of a commit, not only once it has finished. A scroll that
|
|
1245
|
+
// moves the window makes React take the rows that leave it out before it puts the new ones in and sets
|
|
1246
|
+
// this padding, and a layout in between - Chrome lays the page out inside the removal of a focused row,
|
|
1247
|
+
// and so may any focusout listener that measures - would find the list short of the scroll position and
|
|
1248
|
+
// 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] }));
|
|
901
1250
|
}
|
|
902
1251
|
/** The name box of a row being added or renamed. Enter saves, Esc cancels, and leaving it saves. */
|
|
903
1252
|
function TreeEditor({ initial, select, maxLength, saving, label, invalid, describedBy, onText, onCommit, onCancel }) {
|