nexus-shared 3.2.0 → 4.1.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 (271) hide show
  1. package/CHANGELOG.md +2359 -0
  2. package/README.md +1911 -1123
  3. package/dist/Client.Index.d.ts +27 -0
  4. package/dist/Client.Index.js +27 -0
  5. package/dist/Components/Chats/ChatComposer.d.ts +7 -1
  6. package/dist/Components/Chats/ChatComposer.js +9 -6
  7. package/dist/Components/Chats/ChatParts.d.ts +15 -2
  8. package/dist/Components/Chats/ChatParts.js +15 -4
  9. package/dist/Components/Chats/ChatPopup.d.ts +46 -0
  10. package/dist/Components/Chats/ChatPopup.js +34 -0
  11. package/dist/Components/Chats/ChatThread.d.ts +9 -1
  12. package/dist/Components/Chats/ChatThread.js +54 -6
  13. package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
  14. package/dist/Components/Dashboards/DashboardBoard.js +594 -0
  15. package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
  16. package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
  17. package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
  18. package/dist/Components/Dashboards/WidgetFrame.js +45 -0
  19. package/dist/Components/Documents/Button.d.ts +6 -1
  20. package/dist/Components/Documents/Button.js +2 -2
  21. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  22. package/dist/Components/Documents/DocumentFrame.js +83 -0
  23. package/dist/Components/Documents/Menu.d.ts +4 -0
  24. package/dist/Components/Documents/Menu.js +40 -24
  25. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  26. package/dist/Components/Documents/PrintDocument.js +62 -0
  27. package/dist/Components/Documents/TabButtons.d.ts +6 -0
  28. package/dist/Components/Documents/TabButtons.js +5 -4
  29. package/dist/Components/Documents/Tabs.d.ts +59 -0
  30. package/dist/Components/Documents/Tabs.js +70 -0
  31. package/dist/Components/Domains/DomainCard.d.ts +98 -0
  32. package/dist/Components/Domains/DomainCard.js +182 -0
  33. package/dist/Components/Forms/ApiForm.js +24 -3
  34. package/dist/Components/Forms/Crud.d.ts +2 -2
  35. package/dist/Components/Forms/Crud.js +628 -247
  36. package/dist/Components/Forms/CrudActions.d.ts +137 -0
  37. package/dist/Components/Forms/CrudActions.js +214 -0
  38. package/dist/Components/Forms/CrudHistory.d.ts +6 -1
  39. package/dist/Components/Forms/CrudHistory.js +14 -6
  40. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  41. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  42. package/dist/Components/Forms/CrudParts.d.ts +37 -2
  43. package/dist/Components/Forms/CrudParts.js +123 -7
  44. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  45. package/dist/Components/Forms/CrudRowForms.js +174 -0
  46. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  47. package/dist/Components/Forms/CrudScopes.js +76 -0
  48. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  49. package/dist/Components/Forms/CrudTree.js +363 -0
  50. package/dist/Components/Forms/CrudWorkspace.d.ts +73 -0
  51. package/dist/Components/Forms/CrudWorkspace.js +177 -0
  52. package/dist/Components/Forms/ExcelImport.js +3 -3
  53. package/dist/Components/Forms/Form.d.ts +6 -5
  54. package/dist/Components/Forms/Form.js +134 -21
  55. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  56. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  57. package/dist/Components/Forms/FormQuestion.d.ts +6 -0
  58. package/dist/Components/Forms/FormQuestion.js +28 -0
  59. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  60. package/dist/Components/Forms/RecordFlows.js +282 -0
  61. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  62. package/dist/Components/Forms/SubmitForm.js +182 -10
  63. package/dist/Components/Inputs/CheckBox.js +1 -1
  64. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  65. package/dist/Components/Inputs/ChoiceField.js +28 -2
  66. package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
  67. package/dist/Components/Inputs/ComputedInput.js +179 -0
  68. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  69. package/dist/Components/Inputs/Dropdown.js +8 -6
  70. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  71. package/dist/Components/Inputs/DropdownParts.js +16 -4
  72. package/dist/Components/Inputs/Field.d.ts +7 -1
  73. package/dist/Components/Inputs/Field.js +2 -2
  74. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  75. package/dist/Components/Inputs/GroupForm.js +12 -12
  76. package/dist/Components/Inputs/InputRenderer.d.ts +13 -6
  77. package/dist/Components/Inputs/InputRenderer.js +37 -32
  78. package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
  79. package/dist/Components/Inputs/MarkdownEditor.js +32 -6
  80. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  81. package/dist/Components/Inputs/PictureInput.js +75 -0
  82. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  83. package/dist/Components/Inputs/RadioGroup.js +8 -3
  84. package/dist/Components/Inputs/RowsInput.d.ts +15 -2
  85. package/dist/Components/Inputs/RowsInput.js +96 -8
  86. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  87. package/dist/Components/Inputs/TabularForm.js +45 -32
  88. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  89. package/dist/Components/Inputs/TextArea.js +11 -2
  90. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  91. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  92. package/dist/Components/Inputs/TreeView.d.ts +76 -2
  93. package/dist/Components/Inputs/TreeView.js +227 -16
  94. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  95. package/dist/Components/Layouts/MessageBox.js +56 -7
  96. package/dist/Components/Layouts/Popup.d.ts +34 -7
  97. package/dist/Components/Layouts/Popup.js +143 -13
  98. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  99. package/dist/Components/Layouts/PopupDock.js +2 -1
  100. package/dist/Components/Layouts/PopupHost.js +5 -4
  101. package/dist/Components/Messages/MailClient.d.ts +50 -0
  102. package/dist/Components/Messages/MailClient.js +410 -0
  103. package/dist/Components/Messages/MailColumns.d.ts +21 -0
  104. package/dist/Components/Messages/MailColumns.js +92 -0
  105. package/dist/Components/Messages/MailCompose.d.ts +46 -0
  106. package/dist/Components/Messages/MailCompose.js +154 -0
  107. package/dist/Components/Messages/MailReader.d.ts +43 -0
  108. package/dist/Components/Messages/MailReader.js +207 -0
  109. package/dist/Components/Messages/MailThread.d.ts +71 -0
  110. package/dist/Components/Messages/MailThread.js +185 -0
  111. package/dist/Components/Messages/MessageBell.d.ts +41 -0
  112. package/dist/Components/Messages/MessageBell.js +76 -0
  113. package/dist/Components/Viewers/DataTable.js +153 -49
  114. package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
  115. package/dist/Components/Viewers/DataTableColumns.js +2 -1
  116. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  117. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  118. package/dist/Components/Viewers/DataTableParts.d.ts +23 -5
  119. package/dist/Components/Viewers/DataTableParts.js +26 -14
  120. package/dist/Components/Viewers/OwnerFiles.d.ts +227 -0
  121. package/dist/Components/Viewers/OwnerFiles.js +700 -0
  122. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  123. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  124. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  125. package/dist/Components/Viewers/RecordCards.js +509 -0
  126. package/dist/Helpers/ApiModules.js +4 -0
  127. package/dist/Helpers/AuditEntries.d.ts +50 -0
  128. package/dist/Helpers/AuditEntries.js +96 -0
  129. package/dist/Helpers/BusyHooks.d.ts +6 -0
  130. package/dist/Helpers/BusyHooks.js +39 -0
  131. package/dist/Helpers/ChatBackend.d.ts +8 -1
  132. package/dist/Helpers/ChatBackend.js +18 -0
  133. package/dist/Helpers/ChatHelpers.d.ts +2 -0
  134. package/dist/Helpers/ChatHelpers.js +4 -1
  135. package/dist/Helpers/CrudBackend.d.ts +20 -2
  136. package/dist/Helpers/CrudBackend.js +46 -2
  137. package/dist/Helpers/CrudColumns.d.ts +91 -6
  138. package/dist/Helpers/CrudColumns.js +148 -7
  139. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  140. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  141. package/dist/Helpers/CrudHelpers.d.ts +116 -2
  142. package/dist/Helpers/CrudHelpers.js +256 -0
  143. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  144. package/dist/Helpers/CrudNarrowPick.js +157 -0
  145. package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
  146. package/dist/Helpers/CrudPickerPlan.js +46 -0
  147. package/dist/Helpers/CrudRowForms.d.ts +32 -0
  148. package/dist/Helpers/CrudRowForms.js +76 -0
  149. package/dist/Helpers/CrudScopes.d.ts +32 -0
  150. package/dist/Helpers/CrudScopes.js +79 -0
  151. package/dist/Helpers/DashboardHelpers.d.ts +115 -0
  152. package/dist/Helpers/DashboardHelpers.js +305 -0
  153. package/dist/Helpers/DomainCards.d.ts +140 -0
  154. package/dist/Helpers/DomainCards.js +370 -0
  155. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  156. package/dist/Helpers/DropdownHelpers.js +6 -2
  157. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  158. package/dist/Helpers/ExcelHelpers.js +12 -11
  159. package/dist/Helpers/FileAdapter.d.ts +158 -0
  160. package/dist/Helpers/FileAdapter.js +173 -0
  161. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  162. package/dist/Helpers/FilePrimaries.js +138 -0
  163. package/dist/Helpers/FormConfirm.d.ts +32 -0
  164. package/dist/Helpers/FormConfirm.js +40 -0
  165. package/dist/Helpers/FormStore.d.ts +100 -5
  166. package/dist/Helpers/FormStore.js +368 -25
  167. package/dist/Helpers/InputConditions.d.ts +56 -0
  168. package/dist/Helpers/InputConditions.js +192 -0
  169. package/dist/Helpers/InputMemory.d.ts +106 -0
  170. package/dist/Helpers/InputMemory.js +395 -0
  171. package/dist/Helpers/InputNarrowing.d.ts +50 -0
  172. package/dist/Helpers/InputNarrowing.js +148 -0
  173. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  174. package/dist/Helpers/InputParamsHelpers.js +157 -74
  175. package/dist/Helpers/MailClientHelpers.d.ts +96 -0
  176. package/dist/Helpers/MailClientHelpers.js +211 -0
  177. package/dist/Helpers/MessageBackend.d.ts +152 -0
  178. package/dist/Helpers/MessageBackend.js +329 -0
  179. package/dist/Helpers/MessageHelpers.d.ts +160 -0
  180. package/dist/Helpers/MessageHelpers.js +573 -0
  181. package/dist/Helpers/MessageUnread.d.ts +95 -0
  182. package/dist/Helpers/MessageUnread.js +245 -0
  183. package/dist/Helpers/NexusDriveAdapter.d.ts +71 -0
  184. package/dist/Helpers/NexusDriveAdapter.js +236 -0
  185. package/dist/Helpers/NumberHelpers.d.ts +156 -0
  186. package/dist/Helpers/NumberHelpers.js +242 -0
  187. package/dist/Helpers/PagedPicker.d.ts +42 -0
  188. package/dist/Helpers/PagedPicker.js +85 -0
  189. package/dist/Helpers/Permissions.js +2 -1
  190. package/dist/Helpers/QrCode.d.ts +26 -0
  191. package/dist/Helpers/QrCode.js +463 -0
  192. package/dist/Helpers/RecordCards.d.ts +205 -0
  193. package/dist/Helpers/RecordCards.js +378 -0
  194. package/dist/Helpers/RequestCache.d.ts +14 -2
  195. package/dist/Helpers/RequestCache.js +22 -3
  196. package/dist/Helpers/RowsStore.d.ts +61 -2
  197. package/dist/Helpers/RowsStore.js +203 -16
  198. package/dist/Helpers/ServerPreview.d.ts +69 -0
  199. package/dist/Helpers/ServerPreview.js +99 -0
  200. package/dist/Helpers/TableColumns.js +4 -3
  201. package/dist/Helpers/TableExport.d.ts +7 -1
  202. package/dist/Helpers/TableExport.js +9 -3
  203. package/dist/Helpers/TableFilters.d.ts +1 -1
  204. package/dist/Helpers/TableFilters.js +24 -23
  205. package/dist/Helpers/TableHelpers.d.ts +38 -4
  206. package/dist/Helpers/TableHelpers.js +78 -10
  207. package/dist/Helpers/TableJoins.d.ts +13 -0
  208. package/dist/Helpers/TableJoins.js +34 -0
  209. package/dist/Helpers/TableStore.d.ts +28 -4
  210. package/dist/Helpers/TableStore.js +73 -11
  211. package/dist/Helpers/ToolbarFit.d.ts +23 -0
  212. package/dist/Helpers/ToolbarFit.js +30 -0
  213. package/dist/Helpers/TreeStore.d.ts +156 -6
  214. package/dist/Helpers/TreeStore.js +547 -18
  215. package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
  216. package/dist/Interfaces/ApiInterfaces.js +10 -1
  217. package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
  218. package/dist/Interfaces/CrudInterfaces.d.ts +539 -6
  219. package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
  220. package/dist/Interfaces/DashboardInterfaces.js +1 -0
  221. package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
  222. package/dist/Interfaces/DomainInterfaces.js +1 -0
  223. package/dist/Interfaces/FileInterfaces.d.ts +183 -0
  224. package/dist/Interfaces/FileInterfaces.js +6 -0
  225. package/dist/Interfaces/FormInterfaces.d.ts +460 -32
  226. package/dist/Interfaces/FormInterfaces.js +79 -1
  227. package/dist/Interfaces/InputInterfaces.d.ts +22 -3
  228. package/dist/Interfaces/MailInterfaces.d.ts +192 -0
  229. package/dist/Interfaces/MailInterfaces.js +1 -0
  230. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  231. package/dist/Interfaces/RecordInterfaces.js +1 -0
  232. package/dist/Interfaces/TableInterfaces.d.ts +102 -3
  233. package/dist/Services/ExcelApi.js +5 -0
  234. package/dist/Services/FileApi.d.ts +13 -0
  235. package/dist/Services/FileApi.js +29 -0
  236. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  237. package/dist/Services/ImageEditorLoader.js +51 -0
  238. package/dist/Services/JoinSources.d.ts +79 -6
  239. package/dist/Services/JoinSources.js +132 -21
  240. package/dist/Services/MessageApi.d.ts +18 -0
  241. package/dist/Services/MessageApi.js +64 -0
  242. package/dist/Services/ModuleImport.d.ts +7 -0
  243. package/dist/Services/ModuleImport.js +22 -0
  244. package/dist/Shared.Index.d.ts +28 -0
  245. package/dist/Shared.Index.js +28 -0
  246. package/package.json +13 -2
  247. package/src/Styles/Nexus.Avatar.css +6 -5
  248. package/src/Styles/Nexus.Button.css +39 -4
  249. package/src/Styles/Nexus.Chat.css +114 -22
  250. package/src/Styles/Nexus.Choice.css +16 -0
  251. package/src/Styles/Nexus.Crud.css +486 -21
  252. package/src/Styles/Nexus.Dashboard.css +763 -0
  253. package/src/Styles/Nexus.Domain.css +384 -0
  254. package/src/Styles/Nexus.Dropdown.css +8 -0
  255. package/src/Styles/Nexus.Editor.css +20 -0
  256. package/src/Styles/Nexus.Files.css +437 -0
  257. package/src/Styles/Nexus.Form.css +5 -2
  258. package/src/Styles/Nexus.Index.css +7 -0
  259. package/src/Styles/Nexus.Input.css +73 -0
  260. package/src/Styles/Nexus.Mail.css +1062 -0
  261. package/src/Styles/Nexus.Menu.css +10 -1
  262. package/src/Styles/Nexus.Popup.css +135 -1
  263. package/src/Styles/Nexus.Print.css +288 -0
  264. package/src/Styles/Nexus.QrCode.css +12 -0
  265. package/src/Styles/Nexus.Record.css +430 -0
  266. package/src/Styles/Nexus.Tab.Buttons.css +87 -22
  267. package/src/Styles/Nexus.Table.css +1458 -1387
  268. package/src/Styles/Nexus.Theme.Picker.css +1 -0
  269. package/src/Styles/Nexus.Themes.css +12 -6
  270. package/src/Styles/Nexus.Tokens.css +8 -3
  271. package/src/Styles/Nexus.Tree.css +41 -1
@@ -4,10 +4,12 @@ import { Icon, iconAlertCircle, iconCheck, iconChevronRight, iconChevronsDown, i
4
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 { browserInputMemory } from "../../Helpers/InputMemory.js";
7
8
  import { isPromiseLike, REQUEST_CACHE_LIFETIME } from "../../Helpers/RequestCache.js";
8
- import { expireTreePages, TREE_DEFAULT_KEYS, treePageKey, treePages, TreeStore, treeStatusKey, } from "../../Helpers/TreeStore.js";
9
+ import { expireTreePages, shownTreeFilter, subscribeTreeChanges, TREE_DEFAULT_KEYS, treeKey, treePageKey, treePages, TreeStore, treeFilterStorageKey, treeStatusKey, } from "../../Helpers/TreeStore.js";
9
10
  import { Button } from "../Documents/Button.js";
10
11
  import { ContextMenu } from "../Documents/Menu.js";
12
+ import { TabButtons } from "../Documents/TabButtons.js";
11
13
  import { messageBox } from "../Layouts/MessageBox.js";
12
14
  import { CheckBox } from "./CheckBox.js";
13
15
  import { hasContent } from "./Field.js";
@@ -56,7 +58,7 @@ const sameItems = (a, b) => a === b || (a.length === b.length && a.every((item,
56
58
  * loadChildren={({ parent, skip, take, signal }) => loadRegions(parent, skip, take, signal)} />
57
59
  */
58
60
  export function TreeView(props) {
59
- const { items: givenItems, loadChildren, childPageSize = CHILD_PAGE_SIZE, hasChildrenKey, cacheKey, cacheLifetime = REQUEST_CACHE_LIFETIME, partialMessage = "Its items are not all loaded: open it and load them all to check it as a whole.", valueKey = TREE_DEFAULT_KEYS.value, parentKey = TREE_DEFAULT_KEYS.parent, labelKey = TREE_DEFAULT_KEYS.label, descriptionKey = "description", iconKey = "icon", isItemDisabled, lockedMessage = "You do not have permission to change this item.", label, hint, error, size = "md", checkable = false, cascade = true, valueMode = "leaves", value, defaultValue, onValueChange, name, form, counts = true, defaultExpanded, creatable = false, editable = false, deletable = false, canChange, onCreate, onUpdate, onDelete, onItemsChange, confirmDelete = true, maxLabelLength = 100, onItemClick, loading = false, emptyMessage = "No items", toolbar = true, height, maxHeight = 420, readOnly = false, readOnlyMessage = "Read-only: this tree cannot be changed.", disabled = false, id, className, style, "aria-label": ariaLabel, ref, } = props;
61
+ 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, filters, defaultFilter, rememberFilter, onFilterChange, filtersLabel = "Show", maxLabelLength = 100, onItemClick, loading = false, emptyMessage = "No items", toolbar = true, height, maxHeight = 420, fill = false, readOnly = false, readOnlyMessage = "Read-only: this tree cannot be changed.", disabled = false, id, className, style, "aria-label": ariaLabel, ref, } = props;
60
62
  const generatedId = useId();
61
63
  const treeId = id ?? generatedId;
62
64
  const labelId = `${treeId}-label`;
@@ -75,6 +77,14 @@ export function TreeView(props) {
75
77
  const holderRef = useRef(null);
76
78
  // Whether the user (or the handle) changed the checks since the items came, so new items keep them.
77
79
  const touchedRef = useRef(false);
80
+ // The filter on: the tab chosen (or kept), else the default, else the first.
81
+ const [chosenFilter, setChosenFilter] = useState(null);
82
+ const activeFilter = shownTreeFilter(filters, chosenFilter, defaultFilter);
83
+ const activeFilterId = activeFilter?.match ? activeFilter.id : null;
84
+ // The store asks the filter through this, so a page passing a new arrow function each draw counts nothing again.
85
+ const matchRef = useRef(undefined);
86
+ matchRef.current = activeFilter?.match;
87
+ const storeMatch = (id) => (id === null ? null : item => (matchRef.current ? matchRef.current(item) : true));
78
88
  if (holderRef.current?.config !== config) {
79
89
  // A new rule (another value mode, say) keeps what was checked and expanded.
80
90
  const previous = holderRef.current?.store;
@@ -87,6 +97,7 @@ export function TreeView(props) {
87
97
  lazy,
88
98
  lazyRoots,
89
99
  hasChildrenKey,
100
+ filter: storeMatch(activeFilterId),
90
101
  isItemDisabled: item => (disabledRef.current ? disabledRef.current(item) : read(item, "disabled") === true || read(item, "isDisabled") === true),
91
102
  }, value ?? (previous && touchedRef.current ? previous.value() : defaultValue));
92
103
  if (previous)
@@ -97,10 +108,15 @@ export function TreeView(props) {
97
108
  store.expandAll(defaultExpanded);
98
109
  else if (defaultExpanded)
99
110
  store.expandValues(defaultExpanded);
100
- holderRef.current = { config, store, items, value };
111
+ holderRef.current = { config, store, items, value, filterId: activeFilterId };
101
112
  }
102
113
  const holder = holderRef.current;
103
114
  const store = holder.store;
115
+ // Another filter (a kept one read, the filters changed): rows again, nothing checked or unchecked.
116
+ if (holder.filterId !== activeFilterId) {
117
+ store.setFilter(storeMatch(activeFilterId));
118
+ holder.filterId = activeFilterId;
119
+ }
104
120
  if (!sameItems(holder.items, items)) {
105
121
  store.setItems(items, value ?? (touchedRef.current ? store.value() : defaultValue));
106
122
  holder.items = items;
@@ -142,6 +158,8 @@ export function TreeView(props) {
142
158
  const pendingScrollRef = useRef(null);
143
159
  const pendingFocusRef = useRef(null);
144
160
  const typeaheadRef = useRef({ text: "", at: 0 });
161
+ // An item the handle was asked to reveal before it was loaded: revealed when the page that brings it comes.
162
+ const pendingRevealRef = useRef(null);
145
163
  const blocked = readOnly || disabled;
146
164
  const noticeAnchor = (target) => {
147
165
  const row = target.closest(".nx-tree__row");
@@ -150,6 +168,10 @@ export function TreeView(props) {
150
168
  const readOnlyNotice = useReadOnlyNotice(readOnly && !disabled, viewportRef, readOnlyMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
151
169
  const lockedNotice = useReadOnlyNotice(!blocked, viewportRef, lockedMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
152
170
  const partialNotice = useReadOnlyNotice(!blocked && lazy && checkable && cascade, viewportRef, partialMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
171
+ // Why the toolbar's own button cannot be pressed yet, under it, on a press.
172
+ const addNotice = useReadOnlyNotice(Boolean(add?.disabled && add.description), viewportRef, add?.description, { anchor: target => target.closest(".nx-btn"), align: "end", typing: false, lock: "label" });
173
+ // The row shown as selected, as a node key.
174
+ const selectedKey = checkable ? null : treeKey(selectedValue);
153
175
  // The row a note points at: when it leaves the page (scrolled out of the window), the note goes too.
154
176
  const noteRowRef = useRef(null);
155
177
  function showNote(notice, target) {
@@ -319,6 +341,13 @@ export function TreeView(props) {
319
341
  });
320
342
  };
321
343
  }, []);
344
+ // Changes to the source written elsewhere (`writeTreeChanges`) are written into this tree too.
345
+ const applyRef = useRef(() => undefined);
346
+ useEffect(() => {
347
+ if (!lazy || !cacheKey)
348
+ return;
349
+ return subscribeTreeChanges(cacheKey, (changes, rules) => applyRef.current(changes, rules));
350
+ }, [lazy, cacheKey]);
322
351
  // Open parents with rows in view that need pages: the top level at first, a parent a refresh made stale, one whose
323
352
  // request a refresh forgot. A stale parent out of view waits until it scrolls in, and a closed one until it opens; a
324
353
  // failed one waits for Retry, so a server that is down is not asked again and again.
@@ -345,6 +374,141 @@ export function TreeView(props) {
345
374
  function onPanelFocus() {
346
375
  hadFocusRef.current = true;
347
376
  }
377
+ // Filters: the tab kept in this browser is read after the first draw, as the server drew none; a tab chosen before then wins.
378
+ const filterStorageKey = filters && filters.length > 0 ? treeFilterStorageKey(rememberFilter) : null;
379
+ const filterTouchedRef = useRef(false);
380
+ useLayoutEffect(() => {
381
+ if (!filterStorageKey)
382
+ return;
383
+ let kept = null;
384
+ try {
385
+ kept = browserInputMemory()?.getItem(filterStorageKey) ?? null;
386
+ }
387
+ catch {
388
+ kept = null;
389
+ }
390
+ if (kept !== null && !filterTouchedRef.current)
391
+ setChosenFilter(kept);
392
+ }, [filterStorageKey]);
393
+ /** A tab chosen: rows again for its filter, focus kept on a row still shown, and the choice kept. Nothing is checked or unchecked. */
394
+ function chooseFilter(id) {
395
+ const next = shownTreeFilter(filters, id, defaultFilter);
396
+ if (!next)
397
+ return;
398
+ filterTouchedRef.current = true;
399
+ setChosenFilter(next.id);
400
+ if (filterStorageKey) {
401
+ try {
402
+ browserInputMemory()?.setItem(filterStorageKey, next.id);
403
+ }
404
+ catch {
405
+ // Storage switched off or full: the choice lasts until the page goes.
406
+ }
407
+ }
408
+ matchRef.current = next.match;
409
+ const nextId = next.match ? next.id : null;
410
+ if (holder.filterId !== nextId) {
411
+ store.setFilter(storeMatch(nextId));
412
+ holder.filterId = nextId;
413
+ }
414
+ setMotion(null);
415
+ // A focused row the filter hides hands its place to its nearest shown parent, or to the first row.
416
+ const focused = focusKey === null ? undefined : store.find(focusKey);
417
+ if (focused && !store.isVisible(focused)) {
418
+ let up = focused.parent;
419
+ while (up && !store.isVisible(up))
420
+ up = up.parent;
421
+ setFocusKey((up ?? store.rows[0])?.key ?? null);
422
+ }
423
+ noteRowRef.current = null;
424
+ readOnlyNotice.hide();
425
+ lockedNotice.hide();
426
+ partialNotice.hide();
427
+ onFilterChange?.(next.id);
428
+ bump();
429
+ }
430
+ /**
431
+ * Changes made elsewhere, written in (the handle's `apply`). The focused row keeps focus; when it leaves the page or
432
+ * moves in it, focus goes to the viewport first, while the DOM is whole, and comes back to it - or, when the item
433
+ * left, to the row now in its place - in the layout effect, as when the window moves (see `releaseFocusLeaving`).
434
+ */
435
+ function applyChanges(changes, rules = {}) {
436
+ if (changes.length === 0)
437
+ return;
438
+ const viewport = viewportRef.current;
439
+ const active = document.activeElement;
440
+ const held = viewport && active instanceof HTMLElement && active !== viewport && viewport.contains(active) ? nodeOfTarget(active) : undefined;
441
+ const heldIndex = held ? store.indexOf(held) : -1;
442
+ const focused = focusKeyRef.current === null ? undefined : store.find(focusKeyRef.current);
443
+ const focusedIndex = focused ? store.indexOf(focused) : -1;
444
+ const valueBefore = valueSignature();
445
+ for (const change of changes) {
446
+ if (change.type === "put")
447
+ store.placeItem(change.item, change.parent, { before: change.before, compare: rules.compare });
448
+ else
449
+ store.removeItem(change.value, change.parent);
450
+ }
451
+ setMotion(null);
452
+ const stillThere = (node, index) => {
453
+ const found = store.find(node.key);
454
+ if (found && store.isVisible(found))
455
+ return found;
456
+ return store.rows.length > 0 ? store.rows[Math.max(0, Math.min(index, store.rows.length - 1))] : undefined;
457
+ };
458
+ if (held && viewport) {
459
+ const next = stillThere(held, heldIndex);
460
+ if (next !== held || store.indexOf(held) !== heldIndex) {
461
+ restoringRef.current = true;
462
+ viewport.focus({ preventScroll: true });
463
+ restoringRef.current = false;
464
+ if (next)
465
+ focusNode(next);
466
+ }
467
+ }
468
+ else if (focused && !(store.find(focused.key) === focused && store.isVisible(focused))) {
469
+ setFocusKey(stillThere(focused, focusedIndex)?.key ?? null);
470
+ }
471
+ emitValueIfChanged(valueBefore);
472
+ bump();
473
+ }
474
+ applyRef.current = applyChanges;
475
+ /** An item revealed before it was loaded, revealed now if the answer just in brought it. */
476
+ function revealPending() {
477
+ const pending = pendingRevealRef.current;
478
+ const node = pending ? store.get(pending.key) : undefined;
479
+ if (!pending || !node)
480
+ return;
481
+ pendingRevealRef.current = null;
482
+ if (pending.focus) {
483
+ focusNode(node);
484
+ return;
485
+ }
486
+ if (store.reveal(node))
487
+ setMotion(null);
488
+ pendingScrollRef.current = store.indexOf(node);
489
+ }
490
+ /** The page's own actions on a row, where they may be offered. */
491
+ function rowActionsOf(node) {
492
+ if (!rowActions || blocked || node.status || node.draft || node.disabledPath)
493
+ return [];
494
+ return rowActions(node.item);
495
+ }
496
+ /** Runs one of them. The row is busy, its actions waiting, while a promise the action returns runs. */
497
+ function runRowAction(node, action) {
498
+ if (blocked || action.disabled || busyKeys.has(node.key))
499
+ return;
500
+ pendingRevealRef.current = null;
501
+ const result = action.onSelect(node.item);
502
+ if (!isPromiseLike(result))
503
+ return;
504
+ const key = node.key;
505
+ setBusy(key, true);
506
+ const done = () => {
507
+ if (mountedRef.current)
508
+ setBusy(key, false);
509
+ };
510
+ result.then(done, done);
511
+ }
348
512
  function onPanelBlur(event) {
349
513
  const next = event.relatedTarget;
350
514
  if (next instanceof Node && panelRef.current?.contains(next))
@@ -354,6 +518,7 @@ export function TreeView(props) {
354
518
  readOnlyNotice.hide();
355
519
  lockedNotice.hide();
356
520
  partialNotice.hide();
521
+ addNotice.hide();
357
522
  return;
358
523
  }
359
524
  // Focus went nowhere: a click on the page, or a focused row that left the DOM. The first is settled after this frame.
@@ -526,6 +691,7 @@ export function TreeView(props) {
526
691
  if (result.change)
527
692
  setMotion(openMotion(result.change));
528
693
  followStatusFocus(current, result.added);
694
+ revealPending();
529
695
  const now = store.pagesOf(current);
530
696
  const count = result.added.length;
531
697
  const of = now?.total !== undefined ? ` (${now.loaded.toLocaleString()} of ${now.total.toLocaleString()})` : "";
@@ -575,6 +741,7 @@ export function TreeView(props) {
575
741
  return;
576
742
  setMotion(null);
577
743
  followStatusFocus(current, []);
744
+ revealPending();
578
745
  setAnnouncement(`Items loaded again${where}.`);
579
746
  bump();
580
747
  };
@@ -881,6 +1048,8 @@ export function TreeView(props) {
881
1048
  }
882
1049
  // Pointer and keys
883
1050
  function onViewportPointerDown(event) {
1051
+ // The user moves on: an item waiting to be revealed is not brought into view under them later.
1052
+ pendingRevealRef.current = null;
884
1053
  // The context menu opens on the row that was pressed, or at the top level on empty space or a status row.
885
1054
  const node = nodeOfTarget(event.target);
886
1055
  setContextKey(node && !node.status ? node.key : null);
@@ -922,6 +1091,7 @@ export function TreeView(props) {
922
1091
  if (event.defaultPrevented || event.target.closest(".nx-tree__edit"))
923
1092
  return;
924
1093
  setContextKey(undefined);
1094
+ pendingRevealRef.current = null;
925
1095
  if (event.key === "Escape" && noteRowRef.current) {
926
1096
  noteRowRef.current = null;
927
1097
  readOnlyNotice.hide();
@@ -990,12 +1160,18 @@ export function TreeView(props) {
990
1160
  setMotion(null);
991
1161
  bump();
992
1162
  }
993
- else if (key === "F2")
994
- startRename(node);
995
- else if (key === "Delete")
996
- void requestDelete(node);
997
- else if (key === "Insert")
998
- startCreate(node);
1163
+ else if (key === "F2" || key === "Delete" || key === "Insert") {
1164
+ // The page's own action for the key, where it gave one; else the tree's own editing.
1165
+ const own = rowActionsOf(node).find(action => action.shortcut === key && !action.disabled);
1166
+ if (own)
1167
+ runRowAction(node, own);
1168
+ else if (key === "F2")
1169
+ startRename(node);
1170
+ else if (key === "Delete")
1171
+ void requestDelete(node);
1172
+ else
1173
+ startCreate(node);
1174
+ }
999
1175
  else if (key.length === 1 && !ctrlKey && !metaKey && !altKey && key !== " ") {
1000
1176
  const now = performance.now();
1001
1177
  const typed = typeaheadRef.current;
@@ -1041,7 +1217,20 @@ export function TreeView(props) {
1041
1217
  contextItems.push({ value: "delete", label: "Delete", icon: _jsx(InlineIcon, { icon: iconTrash }), shortcut: "Delete", tone: "danger", disabled: !allowed("delete", contextNode) });
1042
1218
  }
1043
1219
  }
1220
+ // The page's own actions on the row, after the tree's.
1221
+ const ownActions = contextNode ? rowActionsOf(contextNode) : [];
1222
+ if (ownActions.length > 0 && contextItems.length > 0)
1223
+ contextItems.push({ separator: true });
1224
+ for (const action of ownActions) {
1225
+ contextItems.push({ value: `row:${action.id}`, label: action.label, description: action.description, icon: action.icon, shortcut: action.shortcut, tone: action.tone, disabled: action.disabled });
1226
+ }
1044
1227
  function onContextSelect(action) {
1228
+ if (action.startsWith("row:")) {
1229
+ const own = ownActions.find(each => `row:${each.id}` === action);
1230
+ if (own && contextNode)
1231
+ runRowAction(contextNode, own);
1232
+ return;
1233
+ }
1045
1234
  if (action === "create")
1046
1235
  startCreate(contextNode ?? null);
1047
1236
  else if (action === "update" && contextNode)
@@ -1076,8 +1265,12 @@ export function TreeView(props) {
1076
1265
  },
1077
1266
  reveal: (revealValue, focus = true) => {
1078
1267
  const node = store.get(revealValue);
1079
- if (!node)
1268
+ if (!node) {
1269
+ const key = treeKey(revealValue);
1270
+ pendingRevealRef.current = key === null ? null : { key, focus };
1080
1271
  return;
1272
+ }
1273
+ pendingRevealRef.current = null;
1081
1274
  if (focus) {
1082
1275
  focusNode(node);
1083
1276
  return;
@@ -1099,6 +1292,10 @@ export function TreeView(props) {
1099
1292
  afterCheck();
1100
1293
  },
1101
1294
  getItems: () => store.items(),
1295
+ getItem: itemValue => {
1296
+ const node = store.get(itemValue);
1297
+ return node && !node.draft ? node.item : undefined;
1298
+ },
1102
1299
  startCreate: parentValue => startCreate(parentValue === null ? null : (store.get(parentValue) ?? null)),
1103
1300
  startRename: renameValue => {
1104
1301
  const node = store.get(renameValue);
@@ -1126,6 +1323,7 @@ export function TreeView(props) {
1126
1323
  setMotion(null);
1127
1324
  bump();
1128
1325
  },
1326
+ apply: (changes, rules) => applyChanges(changes, rules),
1129
1327
  }));
1130
1328
  // Rendering
1131
1329
  const showCounts = checkable && cascade && counts;
@@ -1133,6 +1331,8 @@ export function TreeView(props) {
1133
1331
  const rendered = new Set();
1134
1332
  /** How many siblings a row has: all of them on the server when the loader said, loaded or not. */
1135
1333
  function setSize(node) {
1334
+ if (store.filtered)
1335
+ return store.shownChildCount(node.parent);
1136
1336
  const loaded = node.parent ? node.parent.children.length : store.roots.length;
1137
1337
  const total = store.pagesOf(node.parent)?.total;
1138
1338
  return total === undefined ? loaded : Math.max(total, loaded);
@@ -1191,11 +1391,16 @@ export function TreeView(props) {
1191
1391
  if (allowed("delete", node)) {
1192
1392
  actions.push(_jsx(Button, { variant: "ghost", size: "sm", hoverTone: "danger", className: "nx-tree__action", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${node.label}`, title: "Delete (Delete)", tabIndex: -1, onMouseDown: keepEditorFocus, onClick: () => void requestDelete(node) }, "delete"));
1193
1393
  }
1394
+ for (const action of rowActionsOf(node)) {
1395
+ if (!action.inline)
1396
+ continue;
1397
+ 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}`));
1398
+ }
1194
1399
  }
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 => {
1400
+ return (_jsxs("div", { role: ghost ? undefined : "treeitem", className: cx("nx-tree__row", locked && "nx-tree__row--locked", edit && "nx-tree__row--editing", node.key === selectedKey && "nx-tree__row--selected"), style: { "--nx-tree-depth": node.depth }, "data-key": ghost ? undefined : node.key, "data-uid": ghost ? undefined : node.uid, "data-readonly": locked || undefined, tabIndex: ghost ? undefined : !disabled && tabbable === node ? 0 : -1, "aria-level": ghost ? undefined : node.depth + 1, "aria-setsize": ghost ? undefined : setSize(node), "aria-posinset": ghost ? undefined : node.position + 1, "aria-expanded": ghost || !expandable ? undefined : node.expanded, "aria-checked": ghost || !state ? undefined : state === "mixed" ? "mixed" : state === "checked", "aria-selected": !ghost && node.key === selectedKey ? true : undefined, "aria-disabled": ghost ? undefined : locked || undefined, "aria-busy": busy || childrenLoading || undefined, "aria-labelledby": ghost || edit ? undefined : labelElementId, "aria-label": edit ? (edit.mode === "create" ? "New item" : `Renaming ${node.label}`) : undefined, "aria-describedby": ghost || edit ? undefined : descriptionElementId, children: [_jsx("span", { className: "nx-tree__toggle", "aria-hidden": "true", children: expandable && _jsx(InlineIcon, { icon: iconChevronRight }) }), checkable && (_jsx("span", { className: cx("nx-tree__check nx-choice", `nx-choice--${size}`), "aria-hidden": "true", children: _jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", tabIndex: -1, checked: state === "checked", disabled: disabled, readOnly: true, ref: input => {
1196
1401
  if (input)
1197
1402
  input.indeterminate = state === "mixed";
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));
1403
+ } }) })), 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.shownChecked.toLocaleString(), "/", node.shownLeaves.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));
1199
1404
  }
1200
1405
  const end = Math.min(range.end, total);
1201
1406
  const start = Math.min(range.start, end);
@@ -1228,6 +1433,8 @@ export function TreeView(props) {
1228
1433
  }
1229
1434
  const allState = checkable ? store.allState() : null;
1230
1435
  const tally = store.counts();
1436
+ // Under a filter the toolbar counts what is shown, and everything after it.
1437
+ const shownTally = store.filtered ? store.shownCounts() : null;
1231
1438
  const hasParents = store.roots.some(root => root.children.length > 0 || root.expanded);
1232
1439
  const hasLabel = hasContent(label);
1233
1440
  const shownMessage = message ?? (hasContent(error) ? error : null);
@@ -1236,17 +1443,21 @@ export function TreeView(props) {
1236
1443
  const rootWaiting = store.size === 0 && rootPages !== null && (rootPages.status === "idle" || rootPages.status === "loading");
1237
1444
  const waiting = loading || rootWaiting;
1238
1445
  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
1446
+ 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: [activeFilter && filters && (_jsx("div", { className: "nx-tree__filters", children: _jsx(TabButtons, { size: "sm", wrap: true, options: filters.map(filter => ({ value: filter.id, label: filter.label, title: filter.title, disabled })), value: activeFilter.id, onChange: chooseFilter,
1447
+ // An id no form has: the tabs are no value of the form around the tree.
1448
+ form: `${treeId}-filters`, "aria-label": filtersLabel }) })), toolbar && (_jsxs("div", { className: "nx-tree__toolbar", children: [checkable && (_jsx(CheckBox, { size: "sm", className: "nx-tree__all", label: "Select all", checked: allState === "checked", indeterminate: allState === "mixed", disabled: disabled || allState === null || empty, readOnly: readOnly, readOnlyMessage: readOnlyMessage, onValueChange: toggleAll })), _jsxs("span", { className: "nx-tree__summary", "aria-live": "polite", children: [(loading || rootPages?.status === "loading") && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin", "aria-hidden": "true" }), waiting && empty
1240
1449
  ? "Loading…"
1241
1450
  : checkable
1242
- ? `${tally.checked.toLocaleString()} of ${tally.total.toLocaleString()} selected`
1243
- : `${store.size.toLocaleString()} ${store.size === 1 ? "item" : "items"}${lazy ? " loaded" : ""}`, !hasLabel && readOnlyNotice.lock] }), _jsxs("span", { className: "nx-tree__tools", children: [_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsDown }), "aria-label": "Expand all", title: "Expand all", disabled: !hasParents, onClick: expandAll }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsUp }), "aria-label": "Collapse all", title: "Collapse all", disabled: !hasParents, onClick: collapseAll }), creatable && !blocked && (!canChange || canChange("create", null)) && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onMouseDown: keepEditorFocus, onClick: () => startCreate(null), children: "Add" }))] })] })), _jsx(ContextMenu, { items: contextItems, onSelect: onContextSelect, disabled: contextItems.length === 0, "aria-label": "Item actions", children: _jsx("div", { ref: viewportRef, className: "nx-tree__viewport", style: { height, maxHeight: height === undefined ? maxHeight : undefined }, tabIndex: !disabled && !empty && !(tabbable && rendered.has(tabbable.key)) ? 0 : -1, onScroll: event => syncRange(event.currentTarget), onFocus: onViewportFocus, onPointerDown: onViewportPointerDown, onClick: onViewportClick, onKeyDown: onViewportKeyDown, children: empty ? (waiting ? (_jsx("div", { className: "nx-tree__list", "aria-hidden": "true", children: SKELETON_WIDTHS.map((width, index) => (_jsxs("div", { className: "nx-tree__row nx-tree__row--skeleton", style: { "--nx-tree-depth": index % 3 === 0 ? 0 : 1 }, children: [_jsx("span", { className: "nx-tree__toggle" }), _jsx("span", { className: "nx-tree__skeleton", style: { width } })] }, index))) })) : (_jsx("p", { className: "nx-tree__empty", children: emptyMessage }))) : (_jsx("div", { role: "tree", className: "nx-tree__list", "aria-labelledby": hasLabel ? labelId : undefined, "aria-label": hasLabel ? undefined : (ariaLabel ?? "Tree"), "aria-describedby": shownMessage || hasContent(hint) ? messageId : undefined, "aria-disabled": disabled || undefined, "aria-readonly": readOnly || undefined, "aria-busy": waiting || undefined,
1451
+ ? shownTally
1452
+ ? `${shownTally.checked.toLocaleString()} of ${shownTally.total.toLocaleString()} selected · ${tally.checked.toLocaleString()} of ${tally.total.toLocaleString()} in all`
1453
+ : `${tally.checked.toLocaleString()} of ${tally.total.toLocaleString()} selected`
1454
+ : `${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: store.filtered && store.size > 0 && activeFilter ? `No items in "${activeFilter.label}".` : 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
1455
  // As tall as all of its rows at every moment of a commit, not only once it has finished. A scroll that
1245
1456
  // moves the window makes React take the rows that leave it out before it puts the new ones in and sets
1246
1457
  // this padding, and a layout in between - Chrome lays the page out inside the removal of a focused row,
1247
1458
  // and so may any focusout listener that measures - would find the list short of the scroll position and
1248
1459
  // 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] }));
1460
+ style: { boxSizing: "border-box", minHeight: 2 * PAD + total * rowHeight, paddingTop: PAD + start * rowHeight, paddingBottom: PAD + bottom }, children: body })) }) })] }), (shownMessage || hasContent(hint)) && (_jsx("div", { className: "nx-field__footer", children: _jsxs("p", { id: messageId, className: cx("nx-field__message", shownMessage && "nx-field__message--error"), role: message ? "alert" : undefined, children: [shownMessage && _jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: shownMessage ?? hint })] }) })), name && formValues.map(item => _jsx("input", { type: "hidden", name: name, form: form, disabled: disabled, value: String(item) }, String(item))), lazy && (_jsx("span", { className: "nx-visually-hidden", role: "status", "aria-live": "polite", children: announcement })), readOnlyNotice.notice, lockedNotice.notice, partialNotice.notice, addNotice.notice] }));
1250
1461
  }
1251
1462
  /** The name box of a row being added or renamed. Enter saves, Esc cancels, and leaving it saves. */
1252
1463
  function TreeEditor({ initial, select, maxLength, saving, label, invalid, describedBy, onText, onCommit, onCancel }) {
@@ -5,16 +5,29 @@ import type { PopupSize } from "./Popup.tsx";
5
5
  export type MessageTone = "neutral" | "info" | "success" | "warning" | "danger" | "question";
6
6
  export interface MessageBoxOptions {
7
7
  title: ReactNode;
8
- /** Text under the title. */
8
+ /** Text under the title, in the box's muted text. */
9
9
  message?: ReactNode;
10
+ /**
11
+ * What the question is about, in full, after the message and in the normal text colour: a record's details
12
+ * (`<CrudDetails row columns />`), a list. It scrolls with the box's body while the buttons stay in view; give the box a
13
+ * width that suits it (`size: "lg"` or `"xl"`).
14
+ */
15
+ details?: ReactNode;
10
16
  /** Colors the icon: `info`, `success`, `warning`, `danger`, `question`. `neutral` shows no icon. */
11
17
  tone?: MessageTone;
12
18
  /** Replaces the tone's icon; `null` shows none. */
13
19
  icon?: ReactNode | null;
14
20
  /** Default `sm`. */
15
21
  size?: PopupSize;
16
- /** The close button, Esc, and a click outside dismiss the box. Default true. */
22
+ /** The close button and Esc dismiss the box. Default true. */
17
23
  closable?: boolean;
24
+ /**
25
+ * A click outside dismisses the box. Default: as `closable`. `false` keeps a box with much to read (a question with its
26
+ * `details`) open through a stray click, while Esc and the close button still dismiss it.
27
+ */
28
+ closeOnBackdrop?: boolean;
29
+ /** Dismisses the box when it aborts, as Esc would: the form or the page that asked has gone. */
30
+ signal?: AbortSignal;
18
31
  }
19
32
  export interface MessageBoxButton<T extends string = string> {
20
33
  value: T;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { iconAlertCircle, iconAlertTriangle, iconCircleCheck, iconHelpCircle, iconInfoCircle, InlineIcon } from "nexus-icons";
4
4
  import { cx } from "../../Helpers/ClassHelpers.js";
5
5
  import { Button } from "../Documents/Button.js";
@@ -14,40 +14,85 @@ const TONE_ICONS = {
14
14
  question: iconHelpCircle,
15
15
  };
16
16
  let promptCounter = 0;
17
+ let messageCounter = 0;
18
+ /*
19
+ * A held Enter, or one pressed again at once (a double press), never answers a box. The Enter that opened a question (in
20
+ * a form's box) would otherwise press the Confirm it gave the focus to - by the key's repeat, or by the second tap of a
21
+ * double press - before anything in the box was read. Any Enter within this long of the one before it is held.
22
+ */
23
+ const HASTY_ENTER_MS = 300;
24
+ let lastEnterAt = Number.NEGATIVE_INFINITY;
25
+ function holdHastyEnter(event) {
26
+ if (event.key !== "Enter")
27
+ return;
28
+ const gap = event.timeStamp - lastEnterAt;
29
+ lastEnterAt = event.timeStamp;
30
+ if (!(event.target instanceof Element) || !event.target.closest(".nx-popup--message"))
31
+ return;
32
+ if (event.repeat || gap < HASTY_ENTER_MS)
33
+ event.preventDefault();
34
+ }
35
+ // Listening from the start: the Enter that opens a box comes before the box.
36
+ if (typeof window !== "undefined")
37
+ window.addEventListener("keydown", holdHastyEnter, true);
17
38
  function toneIcon(tone, icon) {
18
39
  if (icon !== undefined)
19
40
  return icon;
20
41
  const data = TONE_ICONS[tone];
21
42
  return data ? _jsx(InlineIcon, { icon: data }) : null;
22
43
  }
44
+ /** The muted message, then the details in the normal text colour; each with an id the box is described by. */
45
+ function messageBody(message, details, id) {
46
+ return (_jsxs(_Fragment, { children: [message && (_jsx("div", { id: `${id}-text`, className: "nx-message__text", children: message })), details && (_jsx("div", { id: `${id}-details`, className: "nx-message__details", children: details }))] }));
47
+ }
48
+ /**
49
+ * What describes a box to a screen reader: its message and its details, read with the title as the focus enters it, so
50
+ * a question's Confirm is never reached before what it confirms was heard.
51
+ */
52
+ function describedBy(message, details, id) {
53
+ return cx(message ? `${id}-text` : undefined, details ? `${id}-details` : undefined) || undefined;
54
+ }
55
+ /** Dismisses an open box when the signal aborts; nothing is left listening once it has closed. */
56
+ function closeOnAbort(popup, signal) {
57
+ if (!signal)
58
+ return;
59
+ const dismiss = () => popup.close();
60
+ signal.addEventListener("abort", dismiss, { once: true });
61
+ void popup.closed.then(() => signal.removeEventListener("abort", dismiss));
62
+ }
23
63
  /**
24
64
  * A message box with the given buttons. Resolves with the clicked button's value, or `null` when the box was
25
65
  * dismissed with the close button, Esc, or a click outside.
26
66
  */
27
67
  export function showMessageBox(options) {
28
- const { title, message, tone = "neutral", icon, size = "sm", closable = true, buttons } = options;
68
+ const { title, message, details, tone = "neutral", icon, size = "sm", closable = true, closeOnBackdrop = closable, signal, buttons } = options;
69
+ if (signal?.aborted)
70
+ return Promise.resolve(null);
29
71
  const cautious = tone === "danger" || tone === "warning";
30
72
  const explicit = buttons.findIndex(button => button.autoFocus);
31
73
  const focusIndex = explicit >= 0 ? explicit : cautious ? 0 : buttons.length - 1;
32
74
  let result = null;
75
+ const id = `nx-message-${++messageCounter}`;
33
76
  const popup = openPopup({
34
77
  title,
35
78
  icon: toneIcon(tone, icon),
36
79
  size,
37
80
  closable,
38
- closeOnBackdrop: closable,
81
+ closeOnBackdrop,
39
82
  closeOnEscape: closable,
40
83
  variant: "dialog",
41
84
  minimizable: false,
42
85
  maximizable: false,
43
86
  className: cx("nx-popup--message", `nx-popup--tone-${tone}`),
44
87
  initialFocus: "[data-nx-focus]",
45
- content: message ? _jsx("div", { className: "nx-message__text", children: message }) : null,
88
+ "aria-describedby": describedBy(message, details, id),
89
+ content: message || details ? messageBody(message, details, id) : null,
46
90
  footer: handle => buttons.map((button, index) => (_jsx(Button, { variant: button.variant ?? (index === buttons.length - 1 ? (tone === "danger" ? "danger" : "primary") : "secondary"), "data-nx-focus": index === focusIndex || undefined, onClick: () => {
47
91
  result = button.value;
48
92
  handle.close();
49
93
  }, children: button.label }, button.value))),
50
94
  });
95
+ closeOnAbort(popup, signal);
51
96
  return popup.closed.then(() => result);
52
97
  }
53
98
  function options(input) {
@@ -72,7 +117,9 @@ function confirm(input) {
72
117
  }
73
118
  /** A text box with Cancel and OK. Resolves with the text, or `null` when cancelled. */
74
119
  function prompt(input) {
75
- const { title, message, tone = "neutral", icon, size = "sm", closable = true, label, defaultValue, placeholder, required = true, inputType = "text", confirmLabel, cancelLabel } = options(input);
120
+ const { title, message, details, tone = "neutral", icon, size = "sm", closable = true, closeOnBackdrop = closable, signal, label, defaultValue, placeholder, required = true, inputType = "text", confirmLabel, cancelLabel } = options(input);
121
+ if (signal?.aborted)
122
+ return Promise.resolve(null);
76
123
  const formId = `nx-prompt-${++promptCounter}`;
77
124
  let result = null;
78
125
  const popup = openPopup({
@@ -80,20 +127,22 @@ function prompt(input) {
80
127
  icon: toneIcon(tone, icon),
81
128
  size,
82
129
  closable,
83
- closeOnBackdrop: closable,
130
+ closeOnBackdrop,
84
131
  closeOnEscape: closable,
85
132
  variant: "dialog",
86
133
  minimizable: false,
87
134
  maximizable: false,
88
135
  className: cx("nx-popup--message", `nx-popup--tone-${tone}`),
89
136
  initialFocus: `#${formId} input`,
137
+ "aria-describedby": describedBy(message, details, formId),
90
138
  content: handle => (_jsxs("form", { id: formId, className: "nx-message__form", onSubmit: (event) => {
91
139
  event.preventDefault();
92
140
  result = String(new FormData(event.currentTarget).get("value") ?? "");
93
141
  handle.close();
94
- }, children: [message && _jsx("div", { className: "nx-message__text", children: message }), _jsx(TextBox, { name: "value", label: label, type: inputType, defaultValue: defaultValue, placeholder: placeholder, required: required })] })),
142
+ }, children: [messageBody(message, details, formId), _jsx(TextBox, { name: "value", label: label, type: inputType, defaultValue: defaultValue, placeholder: placeholder, required: required })] })),
95
143
  footer: handle => (_jsxs(_Fragment, { children: [_jsx(Button, { onClick: () => handle.close(), children: cancelLabel ?? "Cancel" }), _jsx(Button, { type: "submit", form: formId, variant: "primary", children: confirmLabel ?? "OK" })] })),
96
144
  });
145
+ closeOnAbort(popup, signal);
97
146
  return popup.closed.then(() => result);
98
147
  }
99
148
  /**