nexus-shared 3.2.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 (187) hide show
  1. package/CHANGELOG.md +1564 -0
  2. package/README.md +1705 -1123
  3. package/dist/Client.Index.d.ts +14 -0
  4. package/dist/Client.Index.js +14 -0
  5. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  6. package/dist/Components/Documents/DocumentFrame.js +83 -0
  7. package/dist/Components/Documents/Menu.d.ts +4 -0
  8. package/dist/Components/Documents/Menu.js +40 -24
  9. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  10. package/dist/Components/Documents/PrintDocument.js +62 -0
  11. package/dist/Components/Documents/Tabs.d.ts +59 -0
  12. package/dist/Components/Documents/Tabs.js +70 -0
  13. package/dist/Components/Forms/ApiForm.js +19 -2
  14. package/dist/Components/Forms/Crud.d.ts +2 -2
  15. package/dist/Components/Forms/Crud.js +576 -243
  16. package/dist/Components/Forms/CrudActions.d.ts +136 -0
  17. package/dist/Components/Forms/CrudActions.js +184 -0
  18. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  19. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +30 -2
  21. package/dist/Components/Forms/CrudParts.js +55 -5
  22. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  23. package/dist/Components/Forms/CrudRowForms.js +114 -0
  24. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  25. package/dist/Components/Forms/CrudScopes.js +76 -0
  26. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  27. package/dist/Components/Forms/CrudTree.js +363 -0
  28. package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
  29. package/dist/Components/Forms/CrudWorkspace.js +169 -0
  30. package/dist/Components/Forms/ExcelImport.js +3 -3
  31. package/dist/Components/Forms/Form.d.ts +4 -4
  32. package/dist/Components/Forms/Form.js +123 -17
  33. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  34. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  35. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  36. package/dist/Components/Forms/RecordFlows.js +282 -0
  37. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  38. package/dist/Components/Forms/SubmitForm.js +195 -10
  39. package/dist/Components/Inputs/CheckBox.js +1 -1
  40. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  41. package/dist/Components/Inputs/ChoiceField.js +28 -2
  42. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  43. package/dist/Components/Inputs/Dropdown.js +5 -5
  44. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  45. package/dist/Components/Inputs/DropdownParts.js +16 -4
  46. package/dist/Components/Inputs/Field.d.ts +7 -1
  47. package/dist/Components/Inputs/Field.js +2 -2
  48. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  49. package/dist/Components/Inputs/GroupForm.js +10 -11
  50. package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
  51. package/dist/Components/Inputs/InputRenderer.js +37 -32
  52. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  53. package/dist/Components/Inputs/PictureInput.js +70 -0
  54. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  55. package/dist/Components/Inputs/RadioGroup.js +8 -3
  56. package/dist/Components/Inputs/RowsInput.d.ts +1 -1
  57. package/dist/Components/Inputs/RowsInput.js +18 -3
  58. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  59. package/dist/Components/Inputs/TabularForm.js +29 -29
  60. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  61. package/dist/Components/Inputs/TextArea.js +11 -2
  62. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  63. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  64. package/dist/Components/Inputs/TreeView.d.ts +53 -2
  65. package/dist/Components/Inputs/TreeView.js +147 -13
  66. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  67. package/dist/Components/Layouts/MessageBox.js +56 -7
  68. package/dist/Components/Layouts/Popup.d.ts +13 -6
  69. package/dist/Components/Layouts/Popup.js +37 -9
  70. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  71. package/dist/Components/Layouts/PopupDock.js +2 -1
  72. package/dist/Components/Layouts/PopupHost.js +5 -4
  73. package/dist/Components/Viewers/DataTable.js +150 -48
  74. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  75. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  76. package/dist/Components/Viewers/DataTableParts.d.ts +21 -5
  77. package/dist/Components/Viewers/DataTableParts.js +26 -14
  78. package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
  79. package/dist/Components/Viewers/OwnerFiles.js +589 -0
  80. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  81. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  82. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  83. package/dist/Components/Viewers/RecordCards.js +509 -0
  84. package/dist/Helpers/BusyHooks.d.ts +6 -0
  85. package/dist/Helpers/BusyHooks.js +39 -0
  86. package/dist/Helpers/CrudBackend.d.ts +20 -2
  87. package/dist/Helpers/CrudBackend.js +46 -2
  88. package/dist/Helpers/CrudColumns.d.ts +53 -6
  89. package/dist/Helpers/CrudColumns.js +98 -7
  90. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  91. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  92. package/dist/Helpers/CrudHelpers.d.ts +81 -2
  93. package/dist/Helpers/CrudHelpers.js +195 -0
  94. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  95. package/dist/Helpers/CrudNarrowPick.js +157 -0
  96. package/dist/Helpers/CrudRowForms.d.ts +15 -0
  97. package/dist/Helpers/CrudRowForms.js +43 -0
  98. package/dist/Helpers/CrudScopes.d.ts +32 -0
  99. package/dist/Helpers/CrudScopes.js +79 -0
  100. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  101. package/dist/Helpers/DropdownHelpers.js +6 -2
  102. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  103. package/dist/Helpers/ExcelHelpers.js +12 -11
  104. package/dist/Helpers/FileAdapter.d.ts +78 -0
  105. package/dist/Helpers/FileAdapter.js +102 -0
  106. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  107. package/dist/Helpers/FilePrimaries.js +138 -0
  108. package/dist/Helpers/FormConfirm.d.ts +32 -0
  109. package/dist/Helpers/FormConfirm.js +40 -0
  110. package/dist/Helpers/FormStore.d.ts +90 -5
  111. package/dist/Helpers/FormStore.js +318 -22
  112. package/dist/Helpers/InputConditions.d.ts +50 -0
  113. package/dist/Helpers/InputConditions.js +168 -0
  114. package/dist/Helpers/InputMemory.d.ts +106 -0
  115. package/dist/Helpers/InputMemory.js +385 -0
  116. package/dist/Helpers/InputNarrowing.d.ts +18 -0
  117. package/dist/Helpers/InputNarrowing.js +67 -0
  118. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  119. package/dist/Helpers/InputParamsHelpers.js +157 -74
  120. package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
  121. package/dist/Helpers/NexusDriveAdapter.js +207 -0
  122. package/dist/Helpers/NumberHelpers.d.ts +95 -0
  123. package/dist/Helpers/NumberHelpers.js +126 -0
  124. package/dist/Helpers/Permissions.js +2 -1
  125. package/dist/Helpers/QrCode.d.ts +26 -0
  126. package/dist/Helpers/QrCode.js +463 -0
  127. package/dist/Helpers/RecordCards.d.ts +205 -0
  128. package/dist/Helpers/RecordCards.js +378 -0
  129. package/dist/Helpers/RequestCache.d.ts +14 -2
  130. package/dist/Helpers/RequestCache.js +22 -3
  131. package/dist/Helpers/RowsStore.d.ts +37 -2
  132. package/dist/Helpers/RowsStore.js +128 -10
  133. package/dist/Helpers/ServerPreview.d.ts +69 -0
  134. package/dist/Helpers/ServerPreview.js +99 -0
  135. package/dist/Helpers/TableColumns.js +4 -3
  136. package/dist/Helpers/TableExport.d.ts +7 -1
  137. package/dist/Helpers/TableExport.js +9 -3
  138. package/dist/Helpers/TableFilters.d.ts +1 -1
  139. package/dist/Helpers/TableFilters.js +24 -23
  140. package/dist/Helpers/TableHelpers.d.ts +23 -2
  141. package/dist/Helpers/TableHelpers.js +51 -10
  142. package/dist/Helpers/TableJoins.d.ts +13 -0
  143. package/dist/Helpers/TableJoins.js +34 -0
  144. package/dist/Helpers/TableStore.d.ts +21 -1
  145. package/dist/Helpers/TableStore.js +64 -7
  146. package/dist/Helpers/TreeStore.d.ts +81 -0
  147. package/dist/Helpers/TreeStore.js +365 -0
  148. package/dist/Interfaces/CrudInterfaces.d.ts +465 -5
  149. package/dist/Interfaces/FileInterfaces.d.ts +162 -0
  150. package/dist/Interfaces/FileInterfaces.js +4 -0
  151. package/dist/Interfaces/FormInterfaces.d.ts +391 -30
  152. package/dist/Interfaces/FormInterfaces.js +79 -1
  153. package/dist/Interfaces/InputInterfaces.d.ts +15 -3
  154. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  155. package/dist/Interfaces/RecordInterfaces.js +1 -0
  156. package/dist/Interfaces/TableInterfaces.d.ts +74 -1
  157. package/dist/Services/ExcelApi.js +5 -0
  158. package/dist/Services/FileApi.d.ts +13 -0
  159. package/dist/Services/FileApi.js +29 -0
  160. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  161. package/dist/Services/ImageEditorLoader.js +51 -0
  162. package/dist/Services/JoinSources.d.ts +37 -5
  163. package/dist/Services/JoinSources.js +90 -14
  164. package/dist/Services/ModuleImport.d.ts +7 -0
  165. package/dist/Services/ModuleImport.js +22 -0
  166. package/dist/Shared.Index.d.ts +16 -0
  167. package/dist/Shared.Index.js +16 -0
  168. package/package.json +7 -2
  169. package/src/Styles/Nexus.Avatar.css +6 -5
  170. package/src/Styles/Nexus.Button.css +10 -4
  171. package/src/Styles/Nexus.Choice.css +16 -0
  172. package/src/Styles/Nexus.Crud.css +383 -9
  173. package/src/Styles/Nexus.Dropdown.css +8 -0
  174. package/src/Styles/Nexus.Files.css +410 -0
  175. package/src/Styles/Nexus.Form.css +5 -2
  176. package/src/Styles/Nexus.Index.css +4 -0
  177. package/src/Styles/Nexus.Input.css +42 -0
  178. package/src/Styles/Nexus.Menu.css +10 -1
  179. package/src/Styles/Nexus.Popup.css +77 -1
  180. package/src/Styles/Nexus.Print.css +288 -0
  181. package/src/Styles/Nexus.QrCode.css +12 -0
  182. package/src/Styles/Nexus.Record.css +430 -0
  183. package/src/Styles/Nexus.Tab.Buttons.css +87 -22
  184. package/src/Styles/Nexus.Table.css +67 -5
  185. package/src/Styles/Nexus.Themes.css +12 -6
  186. package/src/Styles/Nexus.Tokens.css +8 -3
  187. package/src/Styles/Nexus.Tree.css +31 -1
@@ -5,7 +5,7 @@ import { isValidElement, useEffect, useId, useImperativeHandle, useLayoutEffect,
5
5
  import { afterAnimation } from "../../Helpers/AnimationHelpers.js";
6
6
  import { cx } from "../../Helpers/ClassHelpers.js";
7
7
  import { isPromiseLike, REQUEST_CACHE_LIFETIME } from "../../Helpers/RequestCache.js";
8
- import { expireTreePages, TREE_DEFAULT_KEYS, treePageKey, treePages, TreeStore, treeStatusKey, } from "../../Helpers/TreeStore.js";
8
+ import { expireTreePages, subscribeTreeChanges, TREE_DEFAULT_KEYS, treeKey, treePageKey, treePages, TreeStore, treeStatusKey, } from "../../Helpers/TreeStore.js";
9
9
  import { Button } from "../Documents/Button.js";
10
10
  import { ContextMenu } from "../Documents/Menu.js";
11
11
  import { messageBox } from "../Layouts/MessageBox.js";
@@ -56,7 +56,7 @@ const sameItems = (a, b) => a === b || (a.length === b.length && a.every((item,
56
56
  * loadChildren={({ parent, skip, take, signal }) => loadRegions(parent, skip, take, signal)} />
57
57
  */
58
58
  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;
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;
60
60
  const generatedId = useId();
61
61
  const treeId = id ?? generatedId;
62
62
  const labelId = `${treeId}-label`;
@@ -142,6 +142,8 @@ export function TreeView(props) {
142
142
  const pendingScrollRef = useRef(null);
143
143
  const pendingFocusRef = useRef(null);
144
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);
145
147
  const blocked = readOnly || disabled;
146
148
  const noticeAnchor = (target) => {
147
149
  const row = target.closest(".nx-tree__row");
@@ -150,6 +152,10 @@ export function TreeView(props) {
150
152
  const readOnlyNotice = useReadOnlyNotice(readOnly && !disabled, viewportRef, readOnlyMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
151
153
  const lockedNotice = useReadOnlyNotice(!blocked, viewportRef, lockedMessage, { anchor: noticeAnchor, typing: false, lock: "label" });
152
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);
153
159
  // The row a note points at: when it leaves the page (scrolled out of the window), the note goes too.
154
160
  const noteRowRef = useRef(null);
155
161
  function showNote(notice, target) {
@@ -319,6 +325,13 @@ export function TreeView(props) {
319
325
  });
320
326
  };
321
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]);
322
335
  // Open parents with rows in view that need pages: the top level at first, a parent a refresh made stale, one whose
323
336
  // request a refresh forgot. A stale parent out of view waits until it scrolls in, and a closed one until it opens; a
324
337
  // failed one waits for Retry, so a server that is down is not asked again and again.
@@ -345,6 +358,88 @@ export function TreeView(props) {
345
358
  function onPanelFocus() {
346
359
  hadFocusRef.current = true;
347
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
+ }
348
443
  function onPanelBlur(event) {
349
444
  const next = event.relatedTarget;
350
445
  if (next instanceof Node && panelRef.current?.contains(next))
@@ -354,6 +449,7 @@ export function TreeView(props) {
354
449
  readOnlyNotice.hide();
355
450
  lockedNotice.hide();
356
451
  partialNotice.hide();
452
+ addNotice.hide();
357
453
  return;
358
454
  }
359
455
  // 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 +622,7 @@ export function TreeView(props) {
526
622
  if (result.change)
527
623
  setMotion(openMotion(result.change));
528
624
  followStatusFocus(current, result.added);
625
+ revealPending();
529
626
  const now = store.pagesOf(current);
530
627
  const count = result.added.length;
531
628
  const of = now?.total !== undefined ? ` (${now.loaded.toLocaleString()} of ${now.total.toLocaleString()})` : "";
@@ -575,6 +672,7 @@ export function TreeView(props) {
575
672
  return;
576
673
  setMotion(null);
577
674
  followStatusFocus(current, []);
675
+ revealPending();
578
676
  setAnnouncement(`Items loaded again${where}.`);
579
677
  bump();
580
678
  };
@@ -881,6 +979,8 @@ export function TreeView(props) {
881
979
  }
882
980
  // Pointer and keys
883
981
  function onViewportPointerDown(event) {
982
+ // The user moves on: an item waiting to be revealed is not brought into view under them later.
983
+ pendingRevealRef.current = null;
884
984
  // The context menu opens on the row that was pressed, or at the top level on empty space or a status row.
885
985
  const node = nodeOfTarget(event.target);
886
986
  setContextKey(node && !node.status ? node.key : null);
@@ -922,6 +1022,7 @@ export function TreeView(props) {
922
1022
  if (event.defaultPrevented || event.target.closest(".nx-tree__edit"))
923
1023
  return;
924
1024
  setContextKey(undefined);
1025
+ pendingRevealRef.current = null;
925
1026
  if (event.key === "Escape" && noteRowRef.current) {
926
1027
  noteRowRef.current = null;
927
1028
  readOnlyNotice.hide();
@@ -990,12 +1091,18 @@ export function TreeView(props) {
990
1091
  setMotion(null);
991
1092
  bump();
992
1093
  }
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);
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
+ }
999
1106
  else if (key.length === 1 && !ctrlKey && !metaKey && !altKey && key !== " ") {
1000
1107
  const now = performance.now();
1001
1108
  const typed = typeaheadRef.current;
@@ -1041,7 +1148,20 @@ export function TreeView(props) {
1041
1148
  contextItems.push({ value: "delete", label: "Delete", icon: _jsx(InlineIcon, { icon: iconTrash }), shortcut: "Delete", tone: "danger", disabled: !allowed("delete", contextNode) });
1042
1149
  }
1043
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
+ }
1044
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
+ }
1045
1165
  if (action === "create")
1046
1166
  startCreate(contextNode ?? null);
1047
1167
  else if (action === "update" && contextNode)
@@ -1076,8 +1196,12 @@ export function TreeView(props) {
1076
1196
  },
1077
1197
  reveal: (revealValue, focus = true) => {
1078
1198
  const node = store.get(revealValue);
1079
- if (!node)
1199
+ if (!node) {
1200
+ const key = treeKey(revealValue);
1201
+ pendingRevealRef.current = key === null ? null : { key, focus };
1080
1202
  return;
1203
+ }
1204
+ pendingRevealRef.current = null;
1081
1205
  if (focus) {
1082
1206
  focusNode(node);
1083
1207
  return;
@@ -1099,6 +1223,10 @@ export function TreeView(props) {
1099
1223
  afterCheck();
1100
1224
  },
1101
1225
  getItems: () => store.items(),
1226
+ getItem: itemValue => {
1227
+ const node = store.get(itemValue);
1228
+ return node && !node.draft ? node.item : undefined;
1229
+ },
1102
1230
  startCreate: parentValue => startCreate(parentValue === null ? null : (store.get(parentValue) ?? null)),
1103
1231
  startRename: renameValue => {
1104
1232
  const node = store.get(renameValue);
@@ -1126,6 +1254,7 @@ export function TreeView(props) {
1126
1254
  setMotion(null);
1127
1255
  bump();
1128
1256
  },
1257
+ apply: (changes, rules) => applyChanges(changes, rules),
1129
1258
  }));
1130
1259
  // Rendering
1131
1260
  const showCounts = checkable && cascade && counts;
@@ -1191,8 +1320,13 @@ export function TreeView(props) {
1191
1320
  if (allowed("delete", node)) {
1192
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"));
1193
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
+ }
1194
1328
  }
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 => {
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 => {
1196
1330
  if (input)
1197
1331
  input.indeterminate = state === "mixed";
1198
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));
@@ -1236,17 +1370,17 @@ export function TreeView(props) {
1236
1370
  const rootWaiting = store.size === 0 && rootPages !== null && (rootPages.status === "idle" || rootPages.status === "loading");
1237
1371
  const waiting = loading || rootWaiting;
1238
1372
  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
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
1240
1374
  ? "Loading…"
1241
1375
  : checkable
1242
1376
  ? `${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,
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,
1244
1378
  // As tall as all of its rows at every moment of a commit, not only once it has finished. A scroll that
1245
1379
  // moves the window makes React take the rows that leave it out before it puts the new ones in and sets
1246
1380
  // this padding, and a layout in between - Chrome lays the page out inside the removal of a focused row,
1247
1381
  // and so may any focusout listener that measures - would find the list short of the scroll position and
1248
1382
  // 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] }));
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] }));
1250
1384
  }
1251
1385
  /** The name box of a row being added or renamed. Enter saves, Esc cancels, and leaving it saves. */
1252
1386
  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
  /**
@@ -50,8 +50,9 @@ export interface PopupProps {
50
50
  closeOnBackdrop?: boolean;
51
51
  /**
52
52
  * The minimize button: the popup steps aside to a bar at the bottom right, keeping everything typed in it, and the
53
- * page can be used again. Default: when `closable`, since a popup that must be answered cannot be put aside.
54
- * Needs `<PopupHost />`, which shows the bars.
53
+ * page can be used again. Default false: only a popup that asks has it. A CRUD page's Add and Edit forms and its row
54
+ * forms ask (a form put aside while the list behind it is read); message boxes, viewers, galleries, histories and
55
+ * the rest do not. Needs `<PopupHost />`, which shows the bars.
55
56
  */
56
57
  minimizable?: boolean;
57
58
  /** The maximize button, and a double-click on the title bar: the popup fills the window. Default true. */
@@ -76,17 +77,23 @@ export interface PopupProps {
76
77
  initialFocus?: string;
77
78
  /** Name for screen readers when there is no title. */
78
79
  "aria-label"?: string;
80
+ /**
81
+ * Ids of elements inside the popup that describe it to screen readers, after its `description`: a message box names
82
+ * its message and its details, so they are read with the title as the focus enters.
83
+ */
84
+ "aria-describedby"?: string;
79
85
  className?: string;
80
86
  children?: ReactNode;
81
87
  }
82
88
  /**
83
- * A modal window on the native <dialog>, with the buttons of a desktop window: minimize, maximize, and close. It sits
84
- * in the top layer above everything, dims and locks the page behind it, traps Tab, closes with Esc or a click outside,
85
- * and returns focus to where it came from. The title bar moves it; a double-click there maximizes it. Minimized, it
89
+ * A modal window on the native <dialog>, with the buttons of a desktop window: maximize and close, and minimize when
90
+ * it asks (`minimizable`: a CRUD page's Add and Edit). It sits in the top layer above everything, dims and locks the
91
+ * page behind it, traps Tab, closes with Esc or a click outside, and returns focus to where it came from. The title
92
+ * bar moves it; a double-click there maximizes it. Minimized, it
86
93
  * waits as a bar at the bottom right with its content kept, while the page can be used. The body scrolls while the
87
94
  * title and the footer stay. The content mounts while the popup is open, so a form inside starts fresh every time.
88
95
  *
89
96
  * @example
90
97
  * <Popup open={open} onClose={() => setOpen(false)} title="Edit booking" footer={<Button variant="primary">Save</Button>}>…</Popup>
91
98
  */
92
- export declare function Popup({ open, onClose, onClosed, title, description, icon, variant, size, footer, closable, closeOnEscape, closeOnBackdrop, minimizable, maximizable, movable, reopen, windowState: windowStateProp, defaultWindowState, onWindowStateChange, flush, initialFocus, "aria-label": ariaLabel, className, children, }: PopupProps): import("react").JSX.Element;
99
+ export declare function Popup({ open, onClose, onClosed, title, description, icon, variant, size, footer, closable, closeOnEscape, closeOnBackdrop, minimizable, maximizable, movable, reopen, windowState: windowStateProp, defaultWindowState, onWindowStateChange, flush, initialFocus, "aria-label": ariaLabel, "aria-describedby": ariaDescribedBy, className, children, }: PopupProps): import("react").JSX.Element;