nexus-shared 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +220 -14
  3. package/dist/Client.Index.d.ts +13 -0
  4. package/dist/Client.Index.js +13 -0
  5. package/dist/Components/Chats/ChatComposer.d.ts +7 -1
  6. package/dist/Components/Chats/ChatComposer.js +9 -6
  7. package/dist/Components/Chats/ChatParts.d.ts +15 -2
  8. package/dist/Components/Chats/ChatParts.js +15 -4
  9. package/dist/Components/Chats/ChatPopup.d.ts +46 -0
  10. package/dist/Components/Chats/ChatPopup.js +34 -0
  11. package/dist/Components/Chats/ChatThread.d.ts +9 -1
  12. package/dist/Components/Chats/ChatThread.js +54 -6
  13. package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
  14. package/dist/Components/Dashboards/DashboardBoard.js +594 -0
  15. package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
  16. package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
  17. package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
  18. package/dist/Components/Dashboards/WidgetFrame.js +45 -0
  19. package/dist/Components/Documents/Button.d.ts +6 -1
  20. package/dist/Components/Documents/Button.js +2 -2
  21. package/dist/Components/Documents/TabButtons.d.ts +6 -0
  22. package/dist/Components/Documents/TabButtons.js +5 -4
  23. package/dist/Components/Domains/DomainCard.d.ts +98 -0
  24. package/dist/Components/Domains/DomainCard.js +182 -0
  25. package/dist/Components/Forms/ApiForm.js +5 -1
  26. package/dist/Components/Forms/Crud.js +72 -24
  27. package/dist/Components/Forms/CrudActions.d.ts +5 -4
  28. package/dist/Components/Forms/CrudActions.js +42 -12
  29. package/dist/Components/Forms/CrudHistory.d.ts +6 -1
  30. package/dist/Components/Forms/CrudHistory.js +14 -6
  31. package/dist/Components/Forms/CrudParts.d.ts +11 -4
  32. package/dist/Components/Forms/CrudParts.js +76 -10
  33. package/dist/Components/Forms/CrudRowForms.js +65 -5
  34. package/dist/Components/Forms/CrudTree.js +7 -7
  35. package/dist/Components/Forms/CrudWorkspace.d.ts +15 -5
  36. package/dist/Components/Forms/CrudWorkspace.js +19 -11
  37. package/dist/Components/Forms/Form.d.ts +3 -2
  38. package/dist/Components/Forms/Form.js +33 -26
  39. package/dist/Components/Forms/FormQuestion.d.ts +6 -0
  40. package/dist/Components/Forms/FormQuestion.js +28 -0
  41. package/dist/Components/Forms/SubmitForm.js +2 -15
  42. package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
  43. package/dist/Components/Inputs/ComputedInput.js +179 -0
  44. package/dist/Components/Inputs/Dropdown.js +4 -2
  45. package/dist/Components/Inputs/GroupForm.js +3 -2
  46. package/dist/Components/Inputs/InputRenderer.d.ts +5 -1
  47. package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
  48. package/dist/Components/Inputs/MarkdownEditor.js +32 -6
  49. package/dist/Components/Inputs/PictureInput.js +8 -3
  50. package/dist/Components/Inputs/RowsInput.d.ts +14 -1
  51. package/dist/Components/Inputs/RowsInput.js +78 -5
  52. package/dist/Components/Inputs/TabularForm.js +16 -3
  53. package/dist/Components/Inputs/TreeView.d.ts +24 -1
  54. package/dist/Components/Inputs/TreeView.js +84 -7
  55. package/dist/Components/Layouts/Popup.d.ts +22 -2
  56. package/dist/Components/Layouts/Popup.js +108 -6
  57. package/dist/Components/Messages/MailClient.d.ts +50 -0
  58. package/dist/Components/Messages/MailClient.js +410 -0
  59. package/dist/Components/Messages/MailColumns.d.ts +21 -0
  60. package/dist/Components/Messages/MailColumns.js +92 -0
  61. package/dist/Components/Messages/MailCompose.d.ts +46 -0
  62. package/dist/Components/Messages/MailCompose.js +154 -0
  63. package/dist/Components/Messages/MailReader.d.ts +43 -0
  64. package/dist/Components/Messages/MailReader.js +207 -0
  65. package/dist/Components/Messages/MailThread.d.ts +71 -0
  66. package/dist/Components/Messages/MailThread.js +185 -0
  67. package/dist/Components/Messages/MessageBell.d.ts +41 -0
  68. package/dist/Components/Messages/MessageBell.js +76 -0
  69. package/dist/Components/Viewers/DataTable.js +7 -5
  70. package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
  71. package/dist/Components/Viewers/DataTableColumns.js +2 -1
  72. package/dist/Components/Viewers/DataTableParts.d.ts +3 -1
  73. package/dist/Components/Viewers/DataTableParts.js +2 -2
  74. package/dist/Components/Viewers/OwnerFiles.d.ts +57 -5
  75. package/dist/Components/Viewers/OwnerFiles.js +142 -31
  76. package/dist/Helpers/ApiModules.js +4 -0
  77. package/dist/Helpers/AuditEntries.d.ts +50 -0
  78. package/dist/Helpers/AuditEntries.js +96 -0
  79. package/dist/Helpers/ChatBackend.d.ts +8 -1
  80. package/dist/Helpers/ChatBackend.js +18 -0
  81. package/dist/Helpers/ChatHelpers.d.ts +2 -0
  82. package/dist/Helpers/ChatHelpers.js +4 -1
  83. package/dist/Helpers/CrudColumns.d.ts +44 -6
  84. package/dist/Helpers/CrudColumns.js +63 -13
  85. package/dist/Helpers/CrudHelpers.d.ts +36 -1
  86. package/dist/Helpers/CrudHelpers.js +61 -0
  87. package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
  88. package/dist/Helpers/CrudPickerPlan.js +46 -0
  89. package/dist/Helpers/CrudRowForms.d.ts +18 -1
  90. package/dist/Helpers/CrudRowForms.js +35 -2
  91. package/dist/Helpers/DashboardHelpers.d.ts +115 -0
  92. package/dist/Helpers/DashboardHelpers.js +305 -0
  93. package/dist/Helpers/DomainCards.d.ts +140 -0
  94. package/dist/Helpers/DomainCards.js +370 -0
  95. package/dist/Helpers/FileAdapter.d.ts +82 -2
  96. package/dist/Helpers/FileAdapter.js +72 -1
  97. package/dist/Helpers/FormStore.d.ts +11 -1
  98. package/dist/Helpers/FormStore.js +66 -19
  99. package/dist/Helpers/InputConditions.d.ts +6 -0
  100. package/dist/Helpers/InputConditions.js +24 -0
  101. package/dist/Helpers/InputMemory.js +40 -30
  102. package/dist/Helpers/InputNarrowing.d.ts +38 -6
  103. package/dist/Helpers/InputNarrowing.js +102 -21
  104. package/dist/Helpers/MailClientHelpers.d.ts +96 -0
  105. package/dist/Helpers/MailClientHelpers.js +211 -0
  106. package/dist/Helpers/MessageBackend.d.ts +152 -0
  107. package/dist/Helpers/MessageBackend.js +329 -0
  108. package/dist/Helpers/MessageHelpers.d.ts +160 -0
  109. package/dist/Helpers/MessageHelpers.js +573 -0
  110. package/dist/Helpers/MessageUnread.d.ts +95 -0
  111. package/dist/Helpers/MessageUnread.js +245 -0
  112. package/dist/Helpers/NexusDriveAdapter.d.ts +17 -1
  113. package/dist/Helpers/NexusDriveAdapter.js +32 -3
  114. package/dist/Helpers/NumberHelpers.d.ts +61 -0
  115. package/dist/Helpers/NumberHelpers.js +124 -8
  116. package/dist/Helpers/PagedPicker.d.ts +42 -0
  117. package/dist/Helpers/PagedPicker.js +85 -0
  118. package/dist/Helpers/RowsStore.d.ts +24 -0
  119. package/dist/Helpers/RowsStore.js +79 -10
  120. package/dist/Helpers/TableHelpers.d.ts +15 -2
  121. package/dist/Helpers/TableHelpers.js +28 -1
  122. package/dist/Helpers/TableStore.d.ts +7 -3
  123. package/dist/Helpers/TableStore.js +9 -4
  124. package/dist/Helpers/ToolbarFit.d.ts +23 -0
  125. package/dist/Helpers/ToolbarFit.js +30 -0
  126. package/dist/Helpers/TreeStore.d.ts +75 -6
  127. package/dist/Helpers/TreeStore.js +182 -18
  128. package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
  129. package/dist/Interfaces/ApiInterfaces.js +10 -1
  130. package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
  131. package/dist/Interfaces/CrudInterfaces.d.ts +78 -5
  132. package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
  133. package/dist/Interfaces/DashboardInterfaces.js +1 -0
  134. package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
  135. package/dist/Interfaces/DomainInterfaces.js +1 -0
  136. package/dist/Interfaces/FileInterfaces.d.ts +21 -0
  137. package/dist/Interfaces/FileInterfaces.js +2 -0
  138. package/dist/Interfaces/FormInterfaces.d.ts +78 -11
  139. package/dist/Interfaces/InputInterfaces.d.ts +12 -5
  140. package/dist/Interfaces/MailInterfaces.d.ts +192 -0
  141. package/dist/Interfaces/MailInterfaces.js +1 -0
  142. package/dist/Interfaces/TableInterfaces.d.ts +28 -2
  143. package/dist/Services/JoinSources.d.ts +47 -6
  144. package/dist/Services/JoinSources.js +68 -33
  145. package/dist/Services/MessageApi.d.ts +18 -0
  146. package/dist/Services/MessageApi.js +64 -0
  147. package/dist/Shared.Index.d.ts +12 -0
  148. package/dist/Shared.Index.js +12 -0
  149. package/package.json +7 -1
  150. package/src/Styles/Nexus.Button.css +29 -0
  151. package/src/Styles/Nexus.Chat.css +114 -22
  152. package/src/Styles/Nexus.Crud.css +119 -28
  153. package/src/Styles/Nexus.Dashboard.css +763 -0
  154. package/src/Styles/Nexus.Domain.css +384 -0
  155. package/src/Styles/Nexus.Editor.css +20 -0
  156. package/src/Styles/Nexus.Files.css +27 -0
  157. package/src/Styles/Nexus.Index.css +3 -0
  158. package/src/Styles/Nexus.Input.css +31 -0
  159. package/src/Styles/Nexus.Mail.css +1062 -0
  160. package/src/Styles/Nexus.Popup.css +58 -0
  161. package/src/Styles/Nexus.Table.css +1458 -1449
  162. package/src/Styles/Nexus.Theme.Picker.css +1 -0
  163. package/src/Styles/Nexus.Tree.css +10 -0
package/README.md CHANGED
@@ -196,6 +196,13 @@ on this device, and reaches other open tabs:
196
196
  | `CrudTree` | `nexus-shared/client` | A controller's records as a lazy tree, with the `Crud`'s own Add and Edit forms and its trash, recover and delete calls: icons on each row, a toolbar Add New, and the picked record's fields and buttons for a panel the page draws; every change written into the tree and its kept pages, nothing read again |
197
197
  | `CrudTabFooter` | `nexus-shared/client` | The footer of a tab in a record's workspace: its words and Save in one bar that stays at the bottom of the tab while a long form scrolls, and sits in its place at the end; a plain bar elsewhere |
198
198
  | `RecordCards` | `nexus-shared/client` | One record that is not a list (a profile, my company) as a hero and cards of fields, of items with icon actions on the right, and of the page's own; every change in a popup (a form, steps such as a code sent then typed, a confirm), its answer drawn at once, and no Save on the page; loading held, a failed read per card with Try again, read-only, an ended session |
199
+ | `MailThread`, `MailPeople`, `mailInboxColumns`, `mailRowUnread` | `nexus-shared/client` | A mail thread: its subject and people (From, To, Cc, Bcc as the backend shows them, folded to three names and "+2"), the record it is about, and the chat's own lines and reply box; a notification as an event line; the note a blind copy reads before replying; an inbox's columns and its strong unread row ([Mail thread](#mail-thread)) |
200
+ | `MessageSource`, `apiMessageSource`, `localMessageSource`, `MessageBackend`, `NexusMessageBackend`, `defineMessageBackend` | `nexus-shared` (`apiMessageSource`: `/client`) | Where a mailbox's data comes from: the app's API through its backend (the Nexus Message module's `Mail` controller by default), or a mailbox in memory that keeps a backend's rules, for demos and tests |
201
+ | `DomainCard`, `DomainCardFrame`, `DomainLine`, `useDomainCard` | `nexus-shared/client` | A record a message is about, as a card read live from its module: the kind an app gave a `card` on its `defineChatDomains` row (`domainCard({ load, view })`), drawn from params (title, code, state, fields, figures, lines, link) in every state - a placeholder that keeps its height, refused for 403 and 404, failed with Try again, read again when the record changes ([A record's card in a message](#a-records-card-in-a-message)) |
202
+ | `MessageBell`, `useMessageUnread` | `nexus-shared/client` | The unread count on a top bar's button, one size whatever the count ("99+"), and the newest unread a click away; every bell of a page on one mailbox shares one read a minute, none while the tab is hidden ([Message bell](#message-bell)) |
203
+ | `MessageUnreadPoller`, `messageUnreadPoller`, `refreshMessageUnread`, `mailUnreadWords` | `nexus-shared` | The page's one poller per mailbox, outside React, and the words a bell says |
204
+ | `domainCardReads`, `refreshDomainCards`, `DomainCardRun`, `formatDays` | `nexus-shared` | One read of a record at a time, kept 30 seconds per reader; every card of a record read again when the page changed it; a card's read outside React; days written "1 day", "2.5 days" |
205
+ | `pagedPicker`, `holdPickerItems`, `mailPeoplePicker`, `mailComposeDefaults` | `nexus-shared` | A dropdown over a server's search, paged, that names every value it holds (seen, handed over, looked up, or found in the first pages): people to send to; a new message's defaults for a CRUD's `form.createDefaults` |
199
206
  | `Permissions`, `NexusPermissions`, `definePermissions`, `canCall` | `nexus-shared` | May the user call this endpoint? The app registers its rules once; `NexusPermissions` reads the Nexus backend's access actions. Everything is allowed until then |
200
207
  | `CrudBackend`, `NexusCrudBackend`, `defineCrudBackend` | `nexus-shared` | How a CRUD talks to a backend beyond its endpoints: page requests and answers, mass action bodies, flag and pin marks. Nexus's by default |
201
208
  | `configureCrud`, `getCrudSettings` | `nexus-shared` | What every CRUD in the app follows: how many records a user may pin (100 by default) |
@@ -374,8 +381,9 @@ definePopupRules({
374
381
  backdrop, or code asked; the page decides by setting `open`. `closable`, `closeOnEscape`, `closeOnBackdrop` turn
375
382
  those off for a popup that must be answered.
376
383
  - `size`: `xs` 360, `sm` 440, `md` 560, `lg` 720, `xl` 960, `full`. The body scrolls while the title and footer
377
- stay; `flush` removes the body padding for tables. `initialFocus` picks the first focused element (default: the
378
- first control in the body or footer). The content mounts only while open, so forms start fresh.
384
+ stay; `flush` removes the body padding for tables. `initialFocus` picks the first focused element (default: an
385
+ element of the body or footer marked `data-nx-focus` - a mail thread marks its subject, so a long read opens at its
386
+ top - else the first control there). The content mounts only while open, so forms start fresh.
379
387
  - Popups open other popups and message boxes above themselves; Esc closes the top one.
380
388
  - A **title bar** like a desktop window's: 32px high (40px on touch screens), the icon, the title, and the description
381
389
  on one line (cut with …), and the window buttons filling its top right corner, 46 x 32 each: **minimize** (only when it asks),
@@ -450,6 +458,9 @@ const choice = await messageBox.show({ title: "Unsaved changes", tone: "warning"
450
458
  scrolls with the body while the buttons stay: `details: <CrudDetails row={order} columns={ORDER_COLUMNS} />` with a
451
459
  `size` of `lg` or `xl`. `closeOnBackdrop: false` keeps a box with much to read open through a stray click (Esc and ×
452
460
  still dismiss it, and the focus stays where it was); `signal` dismisses it when the page or form that asked goes.
461
+ - The message and the details describe the box to a screen reader (`aria-describedby`), so they are read with the
462
+ title as the focus enters. A held Enter, or one within 300 ms of the Enter before it (the one that opened the box,
463
+ pressed twice), never answers a box; the next deliberate Enter does.
453
464
 
454
465
  ### Button hover tones
455
466
 
@@ -776,6 +787,9 @@ import { MarkdownEditor } from "nexus-shared/client";
776
787
  (Ctrl+Shift+.), code (Ctrl+E), code block, link (Ctrl+K), image, bulleted, numbered, and task lists (Ctrl+Shift+8,
777
788
  7, L), table, and horizontal line. Marks wrap the selection or the word at the caret and come off when applied
778
789
  again. Edits go through the browser's own editing, so Ctrl+Z undoes them. `toolbar` picks and orders the buttons.
790
+ Under 560 px they take a row of their own; `toolbarOverflow="menu"` keeps one row at any width instead, the buttons
791
+ that do not fit in a "More formatting" menu at its end (`fitToolbar` works out which) - a chat's reply box does, so a
792
+ phone keeps its room for the lines.
779
793
  - **While typing:** Enter continues a list (the next number, an empty task box) and ends it on an empty item; a URL
780
794
  pasted over selected text links it; a task ticked in the preview is ticked in the text.
781
795
  - **Height handle:** the status bar under the editor drags the height between `minHeight` and `maxHeight`, showing
@@ -959,7 +973,10 @@ const ROOM_INPUTS: InputParams[] = [
959
973
  change; a form reset after a save opens on the new choice. No `viewer`, nothing kept.
960
974
  `{ type: EInputTypes.RadioGroup, name: "paidBy", ..., remember: { key: "counter-paid-by", viewer: `${userId}|${companyId}`, preferred: settings.defaultPaymentMode } }`.
961
975
  - **A picker** (`...crudPicker(...)`, options from `loadOptions`) confirms a kept value by its key before it opens -
962
- found, not inactive, inside its `narrowedBy` narrowing - with its spinner meanwhile; never over the person's own pick.
976
+ found, not inactive, inside its `narrowedBy` narrowing - with its spinner meanwhile (the box `aria-busy`, described
977
+ "Loading"; a person in the box keeps the focus when the value opens); never over the person's own pick. A picker
978
+ that cannot name a record by its key is looked through page by page in its own `pageSize`: a value not met in 20
979
+ pages, on a list that goes on, is kept for later but not opened - only the list's end makes it gone.
963
980
  - **`per: "subscriptionGroupId"`** keeps one value per value of that input (`nx-input:{key}-{group}:{viewer}`): a
964
981
  package per group. **`when: { name: "id", filled: false }`** remembers on a CRUD's Add only (Edit holds the key).
965
982
  - **In rows** (a tabular or group form): a new row's input opens on the kept value (give its first choice as the
@@ -968,8 +985,9 @@ const ROOM_INPUTS: InputParams[] = [
968
985
  checks, a question with what will be saved in full - `{ title: "Place this order?", message, details: (values, body) =>
969
986
  ({ row: body, columns, currency }), confirmLabel: "Place order", size: "xl", when }`. `body` is what is sent: an
970
987
  ApiForm's `transform` runs once, before the question, and that very body is sent after Confirm. The details are drawn
971
- as a record's (`CrudDetails`), a section under a heading for each run of columns with one `group`. Confirm has the
972
- focus, so Enter saves; Back to the form, Esc and × send nothing and leave the form as it was, the focus back where it
988
+ as a record's (`CrudDetails`), a section under a heading for each run of columns with one `group`, and are read out
989
+ with the title. Confirm has the focus, so a deliberate Enter saves (the Enter that asked, held or pressed twice,
990
+ does not); Back to the form, Esc and × send nothing and leave the form as it was, the focus back where it
973
991
  was; a click outside leaves it open. While it asks nothing else submits and nothing says "Saving…"; a throw while it
974
992
  is prepared shows under the form and asks nothing. `when` limits it to some saves.
975
993
  - **One save at a time.** Save waits with a spinner; another click, Enter, or Ctrl+S does nothing until it ends. A save
@@ -1054,6 +1072,8 @@ export const { GET, HEAD, POST, PUT, PATCH, DELETE } = createApiProxy({ token: r
1054
1072
  that disagrees with the backend by one bit grants or denies the wrong module in silence. They changed after
1055
1073
  2.0.0: `Chat` took bit 6 and `Accounting` through `Portfolio` each moved up one. Code that uses the names needs
1056
1074
  nothing; anything holding a stored or hard-coded **number** does. [CHANGELOG.md](CHANGELOG.md) has the table.
1075
+ `Inventory` (`API_INVENTORY`) was added at bit 17 in 4.0.0, after `Portfolio`, and moved nothing: a new module takes
1076
+ the next bit at the end.
1057
1077
  - **Module types from one place.** An app names its enum once, next to `defineApiModules`:
1058
1078
  `declare module "nexus-shared" { interface ApiRegister { module: HrModule } }`. Every endpoint, call, `ApiForm`,
1059
1079
  and CRUD then takes only those modules (`AppModule`), so `{ module: NexusModule.Reservation, … }` in an HR app is a
@@ -1176,7 +1196,8 @@ table.upsert(saved); table.patch(7, { paid: true }, { flash: true }); table.remo
1176
1196
  `value`, `render`, `text`, `width`, `minWidth`, `align`, `wrap`, `sortable`, `sortKey`, `searchable`, `hidden`,
1177
1197
  `hideable`, `movable`, `resizable`, `pinnable`, `pinned`. Numbers line up at the end in tabular figures; dates and
1178
1198
  times follow the user's settings; moments show in the user's zone once the page has hydrated; long text ends in … at
1179
- the column's width.
1199
+ the column's width. `table` (sub-columns) marks a list of rows: a record's details draw it as a small table across the line
1200
+ (numbered rows, an option's tone as its tag, a yes or no in words, "None" for an empty list), the table its count.
1180
1201
  - **Money in the company's currency.** `<DataTable currency="Rs." />` (a `Crud`'s `table.currency`) writes every
1181
1202
  `money` column "Rs. 1,250.00" - in the cells, the details, the CSV and the print - with no `render` of the page's; a
1182
1203
  column's own `currency` (a text, or `row => row.currencyText`) wins. `formatMoney(value, { currency, locale })` writes
@@ -1300,7 +1321,9 @@ export const GUESTS = MODULES.controller(NexusModule.Reservation, "Guest", "gues
1300
1321
  pins, and saved values change in place; a new record goes first; a server page loads again in the background to fill up. When a mass
1301
1322
  answer lists the records it changed, only those change.
1302
1323
  - **A save is never read back** (Nexus: an Add is one Create, an Edit one Update). The row is what was sent under what the
1303
- answer said, with the fields only the server keeps written by `CrudBackend.savedRow` (Nexus: Active, no marks, added
1324
+ answer said (`crudSavedRecord`): the body `transform` made over the form's values, so a field sent as null is empty on
1325
+ the row; and a value `showWhen` hid at the save shows only as the answer (a record) or else the body gives it, never
1326
+ the hidden box's own copy. The fields only the server keeps are written by `CrudBackend.savedRow` (Nexus: Active, no marks, added
1304
1327
  now; an edit keeps them and was changed now), and the names the database joins written again by `crudJoinedNames` for
1305
1328
  each `crudColumns.dbJoin` column given `from` (the key's field): from the record the user picked in the form, one the
1306
1329
  page holds (`peekOption`), or the name the row had when the key did not change. An edit takes the status, the marks
@@ -1315,8 +1338,13 @@ export const GUESTS = MODULES.controller(NexusModule.Reservation, "Guest", "gues
1315
1338
  button, Esc, the dock) asks "Discard your changes?". Edit opens with the row, the `details` endpoint's record
1316
1339
  (`form.load: "details"`, for fields a list leaves out), or a loader of your own.
1317
1340
  - **Details** (View, or a double-click) shows every column, hidden ones too, and the full record once `details` answers,
1318
- with Edit in its footer. Columns that name a `group` (a table's band) are drawn in sections, a small muted heading
1319
- over each run of them (`CrudDetails`, `crudDetailsRuns`); with no group anywhere the list is one grid, as before.
1341
+ with Edit in its footer. Each field is a label and its value in a box, two pairs to a line where the details are
1342
+ wide and one where they are not. Columns that name a `group` (a table's band) are drawn in sections under a numbered
1343
+ heading, "01. Contact", "02. Account" (`CrudDetails`, `crudDetailsRuns`); with no group anywhere the list is one
1344
+ grid. In a workspace (below) this footer is the Details tab's alone: every other tab runs to the popup's foot. A
1345
+ section may change on its own: `details.sections: [{ group: "KYC details", edit: "edit" }, { group: "Contacts", edit:
1346
+ "contacts" }]` (the Edit form, or a `rowForms` id) puts an Edit at the end of its heading; once saved the record is
1347
+ read again and the focus comes back to the button.
1320
1348
  - **A record's workspace** (`details.profile`, `details.tabs`, `details.remember`): View, and a click on a row under
1321
1349
  `table.openOnClick`, open the record as a wide popup (`full` unless `size` says otherwise) - who it is and its tabs in
1322
1350
  a 20rem column on the left, the chosen tab on the right, scrolling inside itself. A page passes it in a few lines:
@@ -1345,8 +1373,9 @@ export const GUESTS = MODULES.controller(NexusModule.Reservation, "Guest", "gues
1345
1373
  the chosen tab is drawn, and a switch never changes the popup's size.
1346
1374
  - `profile.fields` puts a few of the record's fields under the tags, a label and a value each, drawn as the table
1347
1375
  draws them (the person's date format, an option's tone, the column's `tag` after the value). A field is a column
1348
- given whole or a key of the details' `columns`; one with no value for the record is left out. The Details tab leaves
1349
- out every key named there, so no field shows twice. Beside the tabs they are a small grid in the 20rem column; on a
1376
+ given whole or a key of the details' `columns`; one with no value for the record is left out. A field the list's
1377
+ row does not carry keeps its line while the record is read (held) and says "Not loaded" if that read fails; it is
1378
+ left out only once read and empty. The Details tab leaves out every key named there, so no field shows twice. Beside the tabs they are a small grid in the 20rem column; on a
1350
1379
  phone they follow the tags as one wrapped line.
1351
1380
 
1352
1381
  ```tsx
@@ -1409,6 +1438,12 @@ export const GUESTS = MODULES.controller(NexusModule.Reservation, "Guest", "gues
1409
1438
  once and searched in the browser, and a saved value it does not offer looked up by key through `findListing`. A
1410
1439
  `valueKey` other than `id` - a person's account rather than their row's key - is looked up through `find`, an endpoint
1411
1440
  of its own that takes those values. Without it such a value is never looked up, since `findListing` takes keys.
1441
+ **`list`** reads another listing in place of `activeListing` - a controller with none, such as a pay run's CRUD
1442
+ endpoints (`crudPicker(PAY_RUNS, { list: PAY_RUNS.lists.normal, ... })`; with no `findListing`, nothing is looked
1443
+ up) - **`usable`** says which records count as usable in place of the backend's rule (under 30): `run =>
1444
+ run.recordStatus < NEXUS_TRASH_STATUS` offers a draft run (31); and **`option`** makes each record's option, keeping its
1445
+ fields (`run => ({ ...run, label: run.monthName + " · " + run.runCode, state: STATES[run.payRunState] })`). A CRUD's
1446
+ change on the controller marks the list old, as it does the active one.
1412
1447
  - **A record nobody may change here** (`rowLock={row => row.isLocked ? row.lockReason : null}`): its Edit, Flag, Move to
1413
1448
  trash, Recover and the deletes are listed switched off with that sentence under each, its details have no Edit, the
1414
1449
  handle's `edit` opens its details, and a selection holding it has those mass actions switched off. View, History, Pin
@@ -1456,8 +1491,18 @@ export const GUESTS = MODULES.controller(NexusModule.Reservation, "Guest", "gues
1456
1491
  record. `writes` names the answer's fields that change the record (or is a function of the answer): only those, merged
1457
1492
  over the record as the CRUD holds it when the answer lands (an Edit made while the popup was minimized stays), written
1458
1493
  into the table, every list kept and the pinned sections with no list read again. The handle's `openRowForm(id, row)`
1459
- opens one.
1460
- - **Options narrowed by another input** (`narrowedBy: { name: "subscriptionGroupId" }` on an input, Form only): a
1494
+ opens one. **`confirm`** asks before the save, as a form's `confirm` does - the details in full, Confirm focused, read
1495
+ out, a hasty Enter held - its params or a function of the record and what was read (`confirm: run => ({ title:
1496
+ "Lock " + run.runCode + "?", details: () => ({ row: run, columns: TOTALS }) })`). **A row form with no `inputs` is its
1497
+ question alone** (a pay run's Lock): no popup, the question (`confirm`, else "{label} {name}?", buttons the action's
1498
+ word and "Cancel"), then the record as the list has it - or what `load` read - sent through `transform`; the API's
1499
+ toasts say it was done or why not, and a refusal writes nothing.
1500
+ - **A record's history** (`api.audit`, so every row offers History; or `AuditHistory`/`openAuditHistory` on a page):
1501
+ each entry says what was done, when and by whom - "by Sita Rai" when the answer carries `creatorName`, else the start
1502
+ of the person's token ("User 3fA9b2…"), which still tells two people apart - and the columns it changed. An app whose
1503
+ backend sends no names sets `setAuditActorNames(async (tokens, signal) => ({ [token]: "Sita Rai (sita.rai)" }))` once:
1504
+ the history asks it once per page shown, for the tokens it has not had an answer for, and keeps the answers.
1505
+ - **Options narrowed by another input** (`narrowedBy: { name: "subscriptionGroupId" }` on an input, in a Form and its rows): a
1461
1506
  Package list only of the Group picked. Static `options` are filtered by the records' `key` field (default the other
1462
1507
  input's name); `loadOptions` is asked with `request.narrow`, which `crudPicker` answers from its kept list, or from
1463
1508
  the server with `crudPicker(PACKAGES, { labelKey, narrowQuery: "subscriptionGroupId" })`. While the other input is
@@ -1465,6 +1510,12 @@ export const GUESTS = MODULES.controller(NexusModule.Reservation, "Guest", "gues
1465
1510
  **Or by a fixed value** the page knows when the form opens (`narrowedBy: { key: "subscriptionGroupId", value:
1466
1511
  subscriber.subscriptionGroupId }`): nothing is followed or cleared, and a `null` value offers nothing. `orUnset: true`
1467
1512
  (both forms) also offers the options whose field is empty: the Fee list of the group, and the fees of every group.
1513
+ **Several at once, and in rows**: `narrowedBy: [{ name: "storeId", from: "form" }, { name: "itemId" }]` on a line
1514
+ table's Tags offers the units of the line's Item in the form's Store - the first empty one asked for, a loader told
1515
+ every value (`request.narrows`), and `crudPicker({}, { list: UNITS, valueKey: "tagNumber", labelKey: "tagNumber" })`
1516
+ over `StockBalance/Units/{storeId}/{itemId}` reads each pair once, the values in its path. A person's new Item clears
1517
+ that line's tags, a new Store every line's; a saved document loading keeps them. `dependsOn: ["quantity"]` on a row
1518
+ input redraws its row as the quantity changes, so `cellParams` can cap the picks (`{ max: row.quantity }`).
1468
1519
  - **Cache.** Lists are kept for 5 minutes while the page is open (`cache={{ lifetime }}`, or `false`): going back to a
1469
1520
  list paints it at once. After a change, the list in view keeps its rows and the others load again when shown; Reload
1470
1521
  and the handle's `reload()` go past the cache. The cache goes when the page does.
@@ -1596,7 +1647,7 @@ owner's `module`, the `category`, the `ownerId`, and for a picture its `aspect`,
1596
1647
  |---|---|---|
1597
1648
  | `EInputTypes.Picture` (`PictureInput`) | `nexus-shared` / `/client` | In a form: the owner's main picture, Upload, Edit, Remove, All pictures (a click on the picture opens them too); in a category that keeps one file, Replace and Delete. Changed at once: the form keeps no field for it (`holdsValue`), so a save sends nothing under its name, and it takes no `required`. `ownerId` null: "Save the record first". |
1598
1649
  | `PictureGallery`, `openPictureGallery` | `nexus-shared/client` | An owner's pictures in two panes: the main picture large with Upload and Remove and the count, then a grid of rounded squares, each with View, Make main, Edit and Delete inside it (on hover, on a keyboard's focus, always by touch; neither Make main nor Delete on the main one). Upload several, up to the category's count; edits are saved as new pictures. Under 560px the main picture sits small over the grid |
1599
- | `DocumentList` | `nexus-shared/client` | The same as a list, for a category that takes PDF (the gallery picks it itself): kind, size, date, open, rename, delete |
1650
+ | `DocumentList` | `nexus-shared/client` | The same as a list, for a category that takes PDF (the gallery picks it itself): kind, size, date, open, rename, delete; `changes` for a record whose files are only added to |
1600
1651
  | `pictureColumn` | `nexus-shared/client` | A table column with each row's picture: the page's rows in one call (`primaryPictures`), a click opens the row's gallery |
1601
1652
  | `OwnerPicture`, `usePrimaryPicture`, `useOwnerFiles` | `nexus-shared/client` | The small picture, the kept main picture, and the files with every change, for a page's own layout |
1602
1653
  | `loadImageEditor`, `preloadImageEditor` | `nexus-shared/client` | The image editor, downloaded from the file backend the first time it is needed; never in the bundle |
@@ -1642,6 +1693,12 @@ columns: [pictureColumn({ ...LOGO, rowTitle: row => row.companyName }), ...];
1642
1693
  - **Read only, and why.** `lockReason: "The branch is closed."` makes the files read only with that sentence under
1643
1694
  them; a person the backend lets only look is told the backend's words (`OwnerFiles.readOnlyReason`), and the files
1644
1695
  of an owner not in use (trashed, ended) say so (`OWNER_NOT_IN_USE_REASON`).
1696
+ - **Only some changes.** `changes: receipt.isPosted ? ["add"] : undefined` with `changesReason: "Posted: a document can
1697
+ be added; none is removed."` keeps Upload and offers no Rename, Delete, Make main or Remove (nor a Replace or an Edit
1698
+ that deletes or marks) for a record whose files may only be added to; the reason is said under the files. The changes
1699
+ are `FileChange` words, the core's `EFileChanges` one for one. A refusal that says so - the Nexus `API_X084`, read by
1700
+ `FileAdapter.changesLeft` - narrows them the same way for that owner under the server's words, so a page read before
1701
+ the record was posted stops offering what the server refuses. `ownerFileActions` is the rule, for a page's own buttons.
1645
1702
  - **Initials.** `pictureColumn({ ..., nameKey: "fullname" })` (or `OwnerPicture name`) shows an owner with no picture
1646
1703
  as the name's initials, on the shared `Avatar`'s tone for that name.
1647
1704
  - **The keyboard's focus stays.** While a change saves, the buttons stay focusable (`aria-disabled`, the press ignored:
@@ -1703,3 +1760,152 @@ follows, the hero too, with nothing read again).
1703
1760
  - **States** are the piece's: the cards held as they will be while they load, a failed read in each of its cards with
1704
1761
  Try again, `readOnly` with no button anywhere, and `signedOut` - an answer that ended the session closes the popups
1705
1762
  and leaves the page's words and its way back in.
1763
+
1764
+ ### Mail thread
1765
+
1766
+ Mail inside the app: a **thread** with a subject, a sender and the people it went to (To, Cc, Bcc), whose first line is
1767
+ the mail and the rest are replies, drawn by the chat's own thread and reply box. A thread can be about a record (a
1768
+ leave request), and an app can write into it when something happens - a **notification**, drawn as a quiet event line
1769
+ among the replies. Everything comes through one interface, `MessageSource`, so a page passes params and the package
1770
+ does the rest.
1771
+
1772
+ ```tsx
1773
+ const mail = apiMessageSource(); // the app's MessageBackend: Nexus's Message module, Mail controller, by default
1774
+ const people = mailPeoplePicker(mail); // To, Cc and Bcc boxes that page the server and name what they hold
1775
+
1776
+ <Crud<MailInboxRow>
1777
+ api={{ pages: { normal: INBOX }, create: MAIL_CREATE }}
1778
+ paging="server"
1779
+ actions={["create", "view"]}
1780
+ columns={mailInboxColumnList()}
1781
+ inputs={[
1782
+ { type: EInputTypes.Dropdown, name: "to", label: "To", multiple: true, required: true, ...people },
1783
+ { type: EInputTypes.Dropdown, name: "cc", label: "Cc", multiple: true, ...people },
1784
+ { type: EInputTypes.Dropdown, name: "bcc", label: "Bcc", multiple: true, ...people },
1785
+ { type: EInputTypes.Text, name: "subject", label: "Subject", required: true, maxLength: 100 },
1786
+ { type: EInputTypes.Markdown, name: "body", label: "Message", required: true, maxLength: 4000 },
1787
+ ]}
1788
+ form={{ newValues: () => ({ clientId: newMailClientId() }), createDefaults: mailComposeDefaults(mail, people) }}
1789
+ details={{ size: "xl", render: row => <MailThread source={mail} threadId={row.id} userId={me.id} /> }}
1790
+ table={{ rowStrong: mailRowUnread, openOnClick: true }}
1791
+ />
1792
+ ```
1793
+
1794
+ - **The head** is a band at the top of the thread, scrolling with its lines (`ChatThread` `lead`), never in a box of
1795
+ its own; the thread opens at its top. It holds the subject, the time, From, To and Cc, and Bcc **only as the source gives
1796
+ it** (the sender sees every person copied unseen; a person copied unseen sees their own row; nobody else sees any).
1797
+ Lists of people fold to three names and "+2", a button that opens the rest in place. A record the thread is about
1798
+ is drawn as its card ([below](#a-records-card-in-a-message)) when its kind has one, read again when a notification
1799
+ arrives, and folded to its title and figures while the thread is narrower than 480px (its real width, followed as
1800
+ it changes, until the reader folds or unfolds it); otherwise as its kind (named by `defineChatDomains`) and its words.
1801
+ `renderDomain` draws it instead.
1802
+ - **The lines** are a `ChatStore` on `source.threadSource(id)`: paging up, a reply on screen at once, Retry, read marks
1803
+ (reading to the bottom marks the thread read; `onSummary` hands the page the new counts), polling.
1804
+ `ChatMessage.kind` says what a line is: `chat`, `mail`, or `notification` (an event line with who acted, no bubble).
1805
+ - **A person copied unseen** reads "You were sent a blind copy. Replying shows you to everyone." over the reply box
1806
+ (`mailBccNote`), and the reply box is described by it; once they reply the head is read again and shows them as a Cc.
1807
+ - **When someone else writes**, the head is read again (the people may have changed) and `onChanged` tells the page;
1808
+ `reason: "notification"` means the record may have changed, and the record's slot is told too (`revision`).
1809
+ - **States**: the head held by bones while it loads, a failure with Try again in the head and in the thread, "This
1810
+ message is not available to you." for a thread the reader is not on (403 or 404), a closed thread that is read and not
1811
+ answered (`canSend: false`).
1812
+ - **`MessageBackend`** maps each action to an endpoint and reads each answer; `NexusMessageBackend` is the Message
1813
+ module's `Mail` controller (POST `Inbox`, `History`, `Create`, `Reply`, `People`; GET `Thread/{id}`, `Unread`,
1814
+ `Defaults`; PUT `Read`, `Archive`, `Unarchive`), reading a role (1 From, 2 To, 3 Cc, 4 Bcc) and a line's type (0 chat,
1815
+ 1 mail, 2 notification) as this package's words. Override `module` or any reader for another backend.
1816
+ - **`localMessageSource({ userId, people, threads })`** keeps a mailbox in memory with a backend's rules - who sees
1817
+ whom, a blind copy's reply, archive and unread per person, a retried send written once - for a demo or a test;
1818
+ `as(userId)` reads it as somebody else and `post` writes a module's notification.
1819
+ - **Three params any page can use**: `pagedPicker` (a dropdown over any server search, naming the values it holds),
1820
+ `Crud` `form.createDefaults` (an Add form's values read as it opens; a failed read opens it on `newValues`), and
1821
+ `DataTable` `rowStrong` (a row read strong while a field says so).
1822
+
1823
+ ### A record's card in a message
1824
+
1825
+ A message about a record - a leave request, a requisition - shows that record as a **card**, read live from the module
1826
+ that owns it each time it is drawn, never a copy kept in the message. The package knows no module and no record kind:
1827
+ an app gives each kind it names with `defineChatDomains` a `card` - how to read the record and what to show of it - and
1828
+ every thread about that kind draws it at its head. Every kind looks alike, because a card is params.
1829
+
1830
+ ```tsx
1831
+ defineChatDomains([
1832
+ {
1833
+ domain: EDomainTypes.LeaveRequest,
1834
+ key: "leave-request",
1835
+ label: "Leave request",
1836
+ card: domainCard({
1837
+ // The owning module's card door, which checks the reader. 403 and 404 read "You cannot open this leave request."
1838
+ load: (id, signal) => api.get<LeaveRequestCard>(REQUEST_CARD.leave, { params: { id }, signal, toast: false }).then(unwrapApiResult),
1839
+ // `view` is typed by what `load` answers.
1840
+ view: leave => ({
1841
+ title: `${leave.leaveTypeName} · ${formatDays(leave.days)}`,
1842
+ code: leave.requestCode,
1843
+ state: { label: "In approval", tone: "info" },
1844
+ fields: [
1845
+ { label: "From", value: leave.startDate, kind: "date" },
1846
+ { label: "To", value: leave.endDate, kind: "date", note: "half day" },
1847
+ ],
1848
+ figures: [
1849
+ { label: "Available", value: leave.balance.availableDays, kind: "days" },
1850
+ { label: "Held", value: leave.balance.heldDays, kind: "days", tone: "warning" },
1851
+ ],
1852
+ link: { href: "/admin/requisition/my-approvals", label: "Open My approvals" },
1853
+ }),
1854
+ held: { subtitle: true, fields: 2, figures: 2 }, // what its placeholder holds the first time
1855
+ }),
1856
+ },
1857
+ ]);
1858
+
1859
+ <DomainCard domain={EDomainTypes.LeaveRequest} id={token} label="LR2082-0007 · Puja Shrestha" viewer={me.id} />
1860
+ ```
1861
+
1862
+ - **The card** (`DomainCardParams`): the kind as a tag and the title; a quiet line under it with the code, a subtitle and
1863
+ the link at its end; the state as a tag in its tone; figures first (large values in a row, two to a row on a phone,
1864
+ a tone mark beside each, a hint under one keeping two lines); then its details: fields (a label over each value, as
1865
+ many to a row as fit) and lines (a short table, numbers and
1866
+ money at the end, the first column one line cut with "..."; "and 3 more"). Values are written by their kind with the
1867
+ reader's settings: `date`, `datetime` (their zone), `days` (`formatDays`: "1 day", "2.5 days"), `money` (the card's
1868
+ `currency`, `formatMoney`), `number`, `person`, `text`. A field with no value is left out unless it names `empty`.
1869
+ `extra(data)` adds one more piece of this package's parts after the lines. A band, never a box: a mail thread's head
1870
+ draws a line above it.
1871
+ The fold button folds the details and keeps the figures (folded, a leave's card is its title, code and balance);
1872
+ `collapsed` with `onCollapsedChange` lets a page hold the fold, `defaultCollapsed` starts it.
1873
+ - **Every state**: a placeholder of the card's own parts, in the shape of the kind's last card (or its `held`), so the
1874
+ record lands without moving what is under it, at any width; the card; "You cannot open this leave request." for 403
1875
+ and 404 alike (a stranger learns nothing from the difference); "Could not load this leave request." with Try again
1876
+ for anything else; the kind and the record's words alone for a kind with no card or a message with no id. `view`
1877
+ throwing on an answer is a failure too, never a broken thread.
1878
+ - **Read again**: when `revision` grows (a mail thread's notification), `ref.reload()`, or `refreshDomainCards(kind,
1879
+ id)` (the page changed the record itself). The card stays while it is read, says "Updated 2:45 PM" when the record
1880
+ turned out different, and "Not up to date" with Try again when the read failed; a refusal on a read again drops it.
1881
+ A screen reader is told each.
1882
+ - **Reads** (`DomainCardReads`, `domainCardReads`): one read of a record at a time however many cards draw it, an
1883
+ answer kept 30 seconds per reader (`lifetime`), a failure never kept, a read nobody waits for aborted, a read again
1884
+ taking only a read started after the change and forgetting what other readers kept. `useDomainCard` and
1885
+ `DomainCardRun` give a page the same read for a look of its own; `DomainCardFrame` draws a card from params a page
1886
+ already holds, with no read.
1887
+
1888
+ ### Message bell
1889
+
1890
+ How much internal mail waits, on a top bar's button: the count over its corner in the chat's badge (one size whatever
1891
+ the count, "99+" from 100 on) and the newest unread threads a click away, each a link into the inbox.
1892
+
1893
+ ```tsx
1894
+ const mail = apiMessageSource(); // one object for the page: every bell on it shares one poller
1895
+
1896
+ <AdminLayout
1897
+ utilities={[{ code: "notifications", label: "Messages", icon: "bell" }]}
1898
+ renderUtility={item => (item.code === "notifications" ? <MessageBell source={mail} href="/admin/message/inbox" label={item.label} /> : undefined)}
1899
+ />
1900
+ ```
1901
+
1902
+ - **One read however many bells.** `useMessageUnread(source)` joins the page's poller for that source: it reads when
1903
+ the first bell appears, then every minute while the page is in front, at once when the window takes the focus or the
1904
+ tab comes back, and never while the tab is hidden. A failure keeps the count read before. Call
1905
+ `refreshMessageUnread(source)` after the page reads or archives threads, and every bell on it reads at once - or
1906
+ right after the read on its way, which may have counted before the change (one more read, however many are asked).
1907
+ - **The menu**: the newest unread (subject; who wrote last, the record, when), a row opening `href?thread=<id>` (or
1908
+ `threadHref(row)`), "Open inbox", and Try again after a failure. Opening it reads the count again.
1909
+ - **A screen reader** hears the button's name ("Messages: 3 unread messages") and, when the count goes up, the new
1910
+ count once from one bell of the page - never on every read, never as the page loads.
1911
+ - **`Button` `badge`** is the piece underneath: a count over any button's corner that leaves its size alone.
@@ -3,15 +3,21 @@ export * from "./Components/Chats/ChatButton.tsx";
3
3
  export * from "./Components/Chats/ChatComposer.tsx";
4
4
  export * from "./Components/Chats/ChatConversations.tsx";
5
5
  export * from "./Components/Chats/ChatPanel.tsx";
6
+ export * from "./Components/Chats/ChatPopup.tsx";
6
7
  export * from "./Components/Chats/ChatParts.tsx";
7
8
  export * from "./Components/Chats/ChatThread.tsx";
9
+ export * from "./Components/Dashboards/DashboardBoard.tsx";
10
+ export * from "./Components/Dashboards/DashboardWidgets.tsx";
11
+ export * from "./Components/Dashboards/WidgetFrame.tsx";
8
12
  export * from "./Components/Documents/DocumentFrame.tsx";
13
+ export * from "./Components/Domains/DomainCard.tsx";
9
14
  export * from "./Components/Documents/Menu.tsx";
10
15
  export * from "./Components/Documents/SplitButton.tsx";
11
16
  export * from "./Components/Documents/TabButtons.tsx";
12
17
  export * from "./Components/Documents/Tabs.tsx";
13
18
  export * from "./Components/Forms/ApiForm.tsx";
14
19
  export * from "./Components/Forms/Crud.tsx";
20
+ export { openCrudForm, type CrudFormRequest } from "./Components/Forms/CrudActions.tsx";
15
21
  export * from "./Components/Forms/CrudHistory.tsx";
16
22
  export * from "./Components/Forms/CrudNarrowPicker.tsx";
17
23
  export * from "./Components/Forms/CrudParts.tsx";
@@ -52,6 +58,12 @@ export * from "./Components/Layouts/PopupRules.ts";
52
58
  export * from "./Components/Layouts/ThemePicker.tsx";
53
59
  export * from "./Components/Layouts/ThemeSwitcher.tsx";
54
60
  export * from "./Components/Layouts/Toaster.tsx";
61
+ export * from "./Components/Messages/MailClient.tsx";
62
+ export * from "./Components/Messages/MailColumns.tsx";
63
+ export * from "./Components/Messages/MailCompose.tsx";
64
+ export * from "./Components/Messages/MailReader.tsx";
65
+ export * from "./Components/Messages/MailThread.tsx";
66
+ export * from "./Components/Messages/MessageBell.tsx";
55
67
  export * from "./Components/Viewers/Avatar.tsx";
56
68
  export * from "./Components/Viewers/DataTable.tsx";
57
69
  export * from "./Components/Viewers/DataTableParts.tsx";
@@ -72,4 +84,5 @@ export * from "./Services/FileApi.ts";
72
84
  export * from "./Services/ImageEditorLoader.ts";
73
85
  export * from "./Services/ModuleImport.ts";
74
86
  export * from "./Services/JoinSources.ts";
87
+ export * from "./Services/MessageApi.ts";
75
88
  export * from "./Services/ThemeService.ts";
@@ -6,15 +6,21 @@ export * from "./Components/Chats/ChatButton.js";
6
6
  export * from "./Components/Chats/ChatComposer.js";
7
7
  export * from "./Components/Chats/ChatConversations.js";
8
8
  export * from "./Components/Chats/ChatPanel.js";
9
+ export * from "./Components/Chats/ChatPopup.js";
9
10
  export * from "./Components/Chats/ChatParts.js";
10
11
  export * from "./Components/Chats/ChatThread.js";
12
+ export * from "./Components/Dashboards/DashboardBoard.js";
13
+ export * from "./Components/Dashboards/DashboardWidgets.js";
14
+ export * from "./Components/Dashboards/WidgetFrame.js";
11
15
  export * from "./Components/Documents/DocumentFrame.js";
16
+ export * from "./Components/Domains/DomainCard.js";
12
17
  export * from "./Components/Documents/Menu.js";
13
18
  export * from "./Components/Documents/SplitButton.js";
14
19
  export * from "./Components/Documents/TabButtons.js";
15
20
  export * from "./Components/Documents/Tabs.js";
16
21
  export * from "./Components/Forms/ApiForm.js";
17
22
  export * from "./Components/Forms/Crud.js";
23
+ export { openCrudForm } from "./Components/Forms/CrudActions.js";
18
24
  export * from "./Components/Forms/CrudHistory.js";
19
25
  export * from "./Components/Forms/CrudNarrowPicker.js";
20
26
  export * from "./Components/Forms/CrudParts.js";
@@ -55,6 +61,12 @@ export * from "./Components/Layouts/PopupRules.js";
55
61
  export * from "./Components/Layouts/ThemePicker.js";
56
62
  export * from "./Components/Layouts/ThemeSwitcher.js";
57
63
  export * from "./Components/Layouts/Toaster.js";
64
+ export * from "./Components/Messages/MailClient.js";
65
+ export * from "./Components/Messages/MailColumns.js";
66
+ export * from "./Components/Messages/MailCompose.js";
67
+ export * from "./Components/Messages/MailReader.js";
68
+ export * from "./Components/Messages/MailThread.js";
69
+ export * from "./Components/Messages/MessageBell.js";
58
70
  export * from "./Components/Viewers/Avatar.js";
59
71
  export * from "./Components/Viewers/DataTable.js";
60
72
  export * from "./Components/Viewers/DataTableParts.js";
@@ -77,4 +89,5 @@ export * from "./Services/FileApi.js";
77
89
  export * from "./Services/ImageEditorLoader.js";
78
90
  export * from "./Services/ModuleImport.js";
79
91
  export * from "./Services/JoinSources.js";
92
+ export * from "./Services/MessageApi.js";
80
93
  export * from "./Services/ThemeService.js";
@@ -1,3 +1,4 @@
1
+ import { type ReactNode } from "react";
1
2
  import type { ChatSnapshot, ChatStore } from "../../Helpers/ChatStore.ts";
2
3
  import type { ChatMessage, ChatParticipant } from "../../Interfaces/ChatInterfaces.ts";
3
4
  import { type MarkdownToolbarItem } from "../Inputs/MarkdownEditor.tsx";
@@ -32,10 +33,15 @@ export interface ChatComposerProps {
32
33
  height?: number;
33
34
  /** Why the user may not write here. */
34
35
  readOnlyMessage?: string;
36
+ /**
37
+ * What the writer should know before sending, in a line over the writing area that the writing area is described by:
38
+ * "You were sent a blind copy. Replying shows you to everyone."
39
+ */
40
+ notice?: ReactNode;
35
41
  /** Most characters in one message. Default 4,000. */
36
42
  maxLength?: number;
37
43
  autoFocus?: boolean;
38
44
  className?: string;
39
45
  }
40
46
  /** Writes and sends a message, in Markdown. */
41
- export declare function ChatComposer({ store, state, draftKey, placeholder, files, replyTo, onCancelReply, sendKey, mentions, mentionHref, toolbar, height, readOnlyMessage, maxLength, autoFocus, className, }: ChatComposerProps): import("react").JSX.Element;
47
+ export declare function ChatComposer({ store, state, draftKey, placeholder, files, replyTo, onCancelReply, sendKey, mentions, mentionHref, toolbar, height, readOnlyMessage, notice, maxLength, autoFocus, className, }: ChatComposerProps): import("react").JSX.Element;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { iconPaperclip, iconSend, iconX, InlineIcon } from "nexus-icons";
4
- import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { iconInfoCircle, iconPaperclip, iconSend, iconX, InlineIcon } from "nexus-icons";
4
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
5
5
  import { applyChatMention, chatFileSize, findChatMention, matchChatMentions, readChatMentions } from "../../Helpers/ChatHelpers.js";
6
6
  import { cx } from "../../Helpers/ClassHelpers.js";
7
7
  import { continueMarkdownList } from "../../Helpers/MarkdownEditing.js";
@@ -54,8 +54,9 @@ function insideCodeFence(value, caret) {
54
54
  return (before.match(/^```/gm) ?? []).length % 2 === 1;
55
55
  }
56
56
  /** Writes and sends a message, in Markdown. */
57
- export function ChatComposer({ store, state, draftKey, placeholder = "Write a message…", files = false, replyTo, onCancelReply, sendKey = "enter", mentions, mentionHref, toolbar = CHAT_TOOLBAR, height = 84, readOnlyMessage = "You can read this conversation, but not write in it.", maxLength = 4000, autoFocus = false, className, }) {
57
+ export function ChatComposer({ store, state, draftKey, placeholder = "Write a message…", files = false, replyTo, onCancelReply, sendKey = "enter", mentions, mentionHref, toolbar = CHAT_TOOLBAR, height = 84, readOnlyMessage = "You can read this conversation, but not write in it.", notice, maxLength = 4000, autoFocus = false, className, }) {
58
58
  const boxRef = useRef(null);
59
+ const noticeId = useId();
59
60
  const fileRef = useRef(null);
60
61
  // The text lives here so Send, the draft, and clearing after a send all read the same thing. A message is short, so
61
62
  // a render per keystroke costs nothing — unlike the long documents the editor is otherwise built for.
@@ -169,9 +170,11 @@ export function ChatComposer({ store, state, draftKey, placeholder = "Write a me
169
170
  return (_jsx("div", { className: cx("nx-chat-composer nx-chat-composer--readonly", className), children: _jsx("p", { children: readOnlyMessage }) }));
170
171
  }
171
172
  const empty = text.trim() === "" && picked.length === 0;
172
- return (_jsxs("div", { className: cx("nx-chat-composer", className), children: [replyTo && (_jsxs("div", { className: "nx-chat-composer__reply", children: [_jsxs("span", { className: "nx-chat-composer__reply-text", children: [_jsx("span", { className: "nx-chat-composer__reply-author", children: replyTo.authorName || "Unknown" }), _jsx("span", { children: markdownToPlainText(replyTo.body) })] }), _jsx("button", { type: "button", onClick: onCancelReply, "aria-label": "Stop replying", children: _jsx(InlineIcon, { icon: iconX }) })] })), picked.length > 0 && (_jsx("ul", { className: "nx-chat-composer__files", children: picked.map((file, index) => (_jsxs("li", { children: [_jsx("span", { className: "nx-chat-composer__file-name", children: file.name }), _jsx("span", { className: "nx-chat-composer__file-size", children: chatFileSize(file.size) }), _jsx("button", { type: "button", onClick: () => setPicked(held => held.filter((_, at) => at !== index)), "aria-label": `Remove ${file.name}`, children: _jsx(InlineIcon, { icon: iconX }) })] }, `${file.name}-${index}`))) })), picking && (_jsx("ul", { className: "nx-chat-mentions", role: "listbox", "aria-label": "People", children: offered.map((person, index) => (_jsx("li", { children: _jsxs("button", { type: "button", className: cx("nx-chat-mentions__item", index === at && "is-at"), role: "option", "aria-selected": index === at,
173
+ return (_jsxs("div", { className: cx("nx-chat-composer", className), children: [notice && (_jsxs("p", { className: "nx-chat-composer__notice", id: noticeId, children: [_jsx(InlineIcon, { icon: iconInfoCircle }), _jsx("span", { children: notice })] })), replyTo && (_jsxs("div", { className: "nx-chat-composer__reply", children: [_jsxs("span", { className: "nx-chat-composer__reply-text", children: [_jsx("span", { className: "nx-chat-composer__reply-author", children: replyTo.authorName || "Unknown" }), _jsx("span", { children: markdownToPlainText(replyTo.body) })] }), _jsx("button", { type: "button", onClick: onCancelReply, "aria-label": "Stop replying", children: _jsx(InlineIcon, { icon: iconX }) })] })), picked.length > 0 && (_jsx("ul", { className: "nx-chat-composer__files", children: picked.map((file, index) => (_jsxs("li", { children: [_jsx("span", { className: "nx-chat-composer__file-name", children: file.name }), _jsx("span", { className: "nx-chat-composer__file-size", children: chatFileSize(file.size) }), _jsx("button", { type: "button", onClick: () => setPicked(held => held.filter((_, at) => at !== index)), "aria-label": `Remove ${file.name}`, children: _jsx(InlineIcon, { icon: iconX }) })] }, `${file.name}-${index}`))) })), picking && (_jsx("ul", { className: "nx-chat-mentions", role: "listbox", "aria-label": "People", children: offered.map((person, index) => (_jsx("li", { children: _jsxs("button", { type: "button", className: cx("nx-chat-mentions__item", index === at && "is-at"), role: "option", "aria-selected": index === at,
173
174
  // The editor must not lose the caret before the name goes in.
174
- onMouseDown: event => event.preventDefault(), onMouseEnter: () => setAt(index), onClick: () => putMention(person), children: [_jsx(ChatAvatar, { name: person.name, src: person.avatar, size: "sm" }), _jsx("span", { className: "nx-chat-mentions__name", children: person.name }), person.role && _jsx("span", { className: "nx-chat-mentions__role", children: person.role })] }) }, String(person.id)))) })), _jsx(MarkdownEditor, { ref: boxRef, className: "nx-chat-composer__editor", "aria-label": "Message", placeholder: placeholder, value: text, onValueChange: change, onKeyDown: keyDown,
175
+ onMouseDown: event => event.preventDefault(), onMouseEnter: () => setAt(index), onClick: () => putMention(person), children: [_jsx(ChatAvatar, { name: person.name, src: person.avatar, size: "sm" }), _jsx("span", { className: "nx-chat-mentions__name", children: person.name }), person.role && _jsx("span", { className: "nx-chat-mentions__role", children: person.role })] }) }, String(person.id)))) })), _jsx(MarkdownEditor, { ref: boxRef, className: "nx-chat-composer__editor", "aria-label": "Message", "aria-describedby": notice ? noticeId : undefined, placeholder: placeholder, value: text, onValueChange: change, onKeyDown: keyDown,
175
176
  // Moving the caret away from an `@…` closes the list, as clicking elsewhere in the text does.
176
- onSelect: () => setMention(mentions ? findChatMention(boxRef.current?.value ?? "", boxRef.current?.selectionStart ?? 0) : null), onBlur: () => setMention(null), modes: ["write", "preview"], toolbar: toolbar, height: height, minHeight: 72, maxHeight: 360, heightKey: "nx-chat", showCount: false, maxLength: maxLength }), _jsxs("div", { className: "nx-chat-composer__row", children: [_jsx("span", { className: "nx-chat-composer__hint", children: sendKey === "enter" ? "Enter sends · Shift+Enter for a line" : "Ctrl+Enter sends" }), files && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "nx-chat-composer__attach", onClick: () => fileRef.current?.click(), "aria-label": "Attach a file", title: "Attach a file", children: _jsx(InlineIcon, { icon: iconPaperclip }) }), _jsx("input", { ref: fileRef, type: "file", multiple: true, hidden: true, onChange: addFiles })] })), _jsxs("button", { type: "button", className: "nx-chat-composer__send", onClick: send, disabled: empty, "aria-label": "Send", title: sendKey === "enter" ? "Send (Enter)" : "Send (Ctrl+Enter)", children: [_jsx(InlineIcon, { icon: iconSend }), _jsx("span", { children: "Send" })] })] })] }));
177
+ onSelect: () => setMention(mentions ? findChatMention(boxRef.current?.value ?? "", boxRef.current?.selectionStart ?? 0) : null), onBlur: () => setMention(null), modes: ["write", "preview"], toolbar: toolbar,
178
+ // One row on a phone too: the buttons that do not fit go into a menu, so the thread above keeps its room.
179
+ toolbarOverflow: "menu", height: height, minHeight: 72, maxHeight: 360, heightKey: "nx-chat", showCount: false, maxLength: maxLength }), _jsxs("div", { className: "nx-chat-composer__row", children: [_jsx("span", { className: "nx-chat-composer__hint", children: sendKey === "enter" ? "Enter sends · Shift+Enter for a line" : "Ctrl+Enter sends" }), files && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "nx-chat-composer__attach", onClick: () => fileRef.current?.click(), "aria-label": "Attach a file", title: "Attach a file", children: _jsx(InlineIcon, { icon: iconPaperclip }) }), _jsx("input", { ref: fileRef, type: "file", multiple: true, hidden: true, onChange: addFiles })] })), _jsxs("button", { type: "button", className: "nx-chat-composer__send", onClick: send, disabled: empty, "aria-label": "Send", title: sendKey === "enter" ? "Send (Enter)" : "Send (Ctrl+Enter)", children: [_jsx(InlineIcon, { icon: iconSend }), _jsx("span", { children: "Send" })] })] })] }));
177
180
  }
@@ -40,6 +40,15 @@ export declare function ChatSystemNote({ message, time }: {
40
40
  message: ChatMessage;
41
41
  time: ChatTimeOptions;
42
42
  }): import("react").JSX.Element;
43
+ /**
44
+ * A notification (`kind: "notification"`): what an app wrote about an event, under the name of the person who acted -
45
+ * "Approved at Supervisor. Now with Accounts." It is a quiet line across the thread, not a bubble on a side: the bell,
46
+ * who acted and when, and the words under them, so a thread of replies reads around its events.
47
+ */
48
+ export declare function ChatEventNote({ message, time }: {
49
+ message: ChatMessage;
50
+ time: ChatTimeOptions;
51
+ }): import("react").JSX.Element;
43
52
  export interface ChatMessageProps {
44
53
  message: ChatMessage;
45
54
  /** This user wrote it: it sits on the right. */
@@ -76,10 +85,14 @@ export declare function ChatEmpty({ message, hint }: {
76
85
  message?: string;
77
86
  hint?: ReactNode;
78
87
  }): import("react").JSX.Element;
79
- /** Something that did not load, with a way to try again. */
80
- export declare function ChatFailure({ message, onRetry }: {
88
+ /**
89
+ * Something that did not load, with a way to try again. `flat`: its tint without an edge, for a failure that already
90
+ * sits in a framed panel (a dashboard widget).
91
+ */
92
+ export declare function ChatFailure({ message, onRetry, flat }: {
81
93
  message: string;
82
94
  onRetry?: () => void;
95
+ flat?: boolean;
83
96
  }): import("react").JSX.Element;
84
97
  /** Messages on their way in. */
85
98
  export declare function ChatLoading({ label }: {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconAlertCircle, iconCheck, iconChecks, iconCornerUpLeft, iconFile, iconLoader2, iconRefresh, iconTrash, iconX, InlineIcon } from "nexus-icons";
3
+ import { iconAlertCircle, iconBell, iconCheck, iconChecks, iconCornerUpLeft, iconFile, iconLoader2, iconRefresh, iconTrash, iconX, InlineIcon } from "nexus-icons";
4
4
  import { cx } from "../../Helpers/ClassHelpers.js";
5
5
  import { chatDeliveryState, chatFileSize, chatTimeLabel, sameChatId, CHAT_DELIVERY_LABELS } from "../../Helpers/ChatHelpers.js";
6
6
  import { markdownToPlainText } from "../../Helpers/MarkdownHelpers.js";
@@ -57,6 +57,14 @@ export function ChatQuote({ message, onOpen }) {
57
57
  export function ChatSystemNote({ message, time }) {
58
58
  return (_jsxs("div", { className: "nx-chat-note", children: [_jsx("span", { children: markdownToPlainText(message.body) }), _jsx("time", { dateTime: message.createdAt, children: chatTimeLabel(message.createdAt, time) })] }));
59
59
  }
60
+ /**
61
+ * A notification (`kind: "notification"`): what an app wrote about an event, under the name of the person who acted -
62
+ * "Approved at Supervisor. Now with Accounts." It is a quiet line across the thread, not a bubble on a side: the bell,
63
+ * who acted and when, and the words under them, so a thread of replies reads around its events.
64
+ */
65
+ export function ChatEventNote({ message, time }) {
66
+ return (_jsxs("div", { className: "nx-chat-event", children: [_jsx(InlineIcon, { icon: iconBell, className: "nx-chat-event__icon" }), _jsxs("div", { className: "nx-chat-event__text", children: [_jsxs("span", { className: "nx-chat-event__meta", children: [_jsx("span", { className: "nx-chat-event__author", children: message.authorName || "Unknown" }), _jsx("time", { dateTime: message.createdAt, children: chatTimeLabel(message.createdAt, time) })] }), message.body && _jsx(MarkdownView, { className: "nx-chat-event__body", value: message.body })] })] }));
67
+ }
60
68
  /**
61
69
  * One message. Its own messages sit on the right in the primary tint, everyone else's on the left on a surface. The
62
70
  * actions are one row of icon buttons that appear on hover or keyboard focus — and never move the message, so a long
@@ -86,9 +94,12 @@ export function ChatParticipantList({ participants, userId, empty = "Nobody else
86
94
  export function ChatEmpty({ message = "No messages yet.", hint }) {
87
95
  return (_jsxs("div", { className: "nx-chat-empty", children: [_jsx("p", { children: message }), hint && _jsx("p", { className: "nx-chat-empty__hint", children: hint })] }));
88
96
  }
89
- /** Something that did not load, with a way to try again. */
90
- export function ChatFailure({ message, onRetry }) {
91
- return (_jsxs("div", { className: "nx-chat-failure", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: message }), onRetry && (_jsxs("button", { type: "button", onClick: onRetry, children: [_jsx(InlineIcon, { icon: iconRefresh }), " Try again"] }))] }));
97
+ /**
98
+ * Something that did not load, with a way to try again. `flat`: its tint without an edge, for a failure that already
99
+ * sits in a framed panel (a dashboard widget).
100
+ */
101
+ export function ChatFailure({ message, onRetry, flat = false }) {
102
+ return (_jsxs("div", { className: flat ? "nx-chat-failure nx-chat-failure--flat" : "nx-chat-failure", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: message }), onRetry && (_jsxs("button", { type: "button", onClick: onRetry, children: [_jsx(InlineIcon, { icon: iconRefresh }), " Try again"] }))] }));
92
103
  }
93
104
  /** Messages on their way in. */
94
105
  export function ChatLoading({ label = "Loading…" }) {