nexus-shared 3.1.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 (104) hide show
  1. package/CHANGELOG.md +704 -446
  2. package/LICENSE +21 -21
  3. package/README.md +44 -5
  4. package/dist/Client.Index.d.ts +3 -0
  5. package/dist/Client.Index.js +3 -0
  6. package/dist/Components/Forms/Crud.js +322 -161
  7. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  8. package/dist/Components/Forms/CrudHistory.js +140 -0
  9. package/dist/Components/Forms/CrudParts.js +2 -1
  10. package/dist/Components/Forms/Form.js +4 -1
  11. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  12. package/dist/Components/Inputs/CodeBox.js +316 -0
  13. package/dist/Components/Inputs/DatePicker.js +4 -1
  14. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  15. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  16. package/dist/Components/Inputs/Dropdown.js +4 -1
  17. package/dist/Components/Inputs/InputRenderer.js +3 -0
  18. package/dist/Components/Inputs/NumberBox.js +4 -2
  19. package/dist/Components/Inputs/TextArea.js +4 -1
  20. package/dist/Components/Inputs/TextBox.js +4 -1
  21. package/dist/Components/Inputs/TimePicker.js +4 -1
  22. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  23. package/dist/Components/Inputs/TreeView.js +372 -23
  24. package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
  25. package/dist/Components/Layouts/ThemePicker.js +16 -4
  26. package/dist/Components/Viewers/DataTable.js +63 -21
  27. package/dist/Components/Viewers/DataTableParts.d.ts +26 -8
  28. package/dist/Components/Viewers/DataTableParts.js +39 -10
  29. package/dist/Helpers/ApiModules.d.ts +3 -34
  30. package/dist/Helpers/ApiModules.js +3 -3
  31. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  32. package/dist/Helpers/ApiRoutes.js +31 -27
  33. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  34. package/dist/Helpers/ArrayHelpers.js +2 -0
  35. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  36. package/dist/Helpers/CodeHelpers.js +332 -0
  37. package/dist/Helpers/CrudBackend.d.ts +94 -4
  38. package/dist/Helpers/CrudBackend.js +168 -9
  39. package/dist/Helpers/CrudColumns.d.ts +51 -0
  40. package/dist/Helpers/CrudColumns.js +69 -0
  41. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  42. package/dist/Helpers/CrudHelpers.js +109 -12
  43. package/dist/Helpers/InputParamsHelpers.js +8 -0
  44. package/dist/Helpers/MessageBuilder.js +2 -0
  45. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  46. package/dist/Helpers/NexusHelpers.js +1 -0
  47. package/dist/Helpers/RequestCache.d.ts +35 -0
  48. package/dist/Helpers/RequestCache.js +113 -0
  49. package/dist/Helpers/TableExport.js +12 -12
  50. package/dist/Helpers/TableHelpers.d.ts +1 -1
  51. package/dist/Helpers/TableHelpers.js +7 -2
  52. package/dist/Helpers/TableJoins.d.ts +70 -0
  53. package/dist/Helpers/TableJoins.js +250 -0
  54. package/dist/Helpers/TableStore.d.ts +5 -0
  55. package/dist/Helpers/TableStore.js +9 -0
  56. package/dist/Helpers/TreeStore.d.ts +225 -11
  57. package/dist/Helpers/TreeStore.js +683 -90
  58. package/dist/Helpers/UserPreferences.d.ts +8 -0
  59. package/dist/Helpers/UserPreferences.js +17 -0
  60. package/dist/Interfaces/CrudInterfaces.d.ts +47 -9
  61. package/dist/Interfaces/FormInterfaces.d.ts +4 -1
  62. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  63. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  64. package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
  65. package/dist/Interfaces/PreferenceInterfaces.js +2 -5
  66. package/dist/Interfaces/TableInterfaces.d.ts +86 -4
  67. package/dist/Services/ApiProxy.d.ts +36 -1
  68. package/dist/Services/ApiProxy.js +182 -52
  69. package/dist/Services/ApiProxyRules.d.ts +203 -0
  70. package/dist/Services/ApiProxyRules.js +426 -0
  71. package/dist/Services/JoinSources.d.ts +48 -0
  72. package/dist/Services/JoinSources.js +74 -0
  73. package/dist/Services/ServerApi.d.ts +13 -2
  74. package/dist/Services/ServerApi.js +8 -3
  75. package/dist/Shared.Index.d.ts +4 -0
  76. package/dist/Shared.Index.js +4 -0
  77. package/package.json +87 -85
  78. package/src/Styles/Nexus.Avatar.css +79 -79
  79. package/src/Styles/Nexus.Base.css +117 -117
  80. package/src/Styles/Nexus.Button.css +180 -180
  81. package/src/Styles/Nexus.Calendar.css +273 -273
  82. package/src/Styles/Nexus.Chat.css +1443 -1443
  83. package/src/Styles/Nexus.Choice.css +343 -343
  84. package/src/Styles/Nexus.Crud.css +168 -168
  85. package/src/Styles/Nexus.DateTime.css +81 -81
  86. package/src/Styles/Nexus.Dropdown.css +725 -725
  87. package/src/Styles/Nexus.Editor.css +295 -295
  88. package/src/Styles/Nexus.Excel.css +389 -389
  89. package/src/Styles/Nexus.Form.css +279 -279
  90. package/src/Styles/Nexus.Index.css +37 -37
  91. package/src/Styles/Nexus.Input.css +196 -4
  92. package/src/Styles/Nexus.Markdown.css +237 -237
  93. package/src/Styles/Nexus.Menu.css +237 -237
  94. package/src/Styles/Nexus.Popup.css +724 -724
  95. package/src/Styles/Nexus.Rows.css +637 -637
  96. package/src/Styles/Nexus.Slider.css +276 -276
  97. package/src/Styles/Nexus.Tab.Buttons.css +432 -432
  98. package/src/Styles/Nexus.Table.css +31 -30
  99. package/src/Styles/Nexus.Theme.Picker.css +59 -3
  100. package/src/Styles/Nexus.Themes.css +194 -194
  101. package/src/Styles/Nexus.Time.css +144 -144
  102. package/src/Styles/Nexus.Toast.css +263 -263
  103. package/src/Styles/Nexus.Tokens.css +89 -89
  104. package/src/Styles/Nexus.Tree.css +500 -431
@@ -13,8 +13,9 @@ export interface ThemePickerProps {
13
13
  className?: string;
14
14
  }
15
15
  /**
16
- * Light / Dark / System, a card per theme with a live preview of its colors, the density, the animations and, in a
17
- * development build, the scale grid. Each choice applies at once and is saved in the browser.
16
+ * Two sections: the theme (Light / Dark / System and a card per theme with a live preview of its colors) and the
17
+ * appearance (the density, the animations and, in a development build, the scale grid). Each choice applies at once
18
+ * and is saved in the browser.
18
19
  */
19
20
  export declare function ThemePicker({ themes, showMode, showDensity, showAnimations, showGrid, className }: ThemePickerProps): import("react").JSX.Element;
20
21
  export interface PreferencesPickerProps {
@@ -15,6 +15,7 @@ import { useTheme } from "../../Services/ThemeService.js";
15
15
  import { TabButtons } from "../Documents/TabButtons.js";
16
16
  import { Switch } from "../Inputs/CheckBox.js";
17
17
  import { Dropdown } from "../Inputs/Dropdown.js";
18
+ import { TextBox } from "../Inputs/TextBox.js";
18
19
  const MODES = [
19
20
  { value: "light", label: "Light", icon: _jsx(InlineIcon, { icon: iconSun }) },
20
21
  { value: "dark", label: "Dark", icon: _jsx(InlineIcon, { icon: iconMoon }) },
@@ -38,14 +39,20 @@ const GRIDS = [
38
39
  function Choice({ id, legend, options, value, onChange, children }) {
39
40
  return (_jsxs("div", { className: "nx-theme-picker__group", children: [_jsx("p", { id: id, className: "nx-theme-picker__legend", children: legend }), children ?? (options && value !== undefined && onChange && _jsx(TabButtons, { "aria-labelledby": id, name: id, options: options, value: value, onChange: onChange, block: true }))] }));
40
41
  }
42
+ /** A group of settings under its name: one section of the panel, a line above it. */
43
+ function Section({ title, wide, children }) {
44
+ const id = useId();
45
+ return (_jsxs("section", { className: cx("nx-theme-picker__block", wide && "nx-theme-picker__block--wide"), "aria-labelledby": id, children: [_jsx("h3", { id: id, className: "nx-theme-picker__section", children: title }), children] }));
46
+ }
41
47
  /**
42
- * Light / Dark / System, a card per theme with a live preview of its colors, the density, the animations and, in a
43
- * development build, the scale grid. Each choice applies at once and is saved in the browser.
48
+ * Two sections: the theme (Light / Dark / System and a card per theme with a live preview of its colors) and the
49
+ * appearance (the density, the animations and, in a development build, the scale grid). Each choice applies at once
50
+ * and is saved in the browser.
44
51
  */
45
52
  export function ThemePicker({ themes = NEXUS_THEMES, showMode = true, showDensity = true, showAnimations = true, showGrid = isDevelopmentBuild(), className }) {
46
53
  const { theme, mode, density, animations, grid, setTheme, setMode, setDensity, setAnimations, setGrid } = useTheme();
47
54
  const name = useId();
48
- return (_jsxs("div", { className: cx("nx-theme-picker", className), children: [showMode && _jsx(Choice, { id: `${name}-mode`, legend: "Mode", options: MODES, value: mode, onChange: setMode }), _jsxs("fieldset", { className: "nx-theme-picker__group", children: [_jsx("legend", { className: "nx-theme-picker__legend", children: "Theme" }), _jsx("div", { className: "nx-theme-picker__grid", children: themes.map(option => (_jsxs("label", { className: "nx-theme-option", title: option.description, children: [_jsx("input", { type: "radio", className: "nx-visually-hidden", name: `${name}-theme`, value: option.id, checked: theme === option.id, onChange: () => setTheme(option.id) }), _jsx("span", { className: "nx-theme-option__preview", "data-nx-theme": option.id, "aria-hidden": "true", children: _jsxs("span", { className: "nx-theme-option__window", children: [_jsx("span", { className: "nx-theme-option__line" }), _jsx("span", { className: "nx-theme-option__line nx-theme-option__line--muted" }), _jsxs("span", { className: "nx-theme-option__swatches", children: [_jsx("span", { className: "nx-theme-option__pill" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--success" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--warning" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--danger" })] })] }) }), _jsxs("span", { className: "nx-theme-option__name", children: [option.name, _jsx(InlineIcon, { icon: iconCheck, className: "nx-theme-option__check" })] })] }, option.id))) })] }), showDensity && _jsx(Choice, { id: `${name}-density`, legend: "Density", options: DENSITIES, value: density, onChange: setDensity }), showAnimations && _jsx(Choice, { id: `${name}-animations`, legend: "Animations", options: ANIMATIONS, value: animations, onChange: setAnimations }), showGrid && _jsx(Choice, { id: `${name}-grid`, legend: "Scale grid", options: GRIDS, value: grid, onChange: setGrid })] }));
55
+ return (_jsxs("div", { className: cx("nx-theme-picker", className), children: [_jsxs(Section, { title: "Theme", wide: true, children: [showMode && _jsx(Choice, { id: `${name}-mode`, legend: "Mode", options: MODES, value: mode, onChange: setMode }), _jsxs("fieldset", { className: "nx-theme-picker__group", children: [_jsx("legend", { className: "nx-theme-picker__legend", children: "Colors" }), _jsx("div", { className: "nx-theme-picker__grid", children: themes.map(option => (_jsxs("label", { className: "nx-theme-option", title: option.description, children: [_jsx("input", { type: "radio", className: "nx-visually-hidden", name: `${name}-theme`, value: option.id, checked: theme === option.id, onChange: () => setTheme(option.id) }), _jsx("span", { className: "nx-theme-option__preview", "data-nx-theme": option.id, "aria-hidden": "true", children: _jsxs("span", { className: "nx-theme-option__window", children: [_jsx("span", { className: "nx-theme-option__line" }), _jsx("span", { className: "nx-theme-option__line nx-theme-option__line--muted" }), _jsxs("span", { className: "nx-theme-option__swatches", children: [_jsx("span", { className: "nx-theme-option__pill" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--success" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--warning" }), _jsx("span", { className: "nx-theme-option__dot nx-theme-option__dot--danger" })] })] }) }), _jsxs("span", { className: "nx-theme-option__name", children: [option.name, _jsx(InlineIcon, { icon: iconCheck, className: "nx-theme-option__check" })] })] }, option.id))) })] })] }), (showDensity || showAnimations || showGrid) && (_jsxs(Section, { title: "Appearance", children: [showDensity && _jsx(Choice, { id: `${name}-density`, legend: "Density", options: DENSITIES, value: density, onChange: setDensity }), showAnimations && _jsx(Choice, { id: `${name}-animations`, legend: "Animations", options: ANIMATIONS, value: animations, onChange: setAnimations }), showGrid && _jsx(Choice, { id: `${name}-grid`, legend: "Scale grid", options: GRIDS, value: grid, onChange: setGrid })] }))] }));
49
56
  }
50
57
  /* The quick preferences: language, dates, numbers and tables, beside the theme. */
51
58
  const LANGUAGES = [
@@ -75,6 +82,11 @@ const SEPARATORS = [
75
82
  { value: "none", label: "1200", title: "None" },
76
83
  ];
77
84
  const PAGE_SIZES = DEFAULT_PAGE_SIZES.map(size => ({ value: size, label: `${size} rows` }));
85
+ const DESIGNS = [
86
+ { value: "standard", label: "Standard", title: "The label above the box" },
87
+ { value: "outlined", label: "Outlined", title: "The label on the box's edge" },
88
+ { value: "inbox", label: "Inbox", title: "The label inside the top of the box" },
89
+ ];
78
90
  // A fixed day, so the server and the browser write the same examples, and one past the 12th, so day and month differ.
79
91
  const SAMPLE_DATE = "2026-09-27";
80
92
  /**
@@ -89,5 +101,5 @@ export function PreferencesPicker({ className }) {
89
101
  const preferences = useUserPreferences();
90
102
  const name = useId();
91
103
  const formats = DATE_FORMATS.map(format => ({ value: format, label: formatDate(SAMPLE_DATE, { ...dates, format }), description: format }));
92
- return (_jsxs("div", { className: cx("nx-theme-picker", className), children: [_jsx("h3", { className: "nx-theme-picker__section", children: "Language and region" }), _jsx(Choice, { id: `${name}-language`, legend: "Interface language", options: LANGUAGES, value: preferences.language, onChange: language => setUserPreferences({ language }) }), _jsx(Choice, { id: `${name}-calendar`, legend: "Calendar", options: CALENDARS, value: dates.calendar, onChange: calendar => setDatePreferences({ calendar }) }), _jsx("h3", { className: "nx-theme-picker__section", children: "Dates and times" }), _jsx(Choice, { id: `${name}-script`, legend: "Date picker language", options: SCRIPTS, value: dates.script, onChange: script => setDatePreferences({ script }) }), _jsx(Choice, { id: `${name}-clock`, legend: "Clock", options: CLOCKS, value: /H/.test(dates.timeFormat) ? "24" : "12", onChange: clock => setDatePreferences({ timeFormat: clock === "24" ? "HH:mm" : DEFAULT_TIME_FORMAT }) }), _jsx(Choice, { id: `${name}-format`, legend: "Display date format", children: _jsx(Dropdown, { "aria-labelledby": `${name}-format`, options: formats, value: dates.format, clearable: false, searchable: false, onValueChange: format => format && setDatePreferences({ format }) }) }), _jsx("h3", { className: "nx-theme-picker__section", children: "Numbers" }), _jsx(Choice, { id: `${name}-scale`, legend: "Number scale", options: SCALES, value: String(preferences.numberScale), onChange: scale => setUserPreferences({ numberScale: Number(scale) === ENumberScales.SouthAsian ? ENumberScales.SouthAsian : ENumberScales.International }) }), _jsx(Choice, { id: `${name}-thousands`, legend: "Thousands separator", options: SEPARATORS, value: preferences.thousands, onChange: thousands => setUserPreferences({ thousands }) }), _jsx("h3", { className: "nx-theme-picker__section", children: "Tables" }), _jsx(Choice, { id: `${name}-page-size`, legend: "Default page size", children: _jsx(Dropdown, { "aria-labelledby": `${name}-page-size`, options: PAGE_SIZES, value: preferences.pageSize ?? DEFAULT_PAGE_SIZE, clearable: false, searchable: false, onValueChange: pageSize => pageSize && setUserPreferences({ pageSize }) }) }), _jsx(Switch, { label: "A row click opens the record", labelPosition: "start", checked: preferences.rowClick ?? false, onValueChange: rowClick => setUserPreferences({ rowClick }) })] }));
104
+ return (_jsxs("div", { className: cx("nx-theme-picker", className), children: [_jsxs(Section, { title: "Language and region", children: [_jsx(Choice, { id: `${name}-language`, legend: "Interface language", options: LANGUAGES, value: preferences.language, onChange: language => setUserPreferences({ language }) }), _jsx(Choice, { id: `${name}-calendar`, legend: "Calendar", options: CALENDARS, value: dates.calendar, onChange: calendar => setDatePreferences({ calendar }) })] }), _jsxs(Section, { title: "Dates and times", children: [_jsx(Choice, { id: `${name}-script`, legend: "Date picker language", options: SCRIPTS, value: dates.script, onChange: script => setDatePreferences({ script }) }), _jsx(Choice, { id: `${name}-clock`, legend: "Clock", options: CLOCKS, value: /H/.test(dates.timeFormat) ? "24" : "12", onChange: clock => setDatePreferences({ timeFormat: clock === "24" ? "HH:mm" : DEFAULT_TIME_FORMAT }) }), _jsx(Choice, { id: `${name}-format`, legend: "Display date format", children: _jsx(Dropdown, { "aria-labelledby": `${name}-format`, options: formats, value: dates.format, clearable: false, searchable: false, onValueChange: format => format && setDatePreferences({ format }) }) })] }), _jsxs(Section, { title: "Numbers", children: [_jsx(Choice, { id: `${name}-scale`, legend: "Number scale", options: SCALES, value: String(preferences.numberScale), onChange: scale => setUserPreferences({ numberScale: Number(scale) === ENumberScales.SouthAsian ? ENumberScales.SouthAsian : ENumberScales.International }) }), _jsx(Choice, { id: `${name}-thousands`, legend: "Thousands separator", options: SEPARATORS, value: preferences.thousands, onChange: thousands => setUserPreferences({ thousands }) })] }), _jsxs(Section, { title: "Tables", children: [_jsx(Choice, { id: `${name}-page-size`, legend: "Default page size", children: _jsx(Dropdown, { "aria-labelledby": `${name}-page-size`, options: PAGE_SIZES, value: preferences.pageSize ?? DEFAULT_PAGE_SIZE, clearable: false, searchable: false, onValueChange: pageSize => pageSize && setUserPreferences({ pageSize }) }) }), _jsx(Switch, { label: "A row click opens the record", labelPosition: "start", checked: preferences.rowClick ?? false, onValueChange: rowClick => setUserPreferences({ rowClick }) })] }), _jsxs(Section, { title: "Forms", children: [_jsx(Choice, { id: `${name}-design`, legend: "Input design", options: DESIGNS, value: preferences.inputDesign ?? "standard", onChange: inputDesign => setUserPreferences({ inputDesign }) }), _jsx(TextBox, { label: "Example", placeholder: "Type to try it" })] })] }));
93
105
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconAlertCircle, iconArrowAutofitWidth, iconArrowBarToLeft, iconArrowBarToRight, iconArrowDown, iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowsSort, iconChecks, iconChevronDown, iconColumns3, iconDatabase, iconDotsVertical, iconEyeOff, iconDownload, iconFileExport, iconFileImport, iconFilter, iconFilterOff, iconPinnedOff, iconPlus, iconPrinter, iconRefresh, iconRestore, iconRowInsertBottom, iconSearch, iconSearchOff, iconSortAscending, iconSortDescending, iconX, InlineIcon, } from "nexus-icons";
3
+ import { iconAlertCircle, iconArrowAutofitWidth, iconArrowBarToLeft, iconArrowBarToRight, iconArrowDown, iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowsSort, iconChecks, iconChevronDown, iconColumns3, iconDatabase, iconDotsVertical, iconEyeOff, iconDownload, iconFileExport, iconFileImport, iconFilter, iconFilterOff, iconPin, iconPinnedOff, iconPlus, iconPrinter, iconRefresh, iconRestore, iconRowInsertBottom, iconSearch, iconSearchOff, iconSortAscending, iconSortDescending, iconX, InlineIcon, } from "nexus-icons";
4
4
  import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { flushSync } from "react-dom";
6
6
  import { cx } from "../../Helpers/ClassHelpers.js";
@@ -8,6 +8,7 @@ import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePrefer
8
8
  import { fitColumnWidth, headLeafCells, startColumnDrag, startColumnResize } from "../../Helpers/TableColumns.js";
9
9
  import { exportTableCsv, printTable } from "../../Helpers/TableExport.js";
10
10
  import { asFilterInput, filterNames, filterRequest } from "../../Helpers/TableFilters.js";
11
+ import { joinKeys, joinSourceName, tableJoins } from "../../Helpers/TableJoins.js";
11
12
  import { canPinColumn, columnAlign, columnValue, cssLength, DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES, formatCellText, headBands, MIN_COLUMN_WIDTH, orderColumns, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, viewColumns, } from "../../Helpers/TableHelpers.js";
12
13
  import { TableStore } from "../../Helpers/TableStore.js";
13
14
  import { readUserPreferences, useNumberLocale, useUserPreferences } from "../../Helpers/UserPreferences.js";
@@ -17,14 +18,15 @@ import { SplitButton } from "../Documents/SplitButton.js";
17
18
  import { TextBox } from "../Inputs/TextBox.js";
18
19
  import { toast } from "../Layouts/Toaster.js";
19
20
  import { downloadExcelTemplate, exportExcel } from "../../Services/ExcelApi.js";
21
+ import { joinSource } from "../../Services/JoinSources.js";
20
22
  import { openExcelImport } from "../Forms/ExcelImport.js";
21
23
  import { TableColumnsPanel } from "./DataTableColumns.js";
22
24
  import { TableFiltersPanel } from "./DataTableFilters.js";
23
- import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TablePinnedHead, TableRowView, TableSkeletonRows, } from "./DataTableParts.js";
25
+ import { columnStyle, formatCount, TableColumnGroup, TableFooter, TableIndexCell, TableMessageRow, TablePageSize, TableRowView, TableSectionHead, TableSkeletonRows, } from "./DataTableParts.js";
24
26
  /*
25
27
  * DataTable: rows in a table with a search, sorting by column, pages, a selection with actions on the selected rows,
26
28
  * actions on each row, and columns the user arranges (shown or hidden, in any order, resized, pinned to a side). Rows
27
- * given as `pinnedRows` sit in a section above the page that folds open and shut. Rows are given, loaded once, or
29
+ * given as `sections` (the records pinned, the favourites) sit above the page, each folding open and shut. Rows are given, loaded once, or
28
30
  * loaded a page at a time; what the table shows lives in a `TableStore`, which pages (and the CRUD component) change
29
31
  * from outside.
30
32
  */
@@ -37,6 +39,10 @@ export function useTable() {
37
39
  return store;
38
40
  }
39
41
  const STATE_PREFIX = "nx-table:";
42
+ /** The key of the section `pinnedRows` makes. */
43
+ const PINS_SECTION = "pins";
44
+ /** The lookups' version for a table with no `join` column, which never listens to them. */
45
+ const NO_JOINS = () => 0;
40
46
  function readSaved(key) {
41
47
  if (!key || typeof localStorage === "undefined")
42
48
  return null;
@@ -48,7 +54,7 @@ function readSaved(key) {
48
54
  return null;
49
55
  }
50
56
  }
51
- // Settings are written in two places (the query and the columns together, the pinned section on its own click), so a
57
+ // Settings are written in two places (the query and the columns together, a section's fold on its own click), so a
52
58
  // write keeps whatever it does not mention.
53
59
  function writeSaved(key, changes) {
54
60
  try {
@@ -93,7 +99,7 @@ const sameNumbers = (a, b) => {
93
99
  * />
94
100
  */
95
101
  export function DataTable(props) {
96
- const { columns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", toolbar, actions, add, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, selectBy, rowActions, onRowOpen, openOnClick: ownOpenOnClick, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, excel, pageSize: ownPageSize, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, emptyMessage = "No records yet.", emptyAction, maxHeight, fill = false, size = "md", id, className, style, ref, } = props;
102
+ const { columns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", toolbar, actions, add, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, selectBy, rowActions, onRowOpen, openOnClick: ownOpenOnClick, sections: ownSections, rowMarks, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, excel, pageSize: ownPageSize, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, emptyMessage = "No records yet.", emptyAction, maxHeight, fill = false, size = "md", id, className, style, ref, } = props;
97
103
  const paging = givenRows ? "client" : (props.paging ?? "client");
98
104
  const reloadable = props.reloadable ?? Boolean(load);
99
105
  const searchDelay = props.searchDelay ?? (paging === "server" ? 300 : 0);
@@ -114,15 +120,22 @@ export function DataTable(props) {
114
120
  table.configure({ columns, rowKey, rows: givenRows, load, paging, maxSelection, onSelectionChange, filterInputs });
115
121
  useImperativeHandle(ref, () => table, [table]);
116
122
  const state = useSyncExternalStore(table.subscribe, table.getState, table.getState);
123
+ // The columns whose values are other records', looked up by key (`join`). Only a table with one listens to the page's
124
+ // lookups, and it draws again - its rows too, through `cellOptions` - each time names come in.
125
+ const joins = useMemo(() => columns.flatMap(column => (column.join ? [column.join] : [])), [columns]);
126
+ const subscribeJoins = useCallback((listener) => (joins.length ? tableJoins.subscribe(listener) : () => { }), [joins.length]);
127
+ const joinsVersion = useSyncExternalStore(subscribeJoins, joins.length ? tableJoins.getVersion : NO_JOINS, NO_JOINS);
117
128
  const dates = useDatePreferences();
118
129
  const timeZone = useBrowserTimeZone();
119
130
  const numbers = useNumberLocale();
120
- const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers }), [dates, timeZone, numbers]);
131
+ // `joinsVersion` is read by no cell option: a new object is what tells the memoized rows that their names came in.
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ const cellOptions = useMemo(() => ({ preferences: dates, timeZone: timeZone ?? undefined, locale: numbers }), [dates, timeZone, numbers, joinsVersion]);
121
134
  const tableId = useId();
122
135
  // The search box is uncontrolled: a new key clears it when a new list starts over.
123
136
  const [searchGeneration, setSearchGeneration] = useState(0);
124
- // The pinned section starts folded shut, then takes the choice this device remembers (a layout effect: no flicker).
125
- const [pinsOpen, setPinsOpen] = useState(false);
137
+ // Every section starts folded shut, then takes the choice this device remembers (a layout effect: no flicker).
138
+ const [openSections, setOpenSections] = useState(() => new Set());
126
139
  const searchTimer = useRef(undefined);
127
140
  const searchRef = useRef(null);
128
141
  const started = useRef(false);
@@ -141,8 +154,9 @@ export function DataTable(props) {
141
154
  widths: saved?.widths,
142
155
  pinned: saved?.pinned,
143
156
  });
144
- if (saved?.pinsOpen)
145
- setPinsOpen(true);
157
+ const open = saved?.openSections ?? (saved?.pinsOpen ? [PINS_SECTION] : []);
158
+ if (open.length)
159
+ setOpenSections(new Set(open));
146
160
  if (givenRows)
147
161
  table.setRows(givenRows);
148
162
  else
@@ -198,6 +212,20 @@ export function DataTable(props) {
198
212
  table.setPageSize(openingPageSize());
199
213
  // eslint-disable-next-line react-hooks/exhaustive-deps
200
214
  }, [preferences.pageSize]);
215
+ // Every key the rows hold for a join, asked for together: a client table's every row, since it searches and sorts them
216
+ // all, a server table's page, and the rows of the sections above it. What is known already is not asked again.
217
+ useEffect(() => {
218
+ if (!joins.length || state.status !== "ready")
219
+ return;
220
+ const shown = [...table.getRows(), ...(pinnedRows ?? []), ...(ownSections ?? []).flatMap(section => section.rows)];
221
+ for (const [source, keys] of joinKeys(joins, shown))
222
+ tableJoins.request(joinSource(source), keys);
223
+ }, [joins, state.status, state.rows, pinnedRows, ownSections, table]);
224
+ // Names came in: a client table searches and sorts again with them.
225
+ useEffect(() => {
226
+ if (joinsVersion)
227
+ table.refreshValues();
228
+ }, [joinsVersion, table]);
201
229
  const visible = useMemo(() => columns.filter(column => !state.hidden.has(column.key)), [columns, state.hidden]);
202
230
  // Every column in the order the user put them in, hidden ones among them: what the Columns panel lists.
203
231
  const ordered = useMemo(() => orderColumns(columns, state.order), [columns, state.order]);
@@ -463,7 +491,11 @@ export function DataTable(props) {
463
491
  cancelAnimationFrame(frame);
464
492
  };
465
493
  }, [addPart, listsPart, searchable, reloadable]);
494
+ // A person's Reload is a refresh of what the table shows, the names it looked up included: those are marked old - still
495
+ // drawn, asked again with the rows - and nothing else the page keeps is touched.
466
496
  const reload = () => {
497
+ for (const join of joins)
498
+ tableJoins.expire(joinSourceName(join.source));
467
499
  onReload?.();
468
500
  void table.reload({ quiet: true });
469
501
  };
@@ -544,24 +576,31 @@ export function DataTable(props) {
544
576
  body = rows.map((row, index) => {
545
577
  const key = pageKeys[index];
546
578
  const selected = state.selected.has(key);
547
- return (_jsx(TableRowView, { row: row, rowKey: key, number: state.offset + index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, key || index));
579
+ return (_jsx(TableRowView, { row: row, rowKey: key, number: state.offset + index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, marks: rowMarks?.(row), frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, key || index));
548
580
  });
549
581
  }
550
- /* The pinned section above the page: the records the user pinned, folded open or shut, remembered on this device */
582
+ /* The sections above the page: the records pinned, the favourites, each folded open or shut, remembered on this device */
583
+ const pinsSection = pinnedRows ? { key: PINS_SECTION, label: pinnedLabel, icon: iconPin, rows: pinnedRows, loading: pinnedLoading, max: pinnedMax } : null;
551
584
  // A section with nothing in it and nothing on its way would be an empty heading, so it does not show at all.
552
- const pins = pinnedRows && (pinnedRows.length > 0 || pinnedLoading) ? pinnedRows : null;
553
- const togglePins = () => {
554
- const next = !pinsOpen;
555
- setPinsOpen(next);
585
+ const sections = [...(pinsSection ? [pinsSection] : []), ...(ownSections ?? [])].filter(section => section.rows.length > 0 || section.loading);
586
+ const toggleSection = (key) => {
587
+ const next = new Set(openSections);
588
+ if (!next.delete(key))
589
+ next.add(key);
590
+ setOpenSections(next);
556
591
  if (stateKey)
557
- writeSaved(stateKey, { pinsOpen: next });
592
+ writeSaved(stateKey, { openSections: [...next], pinsOpen: undefined });
558
593
  };
559
- const pinnedBody = pinsOpen &&
560
- pins?.map((row, index) => {
594
+ const sectionRows = (section) => {
595
+ // A row with no marks of its own still wears the section's icon.
596
+ const own = [{ icon: section.icon, label: section.label }];
597
+ return section.rows.map((row, index) => {
561
598
  const key = table.keyOf(row);
562
599
  const selected = state.selected.has(key);
563
- return (_jsx(TableRowView, { row: row, rowKey: key, number: index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, pinned: true, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, `pin:${key || index}`));
600
+ const marks = rowMarks?.(row);
601
+ return (_jsx(TableRowView, { row: row, rowKey: key, number: index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, marks: marks?.length ? marks : own, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick }, key || index));
564
602
  });
603
+ };
565
604
  const selectedRows = selecting ? [...state.selected.values()] : [];
566
605
  // Select again by what the rows are: exactly the rows on the page that pass, or the whole page.
567
606
  const pick = (label, test) => {
@@ -590,7 +629,10 @@ export function DataTable(props) {
590
629
  lastToggled.current = null;
591
630
  if (table.selectPage(event.currentTarget.checked) > 0)
592
631
  warnFull();
593
- } }) })) })), bands.map(band => band.group === null ? (headingCell(band.columns[0], headRows)) : (_jsx(GroupHeading, { band: band }, `group:${band.group}:${band.columns[0].column.key}`))), sized && _jsx("td", { className: "nx-table__heading nx-table__cell--filler", rowSpan: headRows }), frame.actions && (_jsx("th", { scope: "col", className: "nx-table__heading nx-table__cell--actions", rowSpan: headRows, "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: { right: frame.actions.offset }, children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }), grouped && _jsx("tr", { className: "nx-table__head nx-table__head--leaves", children: bands.filter(band => band.group !== null).flatMap(band => band.columns.map(view => headingCell(view))) })] }), pins && (_jsxs("tbody", { className: "nx-table__pins", "data-open": pinsOpen || undefined, children: [_jsx(TablePinnedHead, { span: span, label: pinnedLabel, count: pins.length, max: pinnedMax, loading: pinnedLoading, open: pinsOpen, onToggle: togglePins }), pinnedBody] })), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, onPage: page => table.setPage(page) })] }));
632
+ } }) })) })), bands.map(band => band.group === null ? (headingCell(band.columns[0], headRows)) : (_jsx(GroupHeading, { band: band }, `group:${band.group}:${band.columns[0].column.key}`))), sized && _jsx("td", { className: "nx-table__heading nx-table__cell--filler", rowSpan: headRows }), frame.actions && (_jsx("th", { scope: "col", className: "nx-table__heading nx-table__cell--actions", rowSpan: headRows, "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: { right: frame.actions.offset }, children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }), grouped && _jsx("tr", { className: "nx-table__head nx-table__head--leaves", children: bands.filter(band => band.group !== null).flatMap(band => band.columns.map(view => headingCell(view))) })] }), sections.map(section => {
633
+ const open = openSections.has(section.key);
634
+ return (_jsxs("tbody", { className: "nx-table__section", "data-section": section.key, "data-open": open || undefined, children: [_jsx(TableSectionHead, { span: span, label: section.label, icon: section.icon, count: section.rows.length, max: section.max, loading: Boolean(section.loading), open: open, onToggle: () => toggleSection(section.key) }), open && sectionRows(section)] }, section.key));
635
+ }), _jsx("tbody", { children: body })] }) })] }), _jsx(TableFooter, { state: state, disabled: refreshing, onPage: page => table.setPage(page) })] }));
594
636
  }
595
637
  /** Where "Move left" and "Move right" put a column: the key it goes before, or null for the end of its side. */
596
638
  function moveTargets(views, key) {
@@ -1,10 +1,14 @@
1
1
  import { type IconNode } from "nexus-icons";
2
2
  import { type CSSProperties, type MouseEvent, type ReactNode } from "react";
3
3
  import { type CellTextOptions } from "../../Helpers/TableHelpers.ts";
4
- import type { TableColumn, TableRow, TableRowAction, TableState, TableViewColumn } from "../../Interfaces/TableInterfaces.ts";
4
+ import type { TableColumn, TableRow, TableRowAction, TableRowMark, TableState, TableViewColumn } from "../../Interfaces/TableInterfaces.ts";
5
5
  /** A number as the table writes counts: 1,234. */
6
6
  export declare const formatCount: (count: number) => string;
7
- /** A value in its column's format: text, a tag for an option with a tone, a check for `true`. Empty values show a dash. */
7
+ /**
8
+ * A value in its column's format: text, a tag for an option with a tone, a check for `true`. Empty values show a dash.
9
+ * A `join` column shows a placeholder until the record it looks up is in, and "Not found" for a key with no record; a
10
+ * column with a `tag` shows it after the value: "Asia" then "Trashed".
11
+ */
8
12
  export declare function TableCellValue<TRow extends TableRow>({ column, row, options }: {
9
13
  column: TableColumn<TRow>;
10
14
  row: TRow;
@@ -66,8 +70,8 @@ export interface TableRowViewProps<TRow extends TableRow> {
66
70
  busy: boolean;
67
71
  flashed: boolean;
68
72
  numbered: boolean;
69
- /** A row of the pinned section above the page: its number is a pin. */
70
- pinned?: boolean;
73
+ /** The row's marks (pinned, a favourite), worn one under the other where a row without any shows its number. */
74
+ marks?: readonly TableRowMark[];
71
75
  /** Where the table's own columns sit; `null` for the ones it does not have. */
72
76
  frame: TableFrameColumns;
73
77
  /** The user gave a column a width, so the table lays its columns out from the `<colgroup>`. */
@@ -80,13 +84,27 @@ export interface TableRowViewProps<TRow extends TableRow> {
80
84
  openOnClick?: boolean;
81
85
  }
82
86
  /** One row. It renders again only when its row, its state, or the columns change. */
83
- declare function TableRowViewInner<TRow extends TableRow>({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, }: TableRowViewProps<TRow>): import("react").JSX.Element;
87
+ declare function TableRowViewInner<TRow extends TableRow>({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, marks, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, }: TableRowViewProps<TRow>): import("react").JSX.Element;
84
88
  export declare const TableRowView: typeof TableRowViewInner;
85
89
  /**
86
- * The head of the pinned section: one row across the table with a button that folds the pinned records open and shut,
87
- * how many there are, and the most that can be pinned.
90
+ * The head of a section above the page: one row across the table with a button that folds the section's records open
91
+ * and shut, how many there are, and the most it can hold.
92
+ */
93
+ export declare function TableSectionHead({ span, label, icon, count, max, loading, open, onToggle }: {
94
+ span: number;
95
+ label: string;
96
+ icon: IconNode;
97
+ count: number;
98
+ max?: number;
99
+ loading: boolean;
100
+ open: boolean;
101
+ onToggle: () => void;
102
+ }): import("react").JSX.Element;
103
+ /**
104
+ * The pinned section's head, by its 3.1.0 name: a `TableSectionHead` with the pin icon.
105
+ * @deprecated since 3.2.0, when favourites became a second section: use `TableSectionHead`, which takes the icon.
88
106
  */
89
- export declare function TablePinnedHead({ span, label, count, max, loading, open, onToggle }: {
107
+ export declare function TablePinnedHead(props: {
90
108
  span: number;
91
109
  label: string;
92
110
  count: number;
@@ -1,21 +1,43 @@
1
1
  "use client";
2
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { iconCheck, iconChevronDown, iconChevronLeft, iconChevronRight, iconDotsVertical, iconLoader2, iconPin, InlineIcon } from "nexus-icons";
4
4
  import { memo } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
6
  import { columnAlign, columnOption, columnValue, cssLength, formatCellText, pageButtons, pageCount, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, } from "../../Helpers/TableHelpers.js";
7
+ import { joinedState } from "../../Helpers/TableJoins.js";
7
8
  import { Button } from "../Documents/Button.js";
8
9
  import { Menu } from "../Documents/Menu.js";
9
10
  /*
10
11
  * The parts of a data table: a cell in its column's format, a row with its number and checkbox in one column and its
11
- * actions, the widths of the columns the user set, the head of the pinned section, the pager, and the footer. The
12
+ * actions, the widths of the columns the user set, the head of a section above the page, the pager, and the footer. The
12
13
  * details view of the CRUD draws values with the same `TableCellValue`.
13
14
  */
14
15
  const countFormat = new Intl.NumberFormat("en-US");
15
16
  /** A number as the table writes counts: 1,234. */
16
17
  export const formatCount = (count) => countFormat.format(count);
17
- /** A value in its column's format: text, a tag for an option with a tone, a check for `true`. Empty values show a dash. */
18
+ /**
19
+ * A value in its column's format: text, a tag for an option with a tone, a check for `true`. Empty values show a dash.
20
+ * A `join` column shows a placeholder until the record it looks up is in, and "Not found" for a key with no record; a
21
+ * column with a `tag` shows it after the value: "Asia" then "Trashed".
22
+ */
18
23
  export function TableCellValue({ column, row, options }) {
24
+ const joined = column.join ? joinedState(column.join, row) : null;
25
+ if (joined?.status === "loading")
26
+ return _jsx("span", { className: "nx-table__bone", "aria-label": "Loading" });
27
+ if (joined?.status === "missing" || joined?.status === "failed") {
28
+ return (_jsxs(_Fragment, { children: [_jsx("span", { className: "nx-table__dash", children: "\u2014" }), joined.status === "missing" && _jsx(TableTagView, { tag: { label: "Not found", tone: "danger" } })] }));
29
+ }
30
+ const tag = column.tag?.(row, joined?.status === "ready" ? joined.record : undefined);
31
+ const value = _jsx(TableCellContent, { column: column, row: row, options: options });
32
+ if (!tag)
33
+ return value;
34
+ return (_jsxs(_Fragment, { children: [value, _jsx(TableTagView, { tag: tag })] }));
35
+ }
36
+ /** A short tag after a cell's value, in the look of a column option's tag. */
37
+ function TableTagView({ tag }) {
38
+ return (_jsx("span", { className: "nx-table__tag nx-table__tag--after", "data-tone": tag.tone, children: tag.label }));
39
+ }
40
+ function TableCellContent({ column, row, options }) {
19
41
  const value = columnValue(column, row);
20
42
  if (column.render)
21
43
  return _jsx(_Fragment, { children: column.render(row, value) });
@@ -104,21 +126,28 @@ function opensRow(event, single) {
104
126
  return !(selection && !selection.isCollapsed && event.currentTarget.contains(selection.anchorNode));
105
127
  }
106
128
  /** One row. It renders again only when its row, its state, or the columns change. */
107
- function TableRowViewInner({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, }) {
129
+ function TableRowViewInner({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, marks, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, }) {
108
130
  const inline = actions?.filter(action => action.inline && action.icon) ?? [];
109
131
  const inMenu = actions?.filter(action => !(action.inline && action.icon)) ?? [];
110
132
  const items = inMenu.map(action => ({ value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled }));
111
- return (_jsxs("tr", { className: "nx-table__row", "data-key": rowKey, "data-pinned": pinned || undefined, "data-selected": selected || undefined, "data-busy": busy || undefined, "data-flash": flashed || undefined, "aria-busy": busy || undefined, "data-open": (onOpen && openOnClick) || undefined,
133
+ return (_jsxs("tr", { className: "nx-table__row", "data-key": rowKey, "data-selected": selected || undefined, "data-busy": busy || undefined, "data-flash": flashed || undefined, "aria-busy": busy || undefined, "data-open": (onOpen && openOnClick) || undefined,
112
134
  // One way in: with a single click the row is already open by the time a double-click would arrive.
113
- onClick: onOpen && openOnClick ? event => opensRow(event, true) && onOpen(row) : undefined, onDoubleClick: onOpen && !openOnClick ? event => opensRow(event, false) && onOpen(row) : undefined, children: [frame.index && (_jsx(TableIndexCell, { own: frame.index, number: pinned ? _jsx(InlineIcon, { icon: iconPin, className: "nx-table__pin-mark" }) : formatCount(number), numbered: numbered || Boolean(pinned), selectable: selectable, checkbox: selectable && (_jsx("label", { className: "nx-choice nx-choice--sm nx-table__check", title: selectionFull ? "No more rows can be selected." : undefined, children: _jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: selected, disabled: selectionFull && !selected, "aria-label": `Select ${label}`, onClick: event => onToggle(row, event.currentTarget.checked, event), onChange: () => undefined }) })) })), columns.map(view => (_jsx("td", { className: cx("nx-table__cell", `nx-table__cell--${columnAlign(view.column)}`, view.column.wrap && "nx-table__cell--wrap"), "data-col": view.column.key, "data-pin": view.pin ?? undefined, "data-pin-edge": (view.pin && view.edge) || undefined, style: columnStyle(view, sized), children: _jsx("span", { className: "nx-table__value", children: _jsx(TableCellValue, { column: view.column, row: row, options: cellOptions }) }) }, view.column.key))), sized && _jsx("td", { className: "nx-table__cell nx-table__cell--filler" }), frame.actions && (_jsx("td", { className: "nx-table__cell nx-table__cell--actions", "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: ownStyle(frame.actions, "right"), children: _jsxs("div", { className: "nx-table__actions", children: [inline.map(action => (_jsx(Button, { variant: "ghost", size: "sm", icon: action.icon, hoverTone: action.tone === "danger" ? "danger" : undefined, "aria-label": `${action.label}: ${label}`, title: action.label, "data-action": action.id, disabled: busy || action.disabled, onClick: () => onAction(row, action) }, action.id))), items.length > 0 && (_jsx(Menu, { items: items, align: "end", "aria-label": `Actions: ${label}`, onSelect: value => onAction(row, inMenu.find(action => action.id === value)), children: _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), loading: busy, "aria-label": `More actions: ${label}`, title: "More actions" }) })), items.length === 0 && busy && _jsx(Button, { variant: "ghost", size: "sm", loading: true, "aria-label": `Working: ${label}` })] }) }))] }));
135
+ onClick: onOpen && openOnClick ? event => opensRow(event, true) && onOpen(row) : undefined, onDoubleClick: onOpen && !openOnClick ? event => opensRow(event, false) && onOpen(row) : undefined, children: [frame.index && (_jsx(TableIndexCell, { own: frame.index, number: marks?.length ? (_jsx("span", { className: "nx-table__marks", children: marks.map(mark => (_jsx(InlineIcon, { icon: mark.icon, className: "nx-table__mark", label: mark.label }, mark.label))) })) : (formatCount(number)), numbered: numbered || Boolean(marks?.length), selectable: selectable, checkbox: selectable && (_jsx("label", { className: "nx-choice nx-choice--sm nx-table__check", title: selectionFull ? "No more rows can be selected." : undefined, children: _jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: selected, disabled: selectionFull && !selected, "aria-label": `Select ${label}`, onClick: event => onToggle(row, event.currentTarget.checked, event), onChange: () => undefined }) })) })), columns.map(view => (_jsx("td", { className: cx("nx-table__cell", `nx-table__cell--${columnAlign(view.column)}`, view.column.wrap && "nx-table__cell--wrap"), "data-col": view.column.key, "data-pin": view.pin ?? undefined, "data-pin-edge": (view.pin && view.edge) || undefined, style: columnStyle(view, sized), children: _jsx("span", { className: "nx-table__value", children: _jsx(TableCellValue, { column: view.column, row: row, options: cellOptions }) }) }, view.column.key))), sized && _jsx("td", { className: "nx-table__cell nx-table__cell--filler" }), frame.actions && (_jsx("td", { className: "nx-table__cell nx-table__cell--actions", "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: ownStyle(frame.actions, "right"), children: _jsxs("div", { className: "nx-table__actions", children: [inline.map(action => (_jsx(Button, { variant: "ghost", size: "sm", icon: action.icon, hoverTone: action.tone === "danger" ? "danger" : undefined, "aria-label": `${action.label}: ${label}`, title: action.label, "data-action": action.id, disabled: busy || action.disabled, onClick: () => onAction(row, action) }, action.id))), items.length > 0 && (_jsx(Menu, { items: items, align: "end", "aria-label": `Actions: ${label}`, onSelect: value => onAction(row, inMenu.find(action => action.id === value)), children: _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), loading: busy, "aria-label": `More actions: ${label}`, title: "More actions" }) })), items.length === 0 && busy && _jsx(Button, { variant: "ghost", size: "sm", loading: true, "aria-label": `Working: ${label}` })] }) }))] }));
114
136
  }
115
137
  export const TableRowView = memo(TableRowViewInner);
116
138
  /**
117
- * The head of the pinned section: one row across the table with a button that folds the pinned records open and shut,
118
- * how many there are, and the most that can be pinned.
139
+ * The head of a section above the page: one row across the table with a button that folds the section's records open
140
+ * and shut, how many there are, and the most it can hold.
141
+ */
142
+ export function TableSectionHead({ span, label, icon, count, max, loading, open, onToggle }) {
143
+ return (_jsx("tr", { className: "nx-table__row nx-table__section-row", children: _jsx("td", { colSpan: span, className: "nx-table__section-cell", children: _jsxs("button", { type: "button", className: "nx-table__section-toggle", "aria-expanded": open, onClick: onToggle, children: [_jsx(InlineIcon, { icon: iconChevronRight, className: "nx-table__section-caret" }), _jsx(InlineIcon, { icon: icon, className: "nx-table__section-icon" }), _jsx("span", { className: "nx-table__section-title", children: label }), (count > 0 || !loading) && (_jsx("span", { className: "nx-table__section-count", children: max ? `${formatCount(count)} of ${formatCount(max)}` : formatCount(count) })), loading && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin nx-table__section-loading" })] }) }) }));
144
+ }
145
+ /**
146
+ * The pinned section's head, by its 3.1.0 name: a `TableSectionHead` with the pin icon.
147
+ * @deprecated since 3.2.0, when favourites became a second section: use `TableSectionHead`, which takes the icon.
119
148
  */
120
- export function TablePinnedHead({ span, label, count, max, loading, open, onToggle }) {
121
- return (_jsx("tr", { className: "nx-table__row nx-table__pin-row", children: _jsx("td", { colSpan: span, className: "nx-table__pin-cell", children: _jsxs("button", { type: "button", className: "nx-table__pin-toggle", "aria-expanded": open, onClick: onToggle, "data-table-pins": true, children: [_jsx(InlineIcon, { icon: iconChevronRight, className: "nx-table__pin-caret" }), _jsx(InlineIcon, { icon: iconPin, className: "nx-table__pin-icon" }), _jsx("span", { className: "nx-table__pin-title", children: label }), (count > 0 || !loading) && (_jsx("span", { className: "nx-table__pin-count", children: max ? `${formatCount(count)} of ${formatCount(max)}` : formatCount(count) })), loading && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin nx-table__pin-loading" })] }) }) }));
149
+ export function TablePinnedHead(props) {
150
+ return _jsx(TableSectionHead, { ...props, icon: iconPin });
122
151
  }
123
152
  /** Placeholder rows while the first rows load, in the shape of the columns. */
124
153
  export function TableSkeletonRows({ count, cells, frame, sized }) {
@@ -1,5 +1,5 @@
1
1
  import { NexusModule, type ApiEndpoint, type ApiModuleInfo, type ApiModuleValue } from "../Interfaces/ApiInterfaces.ts";
2
- import { apiEndpoint, type ApiEndpointExtras, type ApiUrlParts } from "./ApiRoutes.ts";
2
+ import { apiEndpoint, type ApiControllerOptions, type ApiEndpointExtras, type ApiUrlParts } from "./ApiRoutes.ts";
3
3
  /** Where the proxy route sits in an app: `app/api/app/[module]/[...path]/route.ts`. */
4
4
  export declare const DEFAULT_PROXY_PATH = "/api/app";
5
5
  /** An environment variable, read when it is needed (never inlined at build time), so one build serves every environment. */
@@ -67,39 +67,8 @@ export declare abstract class ApiModules<TModule extends ApiModuleValue = ApiMod
67
67
  protected readEnv(name: string | undefined): string | undefined;
68
68
  /** Endpoints typed to this app's modules: `MODULES.endpoint.get(HrModule.Staff, "Employee/Birthdays")`. */
69
69
  readonly endpoint: Record<keyof typeof apiEndpoint, (module: TModule, path: string, extras?: ApiEndpointExtras) => ApiEndpoint<TModule>>;
70
- /** A controller's standard endpoints, typed to this app's modules. See `apiController`. */
71
- controller(module: TModule, controller: string, subject?: string): {
72
- module: TModule;
73
- controller: string;
74
- listing: ApiEndpoint<TModule>;
75
- activeListing: ApiEndpoint<TModule>;
76
- findListing: ApiEndpoint<TModule>;
77
- pagination: ApiEndpoint<TModule>;
78
- lists: Record<import("../Shared.Index.ts").CrudMode, ApiEndpoint<TModule>>;
79
- pages: Record<import("../Shared.Index.ts").CrudMode, ApiEndpoint<TModule>>;
80
- pins: ApiEndpoint<TModule>;
81
- details: ApiEndpoint<TModule>;
82
- create: ApiEndpoint<TModule>;
83
- update: ApiEndpoint<TModule>;
84
- delete: ApiEndpoint<TModule>;
85
- trash: ApiEndpoint<TModule>;
86
- trashFromDelete: ApiEndpoint<TModule>;
87
- recover: ApiEndpoint<TModule>;
88
- flag: ApiEndpoint<TModule>;
89
- pin: ApiEndpoint<TModule>;
90
- audit: ApiEndpoint<TModule>;
91
- accessActions: ApiEndpoint<TModule>;
92
- mass: {
93
- description: ApiEndpoint<TModule>;
94
- trash: ApiEndpoint<TModule>;
95
- trashFromDelete: ApiEndpoint<TModule>;
96
- flag: ApiEndpoint<TModule>;
97
- pin: ApiEndpoint<TModule>;
98
- delete: ApiEndpoint<TModule>;
99
- recover: ApiEndpoint<TModule>;
100
- };
101
- action: (method: import("../Shared.Index.ts").HttpMethod, action: string, extras?: Partial<Omit<ApiEndpoint<number>, "path" | "method" | "module">> | undefined) => ApiEndpoint<TModule>;
102
- };
70
+ /** A controller's standard endpoints, typed to this app's modules: its generation and what it lacks in `options`. See `apiController`. */
71
+ controller(module: TModule, controller: string, subject?: string, options?: ApiControllerOptions): import("./ApiRoutes.ts").ApiControllerEndpoints<TModule>;
103
72
  }
104
73
  /** The Nexus modules, their names in proxy URLs, and the environment variables with their roots (as in the previous project). */
105
74
  export declare const NEXUS_MODULES: readonly ApiModuleInfo<NexusModule>[];
@@ -103,9 +103,9 @@ export class ApiModules {
103
103
  }
104
104
  /** Endpoints typed to this app's modules: `MODULES.endpoint.get(HrModule.Staff, "Employee/Birthdays")`. */
105
105
  endpoint = apiEndpoint;
106
- /** A controller's standard endpoints, typed to this app's modules. See `apiController`. */
107
- controller(module, controller, subject) {
108
- return apiController(module, controller, subject);
106
+ /** A controller's standard endpoints, typed to this app's modules: its generation and what it lacks in `options`. See `apiController`. */
107
+ controller(module, controller, subject, options) {
108
+ return apiController(module, controller, subject, options);
109
109
  }
110
110
  }
111
111
  /** The Nexus modules, their names in proxy URLs, and the environment variables with their roots (as in the previous project). */