nexus-shared 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +442 -0
  2. package/README.md +1123 -1000
  3. package/dist/Client.Index.d.ts +6 -0
  4. package/dist/Client.Index.js +6 -0
  5. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  6. package/dist/Components/Chats/ChatParts.js +3 -7
  7. package/dist/Components/Chats/ChatThread.js +3 -3
  8. package/dist/Components/Documents/Button.d.ts +6 -1
  9. package/dist/Components/Documents/Button.js +2 -2
  10. package/dist/Components/Documents/Menu.js +21 -9
  11. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  12. package/dist/Components/Documents/SplitButton.js +7 -5
  13. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  14. package/dist/Components/Documents/TabButtons.js +2 -2
  15. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  16. package/dist/Components/Forms/ApiForm.js +1 -1
  17. package/dist/Components/Forms/Crud.js +486 -206
  18. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  19. package/dist/Components/Forms/CrudHistory.js +140 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  21. package/dist/Components/Forms/CrudParts.js +38 -5
  22. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  23. package/dist/Components/Forms/ExcelImport.js +189 -60
  24. package/dist/Components/Forms/Form.js +9 -14
  25. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  26. package/dist/Components/Forms/SubmitForm.js +23 -12
  27. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  28. package/dist/Components/Inputs/CodeBox.js +316 -0
  29. package/dist/Components/Inputs/DatePicker.js +4 -1
  30. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  31. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  32. package/dist/Components/Inputs/Dropdown.js +4 -1
  33. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  34. package/dist/Components/Inputs/DropdownParts.js +4 -6
  35. package/dist/Components/Inputs/InputRenderer.js +3 -0
  36. package/dist/Components/Inputs/NumberBox.js +26 -2
  37. package/dist/Components/Inputs/RowsInput.js +2 -2
  38. package/dist/Components/Inputs/Slider.js +4 -1
  39. package/dist/Components/Inputs/TabularForm.js +42 -10
  40. package/dist/Components/Inputs/TextArea.js +4 -1
  41. package/dist/Components/Inputs/TextBox.js +4 -1
  42. package/dist/Components/Inputs/TimePanel.js +4 -3
  43. package/dist/Components/Inputs/TimePicker.js +4 -1
  44. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  45. package/dist/Components/Inputs/TreeView.js +372 -23
  46. package/dist/Components/Layouts/Popup.js +1 -4
  47. package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
  48. package/dist/Components/Layouts/ThemePicker.js +89 -6
  49. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  50. package/dist/Components/Layouts/ThemeScript.js +4 -2
  51. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  52. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  53. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  54. package/dist/Components/Viewers/Avatar.js +21 -0
  55. package/dist/Components/Viewers/DataTable.js +252 -62
  56. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  57. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  58. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  59. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  60. package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
  61. package/dist/Components/Viewers/DataTableParts.js +42 -13
  62. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  63. package/dist/Helpers/AnimationHelpers.js +20 -2
  64. package/dist/Helpers/ApiModules.d.ts +3 -34
  65. package/dist/Helpers/ApiModules.js +3 -3
  66. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  67. package/dist/Helpers/ApiRoutes.js +31 -27
  68. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  69. package/dist/Helpers/ArrayHelpers.js +2 -0
  70. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  71. package/dist/Helpers/CodeHelpers.js +332 -0
  72. package/dist/Helpers/CrudBackend.d.ts +101 -8
  73. package/dist/Helpers/CrudBackend.js +172 -11
  74. package/dist/Helpers/CrudColumns.d.ts +51 -0
  75. package/dist/Helpers/CrudColumns.js +69 -0
  76. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  77. package/dist/Helpers/CrudHelpers.js +109 -12
  78. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  79. package/dist/Helpers/ExcelBackend.js +8 -2
  80. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  81. package/dist/Helpers/ExcelHelpers.js +55 -13
  82. package/dist/Helpers/FormStore.d.ts +9 -5
  83. package/dist/Helpers/FormStore.js +25 -5
  84. package/dist/Helpers/InputParamsHelpers.js +8 -0
  85. package/dist/Helpers/MessageBuilder.js +3 -0
  86. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  87. package/dist/Helpers/NexusHelpers.js +1 -0
  88. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  89. package/dist/Helpers/NumberHelpers.js +41 -3
  90. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  91. package/dist/Helpers/PopoverHelpers.js +52 -17
  92. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  93. package/dist/Helpers/PopoverHooks.js +62 -0
  94. package/dist/Helpers/RequestCache.d.ts +35 -0
  95. package/dist/Helpers/RequestCache.js +113 -0
  96. package/dist/Helpers/RowsStore.d.ts +7 -2
  97. package/dist/Helpers/RowsStore.js +15 -3
  98. package/dist/Helpers/TableFilters.d.ts +25 -0
  99. package/dist/Helpers/TableFilters.js +162 -0
  100. package/dist/Helpers/TableHelpers.d.ts +7 -4
  101. package/dist/Helpers/TableHelpers.js +15 -6
  102. package/dist/Helpers/TableJoins.d.ts +70 -0
  103. package/dist/Helpers/TableJoins.js +250 -0
  104. package/dist/Helpers/TableStore.d.ts +16 -3
  105. package/dist/Helpers/TableStore.js +25 -5
  106. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  107. package/dist/Helpers/ThemeHelpers.js +46 -9
  108. package/dist/Helpers/TreeStore.d.ts +225 -11
  109. package/dist/Helpers/TreeStore.js +683 -90
  110. package/dist/Helpers/UserPreferences.d.ts +22 -0
  111. package/dist/Helpers/UserPreferences.js +116 -0
  112. package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
  113. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  114. package/dist/Interfaces/FormInterfaces.d.ts +37 -6
  115. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  116. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  117. package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
  118. package/dist/Interfaces/PreferenceInterfaces.js +25 -0
  119. package/dist/Interfaces/TableInterfaces.d.ts +145 -16
  120. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  121. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  122. package/dist/Services/ApiProxy.d.ts +36 -1
  123. package/dist/Services/ApiProxy.js +182 -52
  124. package/dist/Services/ApiProxyRules.d.ts +203 -0
  125. package/dist/Services/ApiProxyRules.js +426 -0
  126. package/dist/Services/ExcelApi.d.ts +3 -1
  127. package/dist/Services/ExcelApi.js +1 -1
  128. package/dist/Services/JoinSources.d.ts +48 -0
  129. package/dist/Services/JoinSources.js +74 -0
  130. package/dist/Services/ServerApi.d.ts +13 -2
  131. package/dist/Services/ServerApi.js +8 -3
  132. package/dist/Services/ThemeService.d.ts +18 -2
  133. package/dist/Services/ThemeService.js +80 -16
  134. package/dist/Shared.Index.d.ts +6 -0
  135. package/dist/Shared.Index.js +6 -0
  136. package/package.json +4 -2
  137. package/src/Styles/Nexus.Avatar.css +79 -0
  138. package/src/Styles/Nexus.Base.css +51 -4
  139. package/src/Styles/Nexus.Button.css +27 -0
  140. package/src/Styles/Nexus.Calendar.css +4 -6
  141. package/src/Styles/Nexus.Chat.css +5 -51
  142. package/src/Styles/Nexus.Choice.css +4 -6
  143. package/src/Styles/Nexus.Crud.css +16 -9
  144. package/src/Styles/Nexus.Dropdown.css +12 -61
  145. package/src/Styles/Nexus.Editor.css +4 -6
  146. package/src/Styles/Nexus.Excel.css +34 -15
  147. package/src/Styles/Nexus.Form.css +25 -67
  148. package/src/Styles/Nexus.Index.css +1 -0
  149. package/src/Styles/Nexus.Input.css +794 -637
  150. package/src/Styles/Nexus.Markdown.css +0 -28
  151. package/src/Styles/Nexus.Menu.css +8 -30
  152. package/src/Styles/Nexus.Popup.css +22 -29
  153. package/src/Styles/Nexus.RichText.css +2 -32
  154. package/src/Styles/Nexus.Rows.css +51 -10
  155. package/src/Styles/Nexus.Slider.css +3 -5
  156. package/src/Styles/Nexus.Tab.Buttons.css +20 -7
  157. package/src/Styles/Nexus.Table.css +1387 -1296
  158. package/src/Styles/Nexus.Theme.Picker.css +342 -232
  159. package/src/Styles/Nexus.Time.css +9 -12
  160. package/src/Styles/Nexus.Toast.css +6 -8
  161. package/src/Styles/Nexus.Tokens.css +17 -0
  162. package/src/Styles/Nexus.Tree.css +76 -11
@@ -8,19 +8,16 @@
8
8
  * knows both rectangles; `.nx-popover` draws it from what they write (see "The arrow" below).
9
9
  */
10
10
  /**
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, with an arrow aimed at the anchor. 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.
13
14
  */
14
15
  export function placeUnderAnchor(panel, anchor, options = {}) {
15
16
  const { align = "start", margin = 8, arrow = true } = options;
16
17
  // The arrow needs its reach between the panel and the anchor, or it would be drawn over the anchor.
17
18
  const gap = Math.max(options.gap ?? 4, arrow ? ARROW_REACH : 0);
18
- const width = panel.offsetWidth;
19
- const height = panel.offsetHeight;
20
- const below = anchor.bottom + gap;
21
- const above = anchor.top - gap - height;
22
- const fitsBelow = below + height <= window.innerHeight - margin;
23
- const top = fitsBelow || above < margin ? below : above;
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;
24
21
  const wanted = align === "end" ? anchor.right - width : anchor.left;
25
22
  // The arrow points at the middle of what it belongs to. The panel's own style is read once: this runs again on
26
23
  // every animation frame while the page scrolls under an open panel.
@@ -30,7 +27,6 @@ export function placeUnderAnchor(panel, anchor, options = {}) {
30
27
  const left = Math.max(margin, Math.min(arrow ? centred(panel, target, wanted, width, middle, style) : wanted, window.innerWidth - width - margin));
31
28
  panel.style.top = `${Math.round(top)}px`;
32
29
  panel.style.left = `${Math.round(left)}px`;
33
- const side = top === below ? "below" : "above";
34
30
  const box = { left: Math.round(left), top: Math.round(top), width, height };
35
31
  aimArrow(panel, side, box, arrow ? { at: middle, facing: side === "below" ? target.bottom : target.top, room: gap } : null, style);
36
32
  return side === "above";
@@ -71,24 +67,42 @@ function setStyle(element, property, value) {
71
67
  if (element.style[property] !== value)
72
68
  element.style[property] = value;
73
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
+ }
74
90
  /**
75
91
  * Places a panel at a point, such as where the user right-clicked: to the right of and below the point when there
76
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
77
- * back far enough along both axes for the arrow's tip to land on the point.
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.
78
95
  */
79
96
  export function placeAtPoint(panel, x, y, options = {}) {
80
97
  const { margin = 8, arrow = true } = options;
81
- const width = panel.offsetWidth;
82
- const height = panel.offsetHeight;
83
98
  // The tip sits `lead` along the panel's edge and `ARROW_REACH` out from it, so the panel starts that much back.
84
99
  const lead = arrow ? arrowLead(panel) : 0;
85
100
  const reach = arrow ? ARROW_REACH : 0;
101
+ const { side: opens, width, height } = fitToRoom(panel, window.innerHeight - y - reach - margin, y - reach - margin);
86
102
  let left = x - lead;
87
- let top = y + reach;
103
+ const top = opens === "below" ? y + reach : y - reach - height;
88
104
  if (left + width > window.innerWidth - margin)
89
105
  left = x + lead - width;
90
- if (top + height > window.innerHeight - margin)
91
- top = y - reach - height;
92
106
  const placed = {
93
107
  left: Math.round(Math.max(margin, Math.min(left, window.innerWidth - width - margin))),
94
108
  top: Math.round(Math.max(margin, Math.min(top, window.innerHeight - height - margin))),
@@ -100,8 +114,12 @@ export function placeAtPoint(panel, x, y, options = {}) {
100
114
  const side = placed.top >= y ? "below" : "above";
101
115
  aimArrow(panel, side, placed, arrow ? { at: x, facing: y, room: reach } : null);
102
116
  }
103
- /** Runs `place` again, once per frame at most, while the page scrolls or the window changes size. Returns the stop function. */
104
- 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) {
105
123
  let frame = 0;
106
124
  const reposition = () => {
107
125
  cancelAnimationFrame(frame);
@@ -109,12 +127,29 @@ export function followAnchor(place) {
109
127
  };
110
128
  window.addEventListener("scroll", reposition, { capture: true, passive: true });
111
129
  window.addEventListener("resize", reposition);
130
+ const observer = new ResizeObserver(reposition);
131
+ if (panel)
132
+ observer.observe(panel);
112
133
  return () => {
113
134
  cancelAnimationFrame(frame);
114
135
  window.removeEventListener("scroll", reposition, { capture: true });
115
136
  window.removeEventListener("resize", reposition);
137
+ observer.disconnect();
116
138
  };
117
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
+ }
118
153
  /** Whether the popover is shown. Safe on browsers without popover support, where it is never "open". */
119
154
  export function isPopoverOpen(panel) {
120
155
  return Boolean(panel && typeof panel.matches === "function" && panel.matches(":popover-open"));
@@ -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
+ }
@@ -0,0 +1,35 @@
1
+ /** How long an answer is kept by default: 5 minutes, the CRUD's and the joins' lifetime too. */
2
+ export declare const REQUEST_CACHE_LIFETIME: number;
3
+ /** Whether a value is a promise, or any object with a `then` (what `await` treats as one). */
4
+ export declare function isPromiseLike<T>(value: unknown): value is PromiseLike<T>;
5
+ export declare class RequestCache<T> {
6
+ private kept;
7
+ readonly lifetime: number;
8
+ private now;
9
+ constructor(lifetime?: number, now?: () => number);
10
+ /** The answer kept under `key` while it is fresh; `undefined` when there is none, it has aged, or it is still on its way. */
11
+ peek(key: string, lifetime?: number): T | undefined;
12
+ /** Whether a request for `key` is on its way. */
13
+ isLoading(key: string): boolean;
14
+ /**
15
+ * The answer under `key`: a fresh kept one at once, the request already on its way, or a new request from `load`.
16
+ * An answer `load` gives at once (not a promise) is kept and given back at once too, so a caller can draw it in the
17
+ * same frame. A failure is not kept. `lifetime` 0 or less shares a request on its way but keeps no answer.
18
+ *
19
+ * @example
20
+ * const page = cache.get(`regions|${parentId}|0`, signal => api.post(children, { parentId, skipCount: 0, maxCount: 50 }, { signal }));
21
+ */
22
+ get(key: string, load: (signal: AbortSignal) => T | PromiseLike<T>, lifetime?: number): T | Promise<T>;
23
+ /** Keeps an answer under `key`, as if it had just been loaded. */
24
+ set(key: string, value: T): void;
25
+ private keep;
26
+ /**
27
+ * Drops the answers - kept or on their way - under every key that starts with `prefix`, or that `test` passes, or
28
+ * every one when nothing is given. A request dropped on its way still answers whoever waits for it; its answer is
29
+ * just not kept. Returns how many were dropped.
30
+ */
31
+ drop(match?: string | ((key: string) => boolean)): number;
32
+ clear(): void;
33
+ /** Answers kept and requests on their way. */
34
+ get size(): number;
35
+ }
@@ -0,0 +1,113 @@
1
+ /*
2
+ * Answers kept by name for a lifetime, shared by every caller on the page: the tree view's pages of children, and any
3
+ * other request whose answer a page asks for more than once. These are the owner's caching rules
4
+ * (Project-Skills/README.RULES.md), the same as `tableJoins` keeps for looked-up records:
5
+ *
6
+ * - an answer is kept for a lifetime (5 minutes by default, like JOIN_LIFETIME and CRUD_CACHE_LIFETIME), so leaving a
7
+ * page and coming back, or a second component asking the same thing, makes no call;
8
+ * - a request already on its way is waited for, never made twice;
9
+ * - a failure is not kept, so the next ask (a Retry) tries again;
10
+ * - a refresh drops what it names and nothing else, so only what is needed now is loaded again.
11
+ *
12
+ * Nothing here knows about React or the API: a caller names the answer and says how to load it. Kept in memory, for the
13
+ * life of the page; the browser database the rules ask for is still to come (Agent-Memory/project-address-joins.md).
14
+ */
15
+ /** How long an answer is kept by default: 5 minutes, the CRUD's and the joins' lifetime too. */
16
+ export const REQUEST_CACHE_LIFETIME = 5 * 60 * 1000;
17
+ /** Whether a value is a promise, or any object with a `then` (what `await` treats as one). */
18
+ export function isPromiseLike(value) {
19
+ return typeof value === "object" && value !== null && typeof value.then === "function";
20
+ }
21
+ export class RequestCache {
22
+ kept = new Map();
23
+ lifetime;
24
+ now;
25
+ constructor(lifetime = REQUEST_CACHE_LIFETIME, now = Date.now) {
26
+ this.lifetime = lifetime;
27
+ this.now = now;
28
+ }
29
+ /** The answer kept under `key` while it is fresh; `undefined` when there is none, it has aged, or it is still on its way. */
30
+ peek(key, lifetime = this.lifetime) {
31
+ const entry = this.kept.get(key);
32
+ if (entry?.state !== "ready")
33
+ return undefined;
34
+ if (this.now() - entry.at >= lifetime) {
35
+ this.kept.delete(key);
36
+ return undefined;
37
+ }
38
+ return entry.value;
39
+ }
40
+ /** Whether a request for `key` is on its way. */
41
+ isLoading(key) {
42
+ return this.kept.get(key)?.state === "loading";
43
+ }
44
+ /**
45
+ * The answer under `key`: a fresh kept one at once, the request already on its way, or a new request from `load`.
46
+ * An answer `load` gives at once (not a promise) is kept and given back at once too, so a caller can draw it in the
47
+ * same frame. A failure is not kept. `lifetime` 0 or less shares a request on its way but keeps no answer.
48
+ *
49
+ * @example
50
+ * const page = cache.get(`regions|${parentId}|0`, signal => api.post(children, { parentId, skipCount: 0, maxCount: 50 }, { signal }));
51
+ */
52
+ get(key, load, lifetime = this.lifetime) {
53
+ const entry = this.kept.get(key);
54
+ if (entry?.state === "loading")
55
+ return entry.promise;
56
+ if (entry?.state === "ready" && this.now() - entry.at < lifetime)
57
+ return entry.value;
58
+ // Nothing aborts a kept request: whoever else waits for it, or comes back for it, still wants the answer.
59
+ const answer = load(new AbortController().signal);
60
+ if (!isPromiseLike(answer)) {
61
+ this.keep(key, answer, lifetime);
62
+ return answer;
63
+ }
64
+ const loading = {
65
+ state: "loading",
66
+ promise: Promise.resolve(answer).then(value => {
67
+ // A drop while it was on its way: the answer goes to whoever waits, and is not kept.
68
+ if (this.kept.get(key) === loading)
69
+ this.keep(key, value, lifetime);
70
+ return value;
71
+ }, (error) => {
72
+ if (this.kept.get(key) === loading)
73
+ this.kept.delete(key);
74
+ throw error;
75
+ }),
76
+ };
77
+ this.kept.set(key, loading);
78
+ return loading.promise;
79
+ }
80
+ /** Keeps an answer under `key`, as if it had just been loaded. */
81
+ set(key, value) {
82
+ this.keep(key, value, this.lifetime);
83
+ }
84
+ keep(key, value, lifetime) {
85
+ if (lifetime > 0)
86
+ this.kept.set(key, { state: "ready", value, at: this.now() });
87
+ else
88
+ this.kept.delete(key);
89
+ }
90
+ /**
91
+ * Drops the answers - kept or on their way - under every key that starts with `prefix`, or that `test` passes, or
92
+ * every one when nothing is given. A request dropped on its way still answers whoever waits for it; its answer is
93
+ * just not kept. Returns how many were dropped.
94
+ */
95
+ drop(match) {
96
+ let dropped = 0;
97
+ for (const key of [...this.kept.keys()]) {
98
+ const hit = match === undefined || (typeof match === "string" ? key.startsWith(match) : match(key));
99
+ if (!hit)
100
+ continue;
101
+ this.kept.delete(key);
102
+ dropped++;
103
+ }
104
+ return dropped;
105
+ }
106
+ clear() {
107
+ this.kept.clear();
108
+ }
109
+ /** Answers kept and requests on their way. */
110
+ get size() {
111
+ return this.kept.size;
112
+ }
113
+ }
@@ -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. */
@@ -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);
@@ -0,0 +1,25 @@
1
+ import type { InputParams } from "../Interfaces/FormInterfaces.ts";
2
+ import type { TableColumn, TableFilters, TableRow } from "../Interfaces/TableInterfaces.ts";
3
+ /** The params as a filter: the same object without `required`, `defaultValue`, `defaultChecked`. */
4
+ export declare function asFilterInput(params: InputParams): InputParams;
5
+ /**
6
+ * The values that filter, in input order, or `undefined` when none does. Drops a value that `isEmptyInputValue` calls
7
+ * empty or that equals `emptyInputValue(params)` (an unticked box, a slider at its min, a range slider at [min, max]),
8
+ * and any name no input declares.
9
+ */
10
+ export declare function activeFilters(inputs: readonly InputParams[], values: Readonly<Record<string, unknown>> | undefined): TableFilters | undefined;
11
+ /** The labels of the active filters, for a menu line or an export note: ["Status", "Check-in"]. */
12
+ export declare function filterNames(inputs: readonly InputParams[], filters: TableFilters | undefined): string[];
13
+ /**
14
+ * A client table's test of a row for the active filters, or `null` when nothing filters. Each input reads the column
15
+ * whose `key` is its name; a `number` input named `{key}From` / `{key}To` is a bound of column `{key}`; any other name
16
+ * is a path into the row. A blank cell never matches an active filter.
17
+ */
18
+ export declare function filterMatcher<TRow extends TableRow>(columns: readonly TableColumn<TRow>[], inputs: readonly InputParams[], filters: TableFilters | undefined): ((row: TRow) => boolean) | null;
19
+ /**
20
+ * The active filters in the flat names a server takes: scalars and lists as they are (never an empty list), a date
21
+ * range as `{name}From` / `{name}Before` (the day after its last day), a date on or a date range over a `date-time`
22
+ * column as the UTC moments of those local days, a range slider as `{name}From` / `{name}To`.
23
+ * `timeZone` defaults to the zone the table shows date-times in.
24
+ */
25
+ export declare function filterRequest<TRow extends TableRow>(filters: TableFilters | undefined, inputs: readonly InputParams[], columns: readonly TableColumn<TRow>[], timeZone?: string): Record<string, unknown>;
@@ -0,0 +1,162 @@
1
+ import { addDays } from "./DateHelpers.js";
2
+ import { fromLocalDateTime, toLocalDateTime, toUtcDateTime } from "./DateTimeHelpers.js";
3
+ import { foldSearchText, searchWords } from "./DropdownHelpers.js";
4
+ import { emptyInputValue, inputFieldName, inputValuesEqual, isEmptyInputValue } from "./InputParamsHelpers.js";
5
+ import { columnValue, readPath, searchTextOf } from "./TableHelpers.js";
6
+ /*
7
+ * A data table's advanced filter, with no React: input params as filters, the values that filter, a client table's
8
+ * test of a row, and the names a server takes.
9
+ */
10
+ // Inputs whose value cannot filter a row, and names a Nexus page request already uses for its paging.
11
+ const UNSUPPORTED = new Set(["markdown", "rich-text", "tree", "tabular", "group", "password"]);
12
+ const RESERVED = new Set(["searchKeyword", "skipCount", "maxCount", "sorting", "sortType"]);
13
+ const warned = new Set();
14
+ function filterable(params) {
15
+ const reserved = RESERVED.has(params.name);
16
+ if (!reserved && !UNSUPPORTED.has(params.type))
17
+ return true;
18
+ if (!warned.has(params.name)) {
19
+ warned.add(params.name);
20
+ console.warn(`DataTable: the filter "${params.name}" is ignored: ${reserved ? "a page request already sends that name" : `a ${params.type} input cannot filter rows`}.`);
21
+ }
22
+ return false;
23
+ }
24
+ /** The params as a filter: the same object without `required`, `defaultValue`, `defaultChecked`. */
25
+ export function asFilterInput(params) {
26
+ // Descriptors rather than a spread, so a getter (a `hidden` that follows `dependsOn`) is still read as the form draws.
27
+ const { required: _required, defaultValue: _value, defaultChecked: _checked, ...rest } = Object.getOwnPropertyDescriptors(params);
28
+ // An input that cannot filter stays out of the panel, as its value stays out of the filters.
29
+ if (!filterable(params))
30
+ rest.hidden = { value: true, enumerable: true };
31
+ return Object.defineProperties({}, rest);
32
+ }
33
+ /** Each input that filters, with its value: in input order, text trimmed, empty values left out. */
34
+ function activeInputs(inputs, values) {
35
+ const active = [];
36
+ if (!values)
37
+ return active;
38
+ for (const params of inputs) {
39
+ const given = values[params.name];
40
+ const value = typeof given === "string" ? given.trim() : given;
41
+ if (!isEmptyInputValue(value) && !inputValuesEqual(value, emptyInputValue(params)) && filterable(params))
42
+ active.push([params, value]);
43
+ }
44
+ return active;
45
+ }
46
+ /**
47
+ * The values that filter, in input order, or `undefined` when none does. Drops a value that `isEmptyInputValue` calls
48
+ * empty or that equals `emptyInputValue(params)` (an unticked box, a slider at its min, a range slider at [min, max]),
49
+ * and any name no input declares.
50
+ */
51
+ export function activeFilters(inputs, values) {
52
+ const active = activeInputs(inputs, values);
53
+ return active.length ? Object.fromEntries(active.map(([params, value]) => [params.name, value])) : undefined;
54
+ }
55
+ /** The labels of the active filters, for a menu line or an export note: ["Status", "Check-in"]. */
56
+ export function filterNames(inputs, filters) {
57
+ return activeInputs(inputs, filters).map(([params]) => inputFieldName(params) ?? (typeof params.label === "string" ? params.label : params.name));
58
+ }
59
+ const isBlank = (cell) => cell === undefined || cell === null || cell === "";
60
+ /** One filter's test of a row's cell (never blank here). */
61
+ function cellTest(params, value, column, bound) {
62
+ // A moment's day is the one the table shows it on; any other value is read as an ISO date.
63
+ const dayOf = (cell) => (column?.format === "date-time" ? toLocalDateTime(cell)?.date : String(cell).slice(0, 10));
64
+ switch (params.type) {
65
+ case "text":
66
+ case "email":
67
+ case "search":
68
+ case "tel":
69
+ case "url":
70
+ case "textarea": {
71
+ // Every word, folded as the search folds it, in the text the cell shows (an option's label, Yes or No).
72
+ const words = searchWords(String(value));
73
+ return (cell, row) => {
74
+ const text = foldSearchText(column ? searchTextOf(column, row) : String(cell));
75
+ return words.every(word => text.includes(word));
76
+ };
77
+ }
78
+ case "checkbox":
79
+ case "switch":
80
+ case "radio":
81
+ return cell => cell === true || cell === "true" || cell === 1;
82
+ case "number":
83
+ case "slider": {
84
+ const number = Number(value);
85
+ return cell => (bound === "From" ? Number(cell) >= number : bound === "To" ? Number(cell) <= number : Number(cell) === number);
86
+ }
87
+ case "range-slider": {
88
+ const [low, high] = value;
89
+ return cell => Number(cell) >= low && Number(cell) <= high;
90
+ }
91
+ case "date":
92
+ return cell => dayOf(cell) === value;
93
+ case "date-range": {
94
+ const [from, to] = value;
95
+ return cell => {
96
+ const day = dayOf(cell);
97
+ return day !== undefined && (!from || day >= from) && (!to || day <= to);
98
+ };
99
+ }
100
+ case "time":
101
+ return cell => String(cell).slice(0, 5) === String(value).slice(0, 5);
102
+ case "date-time": {
103
+ // The same minute, however each side writes the moment ("…Z", "+05:45", or no zone for UTC).
104
+ const minute = (moment) => toUtcDateTime(moment)?.slice(0, 16);
105
+ const wanted = minute(value);
106
+ return cell => minute(cell) === wanted;
107
+ }
108
+ default: {
109
+ // A choice, or several: compared as text, so 2 matches "2"; a cell holding a list matches when any item does.
110
+ const wanted = new Set((Array.isArray(value) ? value : [value]).map(String));
111
+ return cell => (Array.isArray(cell) ? cell : [cell]).some(item => wanted.has(String(item)));
112
+ }
113
+ }
114
+ }
115
+ /**
116
+ * A client table's test of a row for the active filters, or `null` when nothing filters. Each input reads the column
117
+ * whose `key` is its name; a `number` input named `{key}From` / `{key}To` is a bound of column `{key}`; any other name
118
+ * is a path into the row. A blank cell never matches an active filter.
119
+ */
120
+ export function filterMatcher(columns, inputs, filters) {
121
+ const tests = activeInputs(inputs, filters).map(([params, value]) => {
122
+ const { name } = params;
123
+ let column = columns.find(candidate => candidate.key === name);
124
+ const [, key, bound] = (!column && params.type === "number" && /^(.+)(From|To)$/.exec(name)) || [];
125
+ column ??= columns.find(candidate => candidate.key === key);
126
+ const test = cellTest(params, value, column, column ? bound : undefined);
127
+ return (row) => {
128
+ const cell = column ? columnValue(column, row) : readPath(row, name);
129
+ return !isBlank(cell) && test(cell, row);
130
+ };
131
+ });
132
+ return tests.length ? row => tests.every(test => test(row)) : null;
133
+ }
134
+ /**
135
+ * The active filters in the flat names a server takes: scalars and lists as they are (never an empty list), a date
136
+ * range as `{name}From` / `{name}Before` (the day after its last day), a date on or a date range over a `date-time`
137
+ * column as the UTC moments of those local days, a range slider as `{name}From` / `{name}To`.
138
+ * `timeZone` defaults to the zone the table shows date-times in.
139
+ */
140
+ export function filterRequest(filters, inputs, columns, timeZone) {
141
+ const request = {};
142
+ for (const [params, value] of activeInputs(inputs, filters)) {
143
+ const { name } = params;
144
+ const moments = columns.find(column => column.key === name)?.format === "date-time";
145
+ // Days are half-open, [From, Before), so the last day is never lost to a time of day.
146
+ const days = (from, to) => {
147
+ if (from)
148
+ request[`${name}From`] = moments ? fromLocalDateTime(from, "00:00", timeZone) : from;
149
+ if (to)
150
+ request[`${name}Before`] = moments ? fromLocalDateTime(addDays(to, 1), "00:00", timeZone) : addDays(to, 1);
151
+ };
152
+ if (params.type === "date-range")
153
+ days(...value);
154
+ else if (params.type === "date" && moments)
155
+ days(value, value);
156
+ else if (params.type === "range-slider")
157
+ [request[`${name}From`], request[`${name}To`]] = value;
158
+ else
159
+ request[name] = value;
160
+ }
161
+ return request;
162
+ }
@@ -1,4 +1,5 @@
1
1
  import type { DatePreferences } from "../Interfaces/DateInterfaces.ts";
2
+ import type { InputParams } from "../Interfaces/FormInterfaces.ts";
2
3
  import type { TableColumn, TableColumnOption, TableHeadBand, TableKey, TablePage, TablePinSide, TableQuery, TableRow, TableSort, TableViewColumn } from "../Interfaces/TableInterfaces.ts";
3
4
  /** Rows per page when nothing says otherwise. */
4
5
  export declare const DEFAULT_PAGE_SIZE = 10;
@@ -10,7 +11,7 @@ export declare const DEFAULT_MAX_SELECTION = 500;
10
11
  export declare function readPath(row: unknown, path: string): unknown;
11
12
  /** A row's key as text, so 5 and "5" are the same row. */
12
13
  export declare function rowKeyText<TRow extends TableRow>(row: TRow, rowKey: string | ((row: TRow) => TableKey)): string;
13
- /** A column's value in a row. */
14
+ /** A column's value in a row: its own `value`, the record a `join` looked up, or the field. */
14
15
  export declare function columnValue<TRow extends TableRow>(column: TableColumn<TRow>, row: TRow): unknown;
15
16
  /** The option a value stands for, if the column has options. Values compare as text, so 2 matches "2". */
16
17
  export declare function columnOption(column: TableColumn<never> | TableColumn, value: unknown): TableColumnOption | undefined;
@@ -23,6 +24,8 @@ export interface CellTextOptions {
23
24
  preferences?: Partial<DatePreferences>;
24
25
  /** IANA zone for `date-time` values. Default: the browser's. */
25
26
  timeZone?: string;
27
+ /** How numbers are written: a locale, or the person's settings (`numberLocale`, `useNumberLocale`). Default `en-US`. */
28
+ locale?: string;
26
29
  }
27
30
  /**
28
31
  * A value as the cell writes it, in the column's format: "1,250.00", "Rs. 4,500.00", "12.5%", the date and time in the
@@ -54,10 +57,10 @@ export declare function readTablePage<TRow extends TableRow>(answer: TablePage<T
54
57
  matches: number;
55
58
  };
56
59
  /**
57
- * Searches, sorts, and pages rows the way the table shows them. `texts` caches each row's folded search text, so a
58
- * search of 10,000 rows looks at each row once per keystroke.
60
+ * Searches, filters, sorts, and pages rows the way the table shows them. `texts` caches each row's folded search text,
61
+ * so a search of 10,000 rows looks at each row once per keystroke. `query.filters` are matched by `filterInputs`.
59
62
  */
60
- export declare function queryRows<TRow extends TableRow>(rows: readonly TRow[], columns: readonly TableColumn<TRow>[], query: TableQuery, texts?: WeakMap<TRow, string>): {
63
+ export declare function queryRows<TRow extends TableRow>(rows: readonly TRow[], columns: readonly TableColumn<TRow>[], query: TableQuery, texts?: WeakMap<TRow, string>, filterInputs?: readonly InputParams[]): {
61
64
  rows: TRow[];
62
65
  matches: number;
63
66
  page: number;