nexus-shared 3.1.0 → 4.0.0

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