nexus-shared 2.0.0 → 3.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 (176) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -21
  3. package/README.md +1084 -903
  4. package/dist/Client.Index.d.ts +14 -0
  5. package/dist/Client.Index.js +14 -0
  6. package/dist/Components/Chats/Chat.d.ts +28 -0
  7. package/dist/Components/Chats/Chat.js +19 -0
  8. package/dist/Components/Chats/ChatButton.d.ts +26 -0
  9. package/dist/Components/Chats/ChatButton.js +41 -0
  10. package/dist/Components/Chats/ChatComposer.d.ts +41 -0
  11. package/dist/Components/Chats/ChatComposer.js +177 -0
  12. package/dist/Components/Chats/ChatConversations.d.ts +35 -0
  13. package/dist/Components/Chats/ChatConversations.js +38 -0
  14. package/dist/Components/Chats/ChatPanel.d.ts +66 -0
  15. package/dist/Components/Chats/ChatPanel.js +94 -0
  16. package/dist/Components/Chats/ChatParts.d.ts +87 -0
  17. package/dist/Components/Chats/ChatParts.js +96 -0
  18. package/dist/Components/Chats/ChatThread.d.ts +22 -0
  19. package/dist/Components/Chats/ChatThread.js +96 -0
  20. package/dist/Components/Documents/Button.d.ts +6 -1
  21. package/dist/Components/Documents/Button.js +2 -2
  22. package/dist/Components/Documents/Menu.js +44 -28
  23. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  24. package/dist/Components/Documents/SplitButton.js +12 -8
  25. package/dist/Components/Documents/TabButtons.d.ts +29 -8
  26. package/dist/Components/Documents/TabButtons.js +24 -5
  27. package/dist/Components/Forms/ApiForm.d.ts +6 -4
  28. package/dist/Components/Forms/ApiForm.js +15 -14
  29. package/dist/Components/Forms/Crud.js +342 -71
  30. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  31. package/dist/Components/Forms/CrudParts.js +37 -5
  32. package/dist/Components/Forms/ExcelImport.d.ts +58 -0
  33. package/dist/Components/Forms/ExcelImport.js +319 -0
  34. package/dist/Components/Forms/Form.js +9 -13
  35. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  36. package/dist/Components/Forms/SubmitForm.js +23 -12
  37. package/dist/Components/Inputs/DateTimePicker.js +2 -1
  38. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  39. package/dist/Components/Inputs/DropdownParts.js +4 -6
  40. package/dist/Components/Inputs/GroupForm.js +4 -2
  41. package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
  42. package/dist/Components/Inputs/InputRenderer.js +1 -1
  43. package/dist/Components/Inputs/NumberBox.js +24 -2
  44. package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
  45. package/dist/Components/Inputs/RowsInput.d.ts +12 -1
  46. package/dist/Components/Inputs/RowsInput.js +55 -6
  47. package/dist/Components/Inputs/Slider.js +4 -1
  48. package/dist/Components/Inputs/TabularForm.d.ts +7 -3
  49. package/dist/Components/Inputs/TabularForm.js +117 -20
  50. package/dist/Components/Inputs/TimePanel.js +4 -3
  51. package/dist/Components/Inputs/TimePicker.js +2 -1
  52. package/dist/Components/Layouts/Popup.js +1 -4
  53. package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
  54. package/dist/Components/Layouts/ThemePicker.js +77 -6
  55. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  56. package/dist/Components/Layouts/ThemeScript.js +4 -2
  57. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  58. package/dist/Components/Layouts/ThemeSwitcher.js +39 -18
  59. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  60. package/dist/Components/Viewers/Avatar.js +21 -0
  61. package/dist/Components/Viewers/DataTable.js +262 -60
  62. package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
  63. package/dist/Components/Viewers/DataTableColumns.js +200 -0
  64. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  65. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  66. package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
  67. package/dist/Components/Viewers/DataTableParts.js +24 -12
  68. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  69. package/dist/Helpers/AnimationHelpers.js +20 -2
  70. package/dist/Helpers/ApiClient.d.ts +22 -0
  71. package/dist/Helpers/ApiClient.js +4 -1
  72. package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
  73. package/dist/Helpers/ApiFormHelpers.js +73 -9
  74. package/dist/Helpers/ApiModules.d.ts +0 -4
  75. package/dist/Helpers/ApiModules.js +1 -0
  76. package/dist/Helpers/ApiResponses.d.ts +14 -4
  77. package/dist/Helpers/ApiResponses.js +116 -25
  78. package/dist/Helpers/ApiRoutes.d.ts +0 -10
  79. package/dist/Helpers/ApiRoutes.js +2 -10
  80. package/dist/Helpers/ChatBackend.d.ts +134 -0
  81. package/dist/Helpers/ChatBackend.js +332 -0
  82. package/dist/Helpers/ChatHelpers.d.ts +189 -0
  83. package/dist/Helpers/ChatHelpers.js +486 -0
  84. package/dist/Helpers/ChatHooks.d.ts +81 -0
  85. package/dist/Helpers/ChatHooks.js +175 -0
  86. package/dist/Helpers/ChatStore.d.ts +132 -0
  87. package/dist/Helpers/ChatStore.js +394 -0
  88. package/dist/Helpers/ChatSummaries.d.ts +39 -0
  89. package/dist/Helpers/ChatSummaries.js +118 -0
  90. package/dist/Helpers/CrudBackend.d.ts +54 -9
  91. package/dist/Helpers/CrudBackend.js +103 -6
  92. package/dist/Helpers/CrudHelpers.d.ts +39 -4
  93. package/dist/Helpers/CrudHelpers.js +76 -11
  94. package/dist/Helpers/ExcelBackend.d.ts +106 -0
  95. package/dist/Helpers/ExcelBackend.js +296 -0
  96. package/dist/Helpers/ExcelHelpers.d.ts +126 -0
  97. package/dist/Helpers/ExcelHelpers.js +344 -0
  98. package/dist/Helpers/FormStore.d.ts +11 -5
  99. package/dist/Helpers/FormStore.js +44 -6
  100. package/dist/Helpers/MessageBuilder.js +1 -2
  101. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  102. package/dist/Helpers/NumberHelpers.js +41 -3
  103. package/dist/Helpers/PopoverHelpers.d.ts +46 -9
  104. package/dist/Helpers/PopoverHelpers.js +163 -24
  105. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  106. package/dist/Helpers/PopoverHooks.js +62 -0
  107. package/dist/Helpers/RowsStore.d.ts +14 -3
  108. package/dist/Helpers/RowsStore.js +37 -3
  109. package/dist/Helpers/TableColumns.d.ts +7 -0
  110. package/dist/Helpers/TableColumns.js +41 -13
  111. package/dist/Helpers/TableExport.js +12 -12
  112. package/dist/Helpers/TableFilters.d.ts +25 -0
  113. package/dist/Helpers/TableFilters.js +162 -0
  114. package/dist/Helpers/TableHelpers.d.ts +15 -4
  115. package/dist/Helpers/TableHelpers.js +28 -4
  116. package/dist/Helpers/TableStore.d.ts +11 -3
  117. package/dist/Helpers/TableStore.js +16 -5
  118. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  119. package/dist/Helpers/ThemeHelpers.js +46 -9
  120. package/dist/Helpers/UserPreferences.d.ts +14 -0
  121. package/dist/Helpers/UserPreferences.js +99 -0
  122. package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
  123. package/dist/Interfaces/ApiInterfaces.js +16 -10
  124. package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
  125. package/dist/Interfaces/ChatInterfaces.js +1 -0
  126. package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
  127. package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
  128. package/dist/Interfaces/ExcelInterfaces.js +45 -0
  129. package/dist/Interfaces/FormInterfaces.d.ts +85 -7
  130. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  131. package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
  132. package/dist/Interfaces/PreferenceInterfaces.js +28 -0
  133. package/dist/Interfaces/TableInterfaces.d.ts +116 -9
  134. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  135. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  136. package/dist/Services/BrowserApi.d.ts +18 -1
  137. package/dist/Services/BrowserApi.js +18 -0
  138. package/dist/Services/ChatApi.d.ts +37 -0
  139. package/dist/Services/ChatApi.js +98 -0
  140. package/dist/Services/ExcelApi.d.ts +48 -0
  141. package/dist/Services/ExcelApi.js +196 -0
  142. package/dist/Services/ServerApi.d.ts +6 -1
  143. package/dist/Services/ServerApi.js +3 -0
  144. package/dist/Services/ThemeService.d.ts +18 -2
  145. package/dist/Services/ThemeService.js +80 -16
  146. package/dist/Shared.Index.d.ts +10 -0
  147. package/dist/Shared.Index.js +10 -0
  148. package/package.json +85 -82
  149. package/src/Styles/Nexus.Avatar.css +79 -0
  150. package/src/Styles/Nexus.Base.css +117 -70
  151. package/src/Styles/Nexus.Button.css +180 -151
  152. package/src/Styles/Nexus.Calendar.css +273 -275
  153. package/src/Styles/Nexus.Chat.css +1443 -0
  154. package/src/Styles/Nexus.Choice.css +343 -345
  155. package/src/Styles/Nexus.Crud.css +168 -84
  156. package/src/Styles/Nexus.DateTime.css +81 -81
  157. package/src/Styles/Nexus.Dropdown.css +725 -774
  158. package/src/Styles/Nexus.Editor.css +295 -297
  159. package/src/Styles/Nexus.Excel.css +389 -0
  160. package/src/Styles/Nexus.Form.css +279 -298
  161. package/src/Styles/Nexus.Index.css +37 -34
  162. package/src/Styles/Nexus.Input.css +602 -637
  163. package/src/Styles/Nexus.Markdown.css +237 -265
  164. package/src/Styles/Nexus.Menu.css +237 -249
  165. package/src/Styles/Nexus.Popup.css +724 -731
  166. package/src/Styles/Nexus.RichText.css +2 -32
  167. package/src/Styles/Nexus.Rows.css +637 -566
  168. package/src/Styles/Nexus.Slider.css +276 -278
  169. package/src/Styles/Nexus.Tab.Buttons.css +432 -364
  170. package/src/Styles/Nexus.Table.css +1386 -1014
  171. package/src/Styles/Nexus.Theme.Picker.css +286 -159
  172. package/src/Styles/Nexus.Themes.css +194 -194
  173. package/src/Styles/Nexus.Time.css +144 -147
  174. package/src/Styles/Nexus.Toast.css +263 -265
  175. package/src/Styles/Nexus.Tokens.css +89 -72
  176. package/src/Styles/Nexus.Tree.css +431 -435
@@ -1,19 +1,30 @@
1
+ /** Which side of its anchor a panel opened on: `below` carries its arrow on its top edge, `above` on its bottom edge. */
2
+ export type PanelSide = "below" | "above";
1
3
  export type PopoverAlign = "start" | "end";
2
4
  export interface PlaceUnderAnchorOptions {
3
5
  /** Which edge of the anchor the panel lines up with. Default `start` (the left edge). */
4
6
  align?: PopoverAlign;
5
- /** Space between the anchor and the panel, in pixels. Default 4. */
7
+ /** Space between the anchor and the panel, in pixels. Default 4, or the arrow's reach while it is drawn. */
6
8
  gap?: number;
7
9
  /** Space kept from the window's edges, in pixels. Default 8. */
8
10
  margin?: number;
11
+ /**
12
+ * Draws an arrow on the panel's edge, aimed at the middle of the anchor. Default true: a panel opened from a button
13
+ * says which button. Pass false for a panel that opens flush under a field and is read as part of it.
14
+ */
15
+ arrow?: boolean;
16
+ /**
17
+ * What the arrow points at, when the panel lines up with more than that: the half of a split button that opened it.
18
+ * Default: the anchor itself.
19
+ */
20
+ arrowAt?: DOMRect;
9
21
  }
10
22
  /**
11
- * Places a panel under an anchor, or above it when there is no room below, aligned to the anchor's start or end
12
- * edge and kept inside the window. Returns whether the panel went above.
23
+ * Places a panel under an anchor, or above it when it fits only there, or on the side with more room when it fits on
24
+ * neither; aligned to the anchor's start or end edge and kept inside the window, with an arrow aimed at the anchor. A
25
+ * panel that scrolls (`.nx-popover__scroll`) is never taller than the room on its side. Returns whether it went above.
13
26
  */
14
27
  export declare function placeUnderAnchor(panel: HTMLElement, anchor: DOMRect, options?: PlaceUnderAnchorOptions): boolean;
15
- /** Which side of its anchor a list panel opened on. */
16
- export type PanelSide = "below" | "above";
17
28
  export interface PlaceListPanelOptions {
18
29
  /** The side of an earlier call, kept while it has `minHeight` of room, so a list that loads more items does not jump across its anchor. */
19
30
  side?: PanelSide;
@@ -30,14 +41,40 @@ export interface PlaceListPanelOptions {
30
41
  * Places a list panel under its anchor, at least as wide as the anchor, or above it when the room below is short and
31
42
  * there is more above. The panel's height is capped to the room on its side, so a long list scrolls inside the window
32
43
  * instead of running past it. Returns the side, to pass back while the panel stays open.
44
+ *
45
+ * It draws no arrow: the panel is as wide as the field and sits flush under it, so it is already read as that field's
46
+ * list, and an arrow over a field's own border would look like a fault.
33
47
  */
34
48
  export declare function placeListPanel(panel: HTMLElement, anchor: DOMRect, options?: PlaceListPanelOptions): PanelSide;
49
+ export interface PlaceAtPointOptions {
50
+ /** Space kept from the window's edges, in pixels. Default 8. */
51
+ margin?: number;
52
+ /**
53
+ * Draws an arrow with its tip on the point, so the menu says where it was opened. Default true. The panel then
54
+ * stands the arrow's reach away from the point, and its corner no longer sits on it.
55
+ */
56
+ arrow?: boolean;
57
+ }
35
58
  /**
36
59
  * Places a panel at a point, such as where the user right-clicked: to the right of and below the point when there
37
- * is room, otherwise to the left of or above it, and never past the window's edges.
60
+ * is room, otherwise to the left of or above it, and never past the window's edges. With an arrow, the panel is set
61
+ * back far enough along both axes for the arrow's tip to land on the point. A panel that scrolls is capped to the
62
+ * room on its side, as under an anchor.
63
+ */
64
+ export declare function placeAtPoint(panel: HTMLElement, x: number, y: number, options?: PlaceAtPointOptions): void;
65
+ /**
66
+ * Runs `place` again, once per frame at most, while the page scrolls, the window changes size, or `panel` does (its
67
+ * content grew). Returns the stop function. A frame later, never inside the observer, so a panel that `place` resizes
68
+ * is measured once more and settles instead of looping.
69
+ */
70
+ export declare function followAnchor(place: () => void, panel?: Element): () => void;
71
+ /**
72
+ * Shows a panel with its scroller (`.nx-popover__scroll`) at the top. The browser keeps where a panel was scrolled
73
+ * while it was shut, and a list that opens part-way down has lost its heading and its first rows. Does nothing on
74
+ * browsers without popover support.
38
75
  */
39
- export declare function placeAtPoint(panel: HTMLElement, x: number, y: number, margin?: number): void;
40
- /** Runs `place` again, once per frame at most, while the page scrolls or the window changes size. Returns the stop function. */
41
- export declare function followAnchor(place: () => void): () => void;
76
+ export declare function showPanel(panel: HTMLElement): void;
42
77
  /** Whether the popover is shown. Safe on browsers without popover support, where it is never "open". */
43
78
  export declare function isPopoverOpen(panel: HTMLElement | null): boolean;
79
+ /** How far the arrow reaches past the panel's edge: its depth, less the panel's border it is drawn over. */
80
+ export declare const ARROW_REACH: number;
@@ -2,29 +2,42 @@
2
2
  * Placing native popovers. A panel is a fixed [popover] element; these helpers set its top and left once it is shown,
3
3
  * keep it inside the window, and move it again while the page scrolls. They measure offsetWidth and offsetHeight,
4
4
  * which ignore the panel's opening transform.
5
+ *
6
+ * A panel that belongs to a button or to a place also gets an arrow, so the panel is read as that button's panel and
7
+ * not as a dialog from nowhere. These helpers work out where the arrow goes, because they are the only code that
8
+ * knows both rectangles; `.nx-popover` draws it from what they write (see "The arrow" below).
5
9
  */
6
10
  /**
7
- * Places a panel under an anchor, or above it when there is no room below, aligned to the anchor's start or end
8
- * edge and kept inside the window. Returns whether the panel went above.
11
+ * Places a panel under an anchor, or above it when it fits only there, or on the side with more room when it fits on
12
+ * neither; aligned to the anchor's start or end edge and kept inside the window, with an arrow aimed at the anchor. A
13
+ * panel that scrolls (`.nx-popover__scroll`) is never taller than the room on its side. Returns whether it went above.
9
14
  */
10
15
  export function placeUnderAnchor(panel, anchor, options = {}) {
11
- const { align = "start", gap = 4, margin = 8 } = options;
12
- const width = panel.offsetWidth;
13
- const height = panel.offsetHeight;
14
- const below = anchor.bottom + gap;
15
- const above = anchor.top - gap - height;
16
- const fitsBelow = below + height <= window.innerHeight - margin;
17
- const top = fitsBelow || above < margin ? below : above;
16
+ const { align = "start", margin = 8, arrow = true } = options;
17
+ // The arrow needs its reach between the panel and the anchor, or it would be drawn over the anchor.
18
+ const gap = Math.max(options.gap ?? 4, arrow ? ARROW_REACH : 0);
19
+ const { side, width, height } = fitToRoom(panel, window.innerHeight - anchor.bottom - gap - margin, anchor.top - gap - margin);
20
+ const top = side === "below" ? anchor.bottom + gap : anchor.top - gap - height;
18
21
  const wanted = align === "end" ? anchor.right - width : anchor.left;
19
- const left = Math.max(margin, Math.min(wanted, window.innerWidth - width - margin));
22
+ // The arrow points at the middle of what it belongs to. The panel's own style is read once: this runs again on
23
+ // every animation frame while the page scrolls under an open panel.
24
+ const target = options.arrowAt ?? anchor;
25
+ const middle = (target.left + target.right) / 2;
26
+ const style = getComputedStyle(panel);
27
+ const left = Math.max(margin, Math.min(arrow ? centred(panel, target, wanted, width, middle, style) : wanted, window.innerWidth - width - margin));
20
28
  panel.style.top = `${Math.round(top)}px`;
21
29
  panel.style.left = `${Math.round(left)}px`;
22
- return top !== below;
30
+ const box = { left: Math.round(left), top: Math.round(top), width, height };
31
+ aimArrow(panel, side, box, arrow ? { at: middle, facing: side === "below" ? target.bottom : target.top, room: gap } : null, style);
32
+ return side === "above";
23
33
  }
24
34
  /**
25
35
  * Places a list panel under its anchor, at least as wide as the anchor, or above it when the room below is short and
26
36
  * there is more above. The panel's height is capped to the room on its side, so a long list scrolls inside the window
27
37
  * instead of running past it. Returns the side, to pass back while the panel stays open.
38
+ *
39
+ * It draws no arrow: the panel is as wide as the field and sits flush under it, so it is already read as that field's
40
+ * list, and an arrow over a field's own border would look like a fault.
28
41
  */
29
42
  export function placeListPanel(panel, anchor, options = {}) {
30
43
  const { maxHeight = 360, minHeight = 180, gap = 4, margin = 8 } = options;
@@ -54,24 +67,59 @@ function setStyle(element, property, value) {
54
67
  if (element.style[property] !== value)
55
68
  element.style[property] = value;
56
69
  }
70
+ /** The room a panel was given on its side, which a panel with a scroller reads as its tallest (`.nx-popover`). */
71
+ const ROOM = "--nx-popover-room";
72
+ /**
73
+ * Picks a panel's side by its whole height: below while it fits there, above while it fits only there, else the side
74
+ * with more room. That side's room is written on the panel, which a panel with `.nx-popover__scroll` takes as its
75
+ * tallest, so it ends inside the window and scrolls inside itself. Returns the side and the size it has.
76
+ *
77
+ * The whole height is the panel's own plus what its scroller hides, read without changing anything: this runs on every
78
+ * scroll while the panel is open, its scroller's own included, and lifting the cap to measure would throw the list
79
+ * back to its top.
80
+ */
81
+ function fitToRoom(panel, roomBelow, roomAbove) {
82
+ const scroller = panel.querySelector(":scope > .nx-popover__scroll");
83
+ const whole = panel.offsetHeight + (scroller ? scroller.scrollHeight - scroller.clientHeight : 0);
84
+ const side = whole <= roomBelow || (whole > roomAbove && roomBelow >= roomAbove) ? "below" : "above";
85
+ const room = `${Math.max(0, Math.floor(side === "below" ? roomBelow : roomAbove))}px`;
86
+ if (panel.style.getPropertyValue(ROOM) !== room)
87
+ panel.style.setProperty(ROOM, room);
88
+ return { side, width: panel.offsetWidth, height: panel.offsetHeight };
89
+ }
57
90
  /**
58
91
  * Places a panel at a point, such as where the user right-clicked: to the right of and below the point when there
59
- * is room, otherwise to the left of or above it, and never past the window's edges.
92
+ * is room, otherwise to the left of or above it, and never past the window's edges. With an arrow, the panel is set
93
+ * back far enough along both axes for the arrow's tip to land on the point. A panel that scrolls is capped to the
94
+ * room on its side, as under an anchor.
60
95
  */
61
- export function placeAtPoint(panel, x, y, margin = 8) {
62
- const width = panel.offsetWidth;
63
- const height = panel.offsetHeight;
64
- let left = x;
65
- let top = y;
96
+ export function placeAtPoint(panel, x, y, options = {}) {
97
+ const { margin = 8, arrow = true } = options;
98
+ // The tip sits `lead` along the panel's edge and `ARROW_REACH` out from it, so the panel starts that much back.
99
+ const lead = arrow ? arrowLead(panel) : 0;
100
+ const reach = arrow ? ARROW_REACH : 0;
101
+ const { side: opens, width, height } = fitToRoom(panel, window.innerHeight - y - reach - margin, y - reach - margin);
102
+ let left = x - lead;
103
+ const top = opens === "below" ? y + reach : y - reach - height;
66
104
  if (left + width > window.innerWidth - margin)
67
- left = x - width;
68
- if (top + height > window.innerHeight - margin)
69
- top = y - height;
70
- panel.style.left = `${Math.round(Math.max(margin, Math.min(left, window.innerWidth - width - margin)))}px`;
71
- panel.style.top = `${Math.round(Math.max(margin, Math.min(top, window.innerHeight - height - margin)))}px`;
105
+ left = x + lead - width;
106
+ const placed = {
107
+ left: Math.round(Math.max(margin, Math.min(left, window.innerWidth - width - margin))),
108
+ top: Math.round(Math.max(margin, Math.min(top, window.innerHeight - height - margin))),
109
+ width,
110
+ height,
111
+ };
112
+ panel.style.left = `${placed.left}px`;
113
+ panel.style.top = `${placed.top}px`;
114
+ const side = placed.top >= y ? "below" : "above";
115
+ aimArrow(panel, side, placed, arrow ? { at: x, facing: y, room: reach } : null);
72
116
  }
73
- /** Runs `place` again, once per frame at most, while the page scrolls or the window changes size. Returns the stop function. */
74
- export function followAnchor(place) {
117
+ /**
118
+ * Runs `place` again, once per frame at most, while the page scrolls, the window changes size, or `panel` does (its
119
+ * content grew). Returns the stop function. A frame later, never inside the observer, so a panel that `place` resizes
120
+ * is measured once more and settles instead of looping.
121
+ */
122
+ export function followAnchor(place, panel) {
75
123
  let frame = 0;
76
124
  const reposition = () => {
77
125
  cancelAnimationFrame(frame);
@@ -79,13 +127,104 @@ export function followAnchor(place) {
79
127
  };
80
128
  window.addEventListener("scroll", reposition, { capture: true, passive: true });
81
129
  window.addEventListener("resize", reposition);
130
+ const observer = new ResizeObserver(reposition);
131
+ if (panel)
132
+ observer.observe(panel);
82
133
  return () => {
83
134
  cancelAnimationFrame(frame);
84
135
  window.removeEventListener("scroll", reposition, { capture: true });
85
136
  window.removeEventListener("resize", reposition);
137
+ observer.disconnect();
86
138
  };
87
139
  }
140
+ /**
141
+ * Shows a panel with its scroller (`.nx-popover__scroll`) at the top. The browser keeps where a panel was scrolled
142
+ * while it was shut, and a list that opens part-way down has lost its heading and its first rows. Does nothing on
143
+ * browsers without popover support.
144
+ */
145
+ export function showPanel(panel) {
146
+ if (typeof panel.showPopover !== "function")
147
+ return;
148
+ panel.showPopover();
149
+ const scroller = panel.querySelector(":scope > .nx-popover__scroll");
150
+ if (scroller)
151
+ scroller.scrollTop = 0;
152
+ }
88
153
  /** Whether the popover is shown. Safe on browsers without popover support, where it is never "open". */
89
154
  export function isPopoverOpen(panel) {
90
155
  return Boolean(panel && typeof panel.matches === "function" && panel.matches(":popover-open"));
91
156
  }
157
+ /* ---------------------------------------------------------------------------------------------------------------
158
+ * The arrow
159
+ *
160
+ * An isosceles tail on the panel's edge, drawn in the panel's own border and background, so the two read as one
161
+ * shape in every theme: `.nx-popover[data-nx-arrow]::before` and `::after` in Nexus.Theme.Picker.css. These
162
+ * functions write the two things only the placement knows: which edge carries the tail (`data-nx-arrow`) and where
163
+ * along that edge its tip sits (`--nx-popover-arrow-x`). Both are cleared when it would point at nothing, and no
164
+ * component has to take part.
165
+ * ------------------------------------------------------------------------------------------------------------- */
166
+ /** The arrow's base, across the panel's edge. Keep in step with `--nx-popover-arrow` in the `.nx-popover` rule. */
167
+ const ARROW_BASE = 26;
168
+ /** The arrow's depth, out from the panel's edge. Keep in step with `--nx-popover-arrow-depth`. */
169
+ const ARROW_DEPTH = 11;
170
+ /** Half the arrow's base: how far from its tip each foot stands. */
171
+ const ARROW_HALF = ARROW_BASE / 2;
172
+ /** How far the arrow reaches past the panel's edge: its depth, less the panel's border it is drawn over. */
173
+ export const ARROW_REACH = ARROW_DEPTH - 1;
174
+ /**
175
+ * How far along the panel's edge the arrow's tip has to stay: its own half base, the panel's rounded corner, and two
176
+ * pixels more, so a foot never stands on the curve and the tail is never read as a torn-off corner.
177
+ */
178
+ function arrowLead(panel) {
179
+ const style = panel instanceof HTMLElement ? getComputedStyle(panel) : panel;
180
+ return (parseFloat(style.borderTopLeftRadius) || 0) + ARROW_HALF + 2;
181
+ }
182
+ /**
183
+ * Where a panel starts along its edge so that its arrow stands on the middle of the anchor.
184
+ *
185
+ * The tip may not stand on a rounded corner, so it keeps `lead` from each end of the panel. An **icon button** is
186
+ * narrower than two leads, so a panel lined up with it would hold the tip short of its middle: the panel slides
187
+ * along its edge instead, past the end of the button, by half the button's width at most.
188
+ *
189
+ * A **wide anchor keeps its alignment**, whatever the arrow can reach. A menu belongs at the start of the row,
190
+ * the cell, or the field it opened from - sliding it to the middle of a 900px row would move it right across the
191
+ * page - so the arrow aims as far as it can there and is left out when that is more than half a base off, which is
192
+ * what it did before any of this. The window's edges still win over both.
193
+ */
194
+ function centred(panel, anchor, wanted, width, at, style) {
195
+ const lead = arrowLead(style);
196
+ // Only a button too small to be aimed at while the panel lines up with it is worth moving the panel for.
197
+ if (anchor.width >= lead * 2 || width < lead * 2)
198
+ return wanted;
199
+ // The far end carries a pixel more than the lead: `width` is offsetWidth, a whole number, while the panel itself
200
+ // is fractionally wider, which would otherwise leave the foot that much nearer the curve than the lead promises.
201
+ return Math.min(Math.max(wanted, at - width + lead + 1), at - lead);
202
+ }
203
+ /**
204
+ * Aims the panel's arrow, or leaves it out. The tip is kept clear of the panel's rounded corners, and the arrow is
205
+ * left out when it would point at nothing: the target is off the window, the panel is too narrow to carry an arrow
206
+ * clear of its corners, the panel was pushed along its edge until the tip is more than half a base from the target,
207
+ * or it was pushed away from the target across the edge.
208
+ */
209
+ function aimArrow(panel, side, box, aim, drawn = getComputedStyle(panel)) {
210
+ if (aim) {
211
+ const lead = arrowLead(drawn);
212
+ const x = Math.min(Math.max(aim.at - box.left, lead), box.width - lead);
213
+ const edge = side === "below" ? box.top : box.top + box.height;
214
+ const tip = box.left + x;
215
+ const points = box.width >= lead * 2 && Math.abs(tip - aim.at) <= ARROW_HALF && Math.abs(edge - aim.facing) <= aim.room + 1;
216
+ if (points && onScreen(aim.facing, aim.at)) {
217
+ panel.dataset.nxArrow = side;
218
+ // An absolutely placed arrow starts at the panel's padding box, so the border it is drawn over comes off here.
219
+ const inside = x - (parseFloat(drawn.borderLeftWidth) || 0);
220
+ panel.style.setProperty("--nx-popover-arrow-x", `${Math.round(inside * 10) / 10}px`);
221
+ return;
222
+ }
223
+ }
224
+ delete panel.dataset.nxArrow;
225
+ panel.style.removeProperty("--nx-popover-arrow-x");
226
+ }
227
+ /** Whether what the arrow points at is still in the window: a button scrolled out of sight gets no arrow. */
228
+ function onScreen(across, along) {
229
+ return across >= 0 && across <= window.innerHeight && along >= 0 && along <= window.innerWidth;
230
+ }
@@ -0,0 +1,24 @@
1
+ import { type Ref, type RefObject, type ToggleEvent } from "react";
2
+ /** What opens a panel from elsewhere, such as an item of a menu. */
3
+ export interface PopoverPanelHandle {
4
+ open: () => void;
5
+ }
6
+ /**
7
+ * A `popover="auto"` panel that `open()` shows under `anchor`, aligned to its end, with the arrow: its height capped to
8
+ * the room on its side (its `.nx-popover__scroll` scrolls), placed again while the page moves or the panel grows, and
9
+ * the focus moved in as it opens (`focus` moves it, else the panel takes it). The anchor holds the focus as the panel
10
+ * opens, so the browser gives it back there when Esc closes the panel. `shown` turns true on the first open and stays,
11
+ * for content drawn only from then on.
12
+ *
13
+ * @example
14
+ * const { panelRef, open, onToggle } = usePopoverPanel(anchor, ref);
15
+ * <div ref={panelRef} popover="auto" role="dialog" tabIndex={-1} className="nx-popover" onToggle={onToggle}>
16
+ * <div className="nx-popover__scroll">…</div>
17
+ * </div>
18
+ */
19
+ export declare function usePopoverPanel(anchor: RefObject<HTMLElement | null>, ref?: Ref<PopoverPanelHandle>, focus?: () => void): {
20
+ panelRef: RefObject<HTMLDivElement | null>;
21
+ open: boolean;
22
+ shown: boolean;
23
+ onToggle: (event: ToggleEvent<HTMLDivElement>) => void;
24
+ };
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import { useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ import { flushSync } from "react-dom";
4
+ import { followAnchor, isPopoverOpen, placeUnderAnchor, showPanel } from "./PopoverHelpers.js";
5
+ /**
6
+ * A `popover="auto"` panel that `open()` shows under `anchor`, aligned to its end, with the arrow: its height capped to
7
+ * the room on its side (its `.nx-popover__scroll` scrolls), placed again while the page moves or the panel grows, and
8
+ * the focus moved in as it opens (`focus` moves it, else the panel takes it). The anchor holds the focus as the panel
9
+ * opens, so the browser gives it back there when Esc closes the panel. `shown` turns true on the first open and stays,
10
+ * for content drawn only from then on.
11
+ *
12
+ * @example
13
+ * const { panelRef, open, onToggle } = usePopoverPanel(anchor, ref);
14
+ * <div ref={panelRef} popover="auto" role="dialog" tabIndex={-1} className="nx-popover" onToggle={onToggle}>
15
+ * <div className="nx-popover__scroll">…</div>
16
+ * </div>
17
+ */
18
+ export function usePopoverPanel(anchor, ref, focus) {
19
+ const panelRef = useRef(null);
20
+ const [open, setOpen] = useState(false);
21
+ const [shown, setShown] = useState(false);
22
+ // A long panel keeps its head and its foot in the window, and its scroller takes what is left (`placeUnderAnchor`).
23
+ function place() {
24
+ const panel = panelRef.current;
25
+ const trigger = anchor.current;
26
+ if (panel && trigger)
27
+ placeUnderAnchor(panel, trigger.getBoundingClientRect(), { align: "end" });
28
+ }
29
+ useImperativeHandle(ref, () => ({
30
+ open() {
31
+ const panel = panelRef.current;
32
+ if (!panel || isPopoverOpen(panel))
33
+ return;
34
+ // Drawn first, so the panel is placed at its real size and the focus finds what it shows.
35
+ flushSync(() => setShown(true));
36
+ anchor.current?.focus({ preventScroll: true });
37
+ showPanel(panel);
38
+ place();
39
+ focus?.();
40
+ if (!panel.contains(document.activeElement))
41
+ panel.focus();
42
+ },
43
+ }));
44
+ // The panel is fixed, so it follows the anchor while it is open.
45
+ useLayoutEffect(() => {
46
+ const panel = panelRef.current;
47
+ if (!open || !panel)
48
+ return;
49
+ return followAnchor(place, panel);
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ }, [open]);
52
+ return {
53
+ panelRef,
54
+ open,
55
+ shown,
56
+ // A list or a calendar inside the panel toggles too, and React passes its event up: only the panel's own counts.
57
+ onToggle: (event) => {
58
+ if (event.target === event.currentTarget)
59
+ setOpen(event.newState === "open");
60
+ },
61
+ };
62
+ }
@@ -1,5 +1,5 @@
1
1
  import type { InputParams, InputValidationContext, RowsInputProps, RowValue } from "../Interfaces/FormInterfaces.ts";
2
- export type RowsStoreOptions<TRow extends RowValue = RowValue> = Pick<RowsInputProps<TRow>, "inputs" | "required" | "min" | "max" | "unique" | "validate" | "itemLabel" | "itemLabelPlural" | "rowKey" | "newRow" | "onRowChange"> & {
2
+ export type RowsStoreOptions<TRow extends RowValue = RowValue> = Pick<RowsInputProps<TRow>, "inputs" | "required" | "min" | "max" | "unique" | "validate" | "itemLabel" | "itemLabelPlural" | "rowKey" | "newRow" | "onRowChange" | "deriveRows"> & {
3
3
  /** The row's name when there is no `itemLabel`: "Row" in a table, "Item" in sections. */
4
4
  fallbackItem?: string;
5
5
  /** The user's date settings, so messages write dates the way the fields show them. */
@@ -109,8 +109,13 @@ export declare class RowsStore<TRow extends RowValue = RowValue> {
109
109
  add(values?: Partial<TRow>, index?: number): string;
110
110
  /** Adds a copy of a row after it, without its id and with its radios off. Returns the copy's key. */
111
111
  duplicate(key: string): string | null;
112
- /** Removes a row. A radio it had on moves to the first row left. `undoRemove` puts it back. */
113
- remove(key: string): void;
112
+ /**
113
+ * Removes a row. A radio it had on moves to the first row left. `undoRemove` puts it back - unless `undo` is false: a
114
+ * row the code drops, such as one nothing was typed in, is not offered back.
115
+ */
116
+ remove(key: string, { undo }?: {
117
+ undo?: boolean;
118
+ }): void;
114
119
  /** Puts the last removed row back where it was. Returns its key, or `null` when there is nothing to undo. */
115
120
  undoRemove(): string | null;
116
121
  /** Moves a row to a new position. */
@@ -151,6 +156,12 @@ export declare class RowsStore<TRow extends RowValue = RowValue> {
151
156
  private checkEverything;
152
157
  /** After a change: repeated values, the rows' own message, the rows to render again, and value listeners. */
153
158
  private settle;
159
+ /**
160
+ * Values the rows' positions decide rather than the user. Every change funnels through `settle`, so this is the one
161
+ * place a move, an add, a remove and a keystroke all pass. The patches are written as code writes, which draws only
162
+ * the cells whose value really changed: a move renumbers its column and leaves focus on the row's own menu.
163
+ */
164
+ private derive;
154
165
  private bump;
155
166
  private touchAllRows;
156
167
  private emit;
@@ -226,8 +226,11 @@ export class RowsStore {
226
226
  values[params.name] = false;
227
227
  return this.add(values, index + 1);
228
228
  }
229
- /** Removes a row. A radio it had on moves to the first row left. `undoRemove` puts it back. */
230
- remove(key) {
229
+ /**
230
+ * Removes a row. A radio it had on moves to the first row left. `undoRemove` puts it back - unless `undo` is false: a
231
+ * row the code drops, such as one nothing was typed in, is not offered back.
232
+ */
233
+ remove(key, { undo = true } = {}) {
231
234
  const index = this.positions.get(key);
232
235
  if (index === undefined)
233
236
  return;
@@ -245,7 +248,16 @@ export class RowsStore {
245
248
  this.write(first.key, params.name, true, "code", changed, 1);
246
249
  moved.push({ key: first.key, name: params.name });
247
250
  }
248
- this.removed = { entry, index, moved };
251
+ if (undo)
252
+ this.removed = { entry, index, moved };
253
+ // The person's own last removal stays theirs to undo, in its place: a row dropped before it moves it up one. One
254
+ // that handed a radio to another row could leave two rows with it on after that undo, so then nothing is undone.
255
+ else if (this.removed) {
256
+ if (moved.length)
257
+ this.removed = null;
258
+ else if (index < this.removed.index)
259
+ this.removed = { ...this.removed, index: this.removed.index - 1 };
260
+ }
249
261
  this.messageShown = true;
250
262
  this.markUniqueDirty();
251
263
  this.settle(changed);
@@ -526,6 +538,7 @@ export class RowsStore {
526
538
  }
527
539
  /** After a change: repeated values, the rows' own message, the rows to render again, and value listeners. */
528
540
  settle(changed) {
541
+ this.derive(changed);
529
542
  for (const name of this.dirtyUnique)
530
543
  this.checkUnique(name, changed);
531
544
  this.dirtyUnique.clear();
@@ -535,6 +548,27 @@ export class RowsStore {
535
548
  this.refresh();
536
549
  this.emit();
537
550
  }
551
+ /**
552
+ * Values the rows' positions decide rather than the user. Every change funnels through `settle`, so this is the one
553
+ * place a move, an add, a remove and a keystroke all pass. The patches are written as code writes, which draws only
554
+ * the cells whose value really changed: a move renumbers its column and leaves focus on the row's own menu.
555
+ */
556
+ derive(changed) {
557
+ const derive = this.options.deriveRows;
558
+ if (!derive)
559
+ return;
560
+ const patches = derive(this.getValue());
561
+ if (!patches)
562
+ return;
563
+ this.entries.forEach((entry, index) => {
564
+ const patch = patches[index];
565
+ if (!patch)
566
+ return;
567
+ // Depth 1, so a derived value never sets `onRowChange` off: these are the store's own writes, not the user's.
568
+ for (const [name, value] of Object.entries(patch))
569
+ this.write(entry.key, name, value, "code", changed, 1);
570
+ });
571
+ }
538
572
  bump(key) {
539
573
  this.versions.set(key, (this.versions.get(key) ?? 0) + 1);
540
574
  const listeners = this.rowListeners.get(key);
@@ -1,3 +1,10 @@
1
+ /**
2
+ * The cells of the header a column stands in, from left to right. The header is one row, or — when any column names a
3
+ * group — a band of group headings over a row holding the headings of the grouped columns only, where a column with no
4
+ * group keeps its heading in the band row across both (`rowSpan`). A band says how many headings it covers with its
5
+ * `colSpan`, so the two rows read back as one list: the table measures the columns from it, and a drag drops among them.
6
+ */
7
+ export declare function headLeafCells(head: HTMLTableSectionElement | null | undefined): HTMLElement[];
1
8
  export interface ColumnDragOptions {
2
9
  /** The heading pressed. */
3
10
  heading: HTMLElement;
@@ -9,18 +9,46 @@ const DRAG_THRESHOLD = 4;
9
9
  /** Pixels from the edge of the scroller where a drag scrolls the columns. */
10
10
  const EDGE = 52;
11
11
  const KEY = "data-col";
12
+ const GROUP = "data-group";
12
13
  const DROP_BEFORE = "data-drop-before";
13
14
  const DROP_AFTER = "data-drop-after";
14
- /** The headings a dragged one can be dropped between: the movable ones pinned to the same side. */
15
- function dropTargets(heading) {
16
- const row = heading.parentElement;
17
- if (!row)
15
+ /**
16
+ * The cells of the header a column stands in, from left to right. The header is one row, or — when any column names a
17
+ * group — a band of group headings over a row holding the headings of the grouped columns only, where a column with no
18
+ * group keeps its heading in the band row across both (`rowSpan`). A band says how many headings it covers with its
19
+ * `colSpan`, so the two rows read back as one list: the table measures the columns from it, and a drag drops among them.
20
+ */
21
+ export function headLeafCells(head) {
22
+ const band = head?.rows[0];
23
+ if (!band)
18
24
  return [];
25
+ const top = [...band.cells];
26
+ const leaves = head?.rows[1];
27
+ if (!leaves)
28
+ return top;
29
+ const under = [...leaves.cells];
30
+ const cells = [];
31
+ let at = 0;
32
+ for (const cell of top) {
33
+ if (!cell.hasAttribute(GROUP))
34
+ cells.push(cell);
35
+ else
36
+ for (let covered = 0; covered < cell.colSpan; covered++)
37
+ if (under[at])
38
+ cells.push(under[at++]);
39
+ }
40
+ return cells;
41
+ }
42
+ /**
43
+ * The headings a dragged one can be dropped between: the ones pinned to the same side, in the order they are drawn.
44
+ * They are taken from the whole header, so a column under a band and one beside it are the same list.
45
+ */
46
+ function dropTargets(head, heading) {
19
47
  const pin = heading.getAttribute("data-pin") ?? "";
20
- return [...row.children].filter((cell) => cell instanceof HTMLElement && cell.hasAttribute(KEY) && (cell.getAttribute("data-pin") ?? "") === pin);
48
+ return headLeafCells(head).filter(cell => cell.hasAttribute(KEY) && (cell.getAttribute("data-pin") ?? "") === pin);
21
49
  }
22
- function clearDropMarks(row) {
23
- for (const cell of row?.children ?? []) {
50
+ function clearDropMarks(head) {
51
+ for (const cell of head?.querySelectorAll(`[${DROP_BEFORE}], [${DROP_AFTER}]`) ?? []) {
24
52
  cell.removeAttribute(DROP_BEFORE);
25
53
  cell.removeAttribute(DROP_AFTER);
26
54
  }
@@ -31,8 +59,8 @@ function clearDropMarks(row) {
31
59
  */
32
60
  export function startColumnDrag(event, { heading, scroller, root, onDrop }) {
33
61
  const key = heading.getAttribute(KEY) ?? "";
34
- const row = heading.parentElement;
35
- if (!key || !row)
62
+ const head = heading.closest("thead");
63
+ if (!key || !head)
36
64
  return () => undefined;
37
65
  const pointerId = event.pointerId;
38
66
  const startX = event.clientX;
@@ -42,8 +70,8 @@ export function startColumnDrag(event, { heading, scroller, root, onDrop }) {
42
70
  let scrollBy = 0;
43
71
  let frame = 0;
44
72
  function place(x) {
45
- const targets = dropTargets(heading);
46
- clearDropMarks(row);
73
+ const targets = dropTargets(head, heading);
74
+ clearDropMarks(head);
47
75
  target = null;
48
76
  for (let index = 0; index < targets.length; index++) {
49
77
  const cell = targets[index];
@@ -63,7 +91,7 @@ export function startColumnDrag(event, { heading, scroller, root, onDrop }) {
63
91
  }
64
92
  // A drop where the column already sits changes nothing, so no line shows there.
65
93
  if (target.before === key || cellKey === key) {
66
- clearDropMarks(row);
94
+ clearDropMarks(head);
67
95
  target = null;
68
96
  }
69
97
  return;
@@ -144,7 +172,7 @@ export function startColumnDrag(event, { heading, scroller, root, onDrop }) {
144
172
  cancelAnimationFrame(frame);
145
173
  if (!dragging)
146
174
  return;
147
- clearDropMarks(row);
175
+ clearDropMarks(head);
148
176
  heading.removeAttribute("data-dragging");
149
177
  root?.removeAttribute("data-col-drag");
150
178
  if (heading.hasPointerCapture?.(pointerId))
@@ -38,18 +38,18 @@ export function exportTableCsv(output) {
38
38
  }
39
39
  const escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
40
40
  /* Plain black on white, since the paper has no theme, and a header row that repeats on every page. */
41
- const PRINT_STYLE = `
42
- * { box-sizing: border-box; }
43
- body { margin: 16mm 12mm; color: #111; font: 11pt/1.45 system-ui, "Segoe UI", sans-serif; }
44
- h1 { margin: 0 0 2mm; font-size: 15pt; }
45
- p.nx-note { margin: 0 0 5mm; color: #555; font-size: 9pt; }
46
- table { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
47
- thead { display: table-header-group; }
48
- th, td { padding: 4px 6px; border-bottom: 1px solid #bbb; text-align: left; vertical-align: top; }
49
- th { border-bottom: 1.5px solid #555; font-weight: 600; white-space: nowrap; }
50
- td.nx-end, th.nx-end { text-align: right; }
51
- td.nx-center, th.nx-center { text-align: center; }
52
- tr { page-break-inside: avoid; }
41
+ const PRINT_STYLE = `
42
+ * { box-sizing: border-box; }
43
+ body { margin: 16mm 12mm; color: #111; font: 11pt/1.45 system-ui, "Segoe UI", sans-serif; }
44
+ h1 { margin: 0 0 2mm; font-size: 15pt; }
45
+ p.nx-note { margin: 0 0 5mm; color: #555; font-size: 9pt; }
46
+ table { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
47
+ thead { display: table-header-group; }
48
+ th, td { padding: 4px 6px; border-bottom: 1px solid #bbb; text-align: left; vertical-align: top; }
49
+ th { border-bottom: 1.5px solid #555; font-weight: 600; white-space: nowrap; }
50
+ td.nx-end, th.nx-end { text-align: right; }
51
+ td.nx-center, th.nx-center { text-align: center; }
52
+ tr { page-break-inside: avoid; }
53
53
  `;
54
54
  /** The print view's HTML: a heading, a note, and the table. */
55
55
  export function tableToPrintHtml({ columns, rows, cellOptions = {}, name = "Table", note }) {