nexus-shared 3.1.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +704 -446
  2. package/LICENSE +21 -21
  3. package/README.md +44 -5
  4. package/dist/Client.Index.d.ts +3 -0
  5. package/dist/Client.Index.js +3 -0
  6. package/dist/Components/Forms/Crud.js +322 -161
  7. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  8. package/dist/Components/Forms/CrudHistory.js +140 -0
  9. package/dist/Components/Forms/CrudParts.js +2 -1
  10. package/dist/Components/Forms/Form.js +4 -1
  11. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  12. package/dist/Components/Inputs/CodeBox.js +316 -0
  13. package/dist/Components/Inputs/DatePicker.js +4 -1
  14. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  15. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  16. package/dist/Components/Inputs/Dropdown.js +4 -1
  17. package/dist/Components/Inputs/InputRenderer.js +3 -0
  18. package/dist/Components/Inputs/NumberBox.js +4 -2
  19. package/dist/Components/Inputs/TextArea.js +4 -1
  20. package/dist/Components/Inputs/TextBox.js +4 -1
  21. package/dist/Components/Inputs/TimePicker.js +4 -1
  22. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  23. package/dist/Components/Inputs/TreeView.js +372 -23
  24. package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
  25. package/dist/Components/Layouts/ThemePicker.js +16 -4
  26. package/dist/Components/Viewers/DataTable.js +63 -21
  27. package/dist/Components/Viewers/DataTableParts.d.ts +26 -8
  28. package/dist/Components/Viewers/DataTableParts.js +39 -10
  29. package/dist/Helpers/ApiModules.d.ts +3 -34
  30. package/dist/Helpers/ApiModules.js +3 -3
  31. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  32. package/dist/Helpers/ApiRoutes.js +31 -27
  33. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  34. package/dist/Helpers/ArrayHelpers.js +2 -0
  35. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  36. package/dist/Helpers/CodeHelpers.js +332 -0
  37. package/dist/Helpers/CrudBackend.d.ts +94 -4
  38. package/dist/Helpers/CrudBackend.js +168 -9
  39. package/dist/Helpers/CrudColumns.d.ts +51 -0
  40. package/dist/Helpers/CrudColumns.js +69 -0
  41. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  42. package/dist/Helpers/CrudHelpers.js +109 -12
  43. package/dist/Helpers/InputParamsHelpers.js +8 -0
  44. package/dist/Helpers/MessageBuilder.js +2 -0
  45. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  46. package/dist/Helpers/NexusHelpers.js +1 -0
  47. package/dist/Helpers/RequestCache.d.ts +35 -0
  48. package/dist/Helpers/RequestCache.js +113 -0
  49. package/dist/Helpers/TableExport.js +12 -12
  50. package/dist/Helpers/TableHelpers.d.ts +1 -1
  51. package/dist/Helpers/TableHelpers.js +7 -2
  52. package/dist/Helpers/TableJoins.d.ts +70 -0
  53. package/dist/Helpers/TableJoins.js +250 -0
  54. package/dist/Helpers/TableStore.d.ts +5 -0
  55. package/dist/Helpers/TableStore.js +9 -0
  56. package/dist/Helpers/TreeStore.d.ts +225 -11
  57. package/dist/Helpers/TreeStore.js +683 -90
  58. package/dist/Helpers/UserPreferences.d.ts +8 -0
  59. package/dist/Helpers/UserPreferences.js +17 -0
  60. package/dist/Interfaces/CrudInterfaces.d.ts +47 -9
  61. package/dist/Interfaces/FormInterfaces.d.ts +4 -1
  62. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  63. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  64. package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
  65. package/dist/Interfaces/PreferenceInterfaces.js +2 -5
  66. package/dist/Interfaces/TableInterfaces.d.ts +86 -4
  67. package/dist/Services/ApiProxy.d.ts +36 -1
  68. package/dist/Services/ApiProxy.js +182 -52
  69. package/dist/Services/ApiProxyRules.d.ts +203 -0
  70. package/dist/Services/ApiProxyRules.js +426 -0
  71. package/dist/Services/JoinSources.d.ts +48 -0
  72. package/dist/Services/JoinSources.js +74 -0
  73. package/dist/Services/ServerApi.d.ts +13 -2
  74. package/dist/Services/ServerApi.js +8 -3
  75. package/dist/Shared.Index.d.ts +4 -0
  76. package/dist/Shared.Index.js +4 -0
  77. package/package.json +87 -85
  78. package/src/Styles/Nexus.Avatar.css +79 -79
  79. package/src/Styles/Nexus.Base.css +117 -117
  80. package/src/Styles/Nexus.Button.css +180 -180
  81. package/src/Styles/Nexus.Calendar.css +273 -273
  82. package/src/Styles/Nexus.Chat.css +1443 -1443
  83. package/src/Styles/Nexus.Choice.css +343 -343
  84. package/src/Styles/Nexus.Crud.css +168 -168
  85. package/src/Styles/Nexus.DateTime.css +81 -81
  86. package/src/Styles/Nexus.Dropdown.css +725 -725
  87. package/src/Styles/Nexus.Editor.css +295 -295
  88. package/src/Styles/Nexus.Excel.css +389 -389
  89. package/src/Styles/Nexus.Form.css +279 -279
  90. package/src/Styles/Nexus.Index.css +37 -37
  91. package/src/Styles/Nexus.Input.css +196 -4
  92. package/src/Styles/Nexus.Markdown.css +237 -237
  93. package/src/Styles/Nexus.Menu.css +237 -237
  94. package/src/Styles/Nexus.Popup.css +724 -724
  95. package/src/Styles/Nexus.Rows.css +637 -637
  96. package/src/Styles/Nexus.Slider.css +276 -276
  97. package/src/Styles/Nexus.Tab.Buttons.css +432 -432
  98. package/src/Styles/Nexus.Table.css +31 -30
  99. package/src/Styles/Nexus.Theme.Picker.css +59 -3
  100. package/src/Styles/Nexus.Themes.css +194 -194
  101. package/src/Styles/Nexus.Time.css +144 -144
  102. package/src/Styles/Nexus.Toast.css +263 -263
  103. package/src/Styles/Nexus.Tokens.css +89 -89
  104. package/src/Styles/Nexus.Tree.css +500 -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 { TREE_DEFAULT_KEYS, TreeStore, } from "../../Helpers/TreeStore.js";
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 config = [valueKey, parentKey, labelKey, cascade, valueMode].join("");
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.get(key);
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
- return row?.dataset.key === undefined ? undefined : store.get(row.dataset.key);
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.get(focusKey);
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.children.length === 0)
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.get(focusKey);
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.children.length > 0) {
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.children.length === 0)
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.children.length > 0)
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.parent ? node.parent.children.length : store.roots.length), "aria-posinset": ghost ? undefined : node.position + 1, "aria-expanded": ghost || !hasChildren ? undefined : node.expanded, "aria-checked": ghost || !state ? undefined : state === "mixed" ? "mixed" : state === "checked", "aria-disabled": ghost ? undefined : locked || undefined, "aria-busy": busy || 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: hasChildren && _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 => {
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
- const empty = total === 0;
896
- 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 && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin", "aria-hidden": "true" }), loading && empty
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 ? (loading ? (_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": loading || undefined, style: { 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))), readOnlyNotice.notice, lockedNotice.notice] }));
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 }) {