nexus-shared 3.2.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.
- package/CHANGELOG.md +2359 -0
- package/README.md +1911 -1123
- package/dist/Client.Index.d.ts +27 -0
- package/dist/Client.Index.js +27 -0
- package/dist/Components/Chats/ChatComposer.d.ts +7 -1
- package/dist/Components/Chats/ChatComposer.js +9 -6
- package/dist/Components/Chats/ChatParts.d.ts +15 -2
- package/dist/Components/Chats/ChatParts.js +15 -4
- package/dist/Components/Chats/ChatPopup.d.ts +46 -0
- package/dist/Components/Chats/ChatPopup.js +34 -0
- package/dist/Components/Chats/ChatThread.d.ts +9 -1
- package/dist/Components/Chats/ChatThread.js +54 -6
- package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
- package/dist/Components/Dashboards/DashboardBoard.js +594 -0
- package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
- package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
- package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
- package/dist/Components/Dashboards/WidgetFrame.js +45 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
- package/dist/Components/Documents/DocumentFrame.js +83 -0
- package/dist/Components/Documents/Menu.d.ts +4 -0
- package/dist/Components/Documents/Menu.js +40 -24
- package/dist/Components/Documents/PrintDocument.d.ts +95 -0
- package/dist/Components/Documents/PrintDocument.js +62 -0
- package/dist/Components/Documents/TabButtons.d.ts +6 -0
- package/dist/Components/Documents/TabButtons.js +5 -4
- package/dist/Components/Documents/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Domains/DomainCard.d.ts +98 -0
- package/dist/Components/Domains/DomainCard.js +182 -0
- package/dist/Components/Forms/ApiForm.js +24 -3
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +628 -247
- package/dist/Components/Forms/CrudActions.d.ts +137 -0
- package/dist/Components/Forms/CrudActions.js +214 -0
- package/dist/Components/Forms/CrudHistory.d.ts +6 -1
- package/dist/Components/Forms/CrudHistory.js +14 -6
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +37 -2
- package/dist/Components/Forms/CrudParts.js +123 -7
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +174 -0
- package/dist/Components/Forms/CrudScopes.d.ts +28 -0
- package/dist/Components/Forms/CrudScopes.js +76 -0
- package/dist/Components/Forms/CrudTree.d.ts +90 -0
- package/dist/Components/Forms/CrudTree.js +363 -0
- package/dist/Components/Forms/CrudWorkspace.d.ts +73 -0
- package/dist/Components/Forms/CrudWorkspace.js +177 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +6 -5
- package/dist/Components/Forms/Form.js +134 -21
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -0
- package/dist/Components/Forms/FormQuestion.d.ts +6 -0
- package/dist/Components/Forms/FormQuestion.js +28 -0
- package/dist/Components/Forms/RecordFlows.d.ts +50 -0
- package/dist/Components/Forms/RecordFlows.js +282 -0
- package/dist/Components/Forms/SubmitForm.d.ts +37 -2
- package/dist/Components/Forms/SubmitForm.js +182 -10
- package/dist/Components/Inputs/CheckBox.js +1 -1
- package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
- package/dist/Components/Inputs/ChoiceField.js +28 -2
- package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
- package/dist/Components/Inputs/ComputedInput.js +179 -0
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +8 -6
- package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
- package/dist/Components/Inputs/DropdownParts.js +16 -4
- package/dist/Components/Inputs/Field.d.ts +7 -1
- package/dist/Components/Inputs/Field.js +2 -2
- package/dist/Components/Inputs/GroupForm.d.ts +6 -6
- package/dist/Components/Inputs/GroupForm.js +12 -12
- package/dist/Components/Inputs/InputRenderer.d.ts +13 -6
- package/dist/Components/Inputs/InputRenderer.js +37 -32
- package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
- package/dist/Components/Inputs/MarkdownEditor.js +32 -6
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +75 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
- package/dist/Components/Inputs/RadioGroup.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +15 -2
- package/dist/Components/Inputs/RowsInput.js +96 -8
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +45 -32
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +11 -2
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TreeView.d.ts +76 -2
- package/dist/Components/Inputs/TreeView.js +227 -16
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +34 -7
- package/dist/Components/Layouts/Popup.js +143 -13
- package/dist/Components/Layouts/PopupDock.d.ts +3 -1
- package/dist/Components/Layouts/PopupDock.js +2 -1
- package/dist/Components/Layouts/PopupHost.js +5 -4
- package/dist/Components/Messages/MailClient.d.ts +50 -0
- package/dist/Components/Messages/MailClient.js +410 -0
- package/dist/Components/Messages/MailColumns.d.ts +21 -0
- package/dist/Components/Messages/MailColumns.js +92 -0
- package/dist/Components/Messages/MailCompose.d.ts +46 -0
- package/dist/Components/Messages/MailCompose.js +154 -0
- package/dist/Components/Messages/MailReader.d.ts +43 -0
- package/dist/Components/Messages/MailReader.js +207 -0
- package/dist/Components/Messages/MailThread.d.ts +71 -0
- package/dist/Components/Messages/MailThread.js +185 -0
- package/dist/Components/Messages/MessageBell.d.ts +41 -0
- package/dist/Components/Messages/MessageBell.js +76 -0
- package/dist/Components/Viewers/DataTable.js +153 -49
- package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
- package/dist/Components/Viewers/DataTableColumns.js +2 -1
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +23 -5
- package/dist/Components/Viewers/DataTableParts.js +26 -14
- package/dist/Components/Viewers/OwnerFiles.d.ts +227 -0
- package/dist/Components/Viewers/OwnerFiles.js +700 -0
- package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
- package/dist/Components/Viewers/QrCodeImage.js +15 -0
- package/dist/Components/Viewers/RecordCards.d.ts +16 -0
- package/dist/Components/Viewers/RecordCards.js +509 -0
- package/dist/Helpers/ApiModules.js +4 -0
- package/dist/Helpers/AuditEntries.d.ts +50 -0
- package/dist/Helpers/AuditEntries.js +96 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/ChatBackend.d.ts +8 -1
- package/dist/Helpers/ChatBackend.js +18 -0
- package/dist/Helpers/ChatHelpers.d.ts +2 -0
- package/dist/Helpers/ChatHelpers.js +4 -1
- package/dist/Helpers/CrudBackend.d.ts +20 -2
- package/dist/Helpers/CrudBackend.js +46 -2
- package/dist/Helpers/CrudColumns.d.ts +91 -6
- package/dist/Helpers/CrudColumns.js +148 -7
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +116 -2
- package/dist/Helpers/CrudHelpers.js +256 -0
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
- package/dist/Helpers/CrudPickerPlan.js +46 -0
- package/dist/Helpers/CrudRowForms.d.ts +32 -0
- package/dist/Helpers/CrudRowForms.js +76 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -0
- package/dist/Helpers/DashboardHelpers.d.ts +115 -0
- package/dist/Helpers/DashboardHelpers.js +305 -0
- package/dist/Helpers/DomainCards.d.ts +140 -0
- package/dist/Helpers/DomainCards.js +370 -0
- package/dist/Helpers/DropdownHelpers.d.ts +3 -1
- package/dist/Helpers/DropdownHelpers.js +6 -2
- package/dist/Helpers/ExcelHelpers.d.ts +1 -1
- package/dist/Helpers/ExcelHelpers.js +12 -11
- package/dist/Helpers/FileAdapter.d.ts +158 -0
- package/dist/Helpers/FileAdapter.js +173 -0
- package/dist/Helpers/FilePrimaries.d.ts +48 -0
- package/dist/Helpers/FilePrimaries.js +138 -0
- package/dist/Helpers/FormConfirm.d.ts +32 -0
- package/dist/Helpers/FormConfirm.js +40 -0
- package/dist/Helpers/FormStore.d.ts +100 -5
- package/dist/Helpers/FormStore.js +368 -25
- package/dist/Helpers/InputConditions.d.ts +56 -0
- package/dist/Helpers/InputConditions.js +192 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +395 -0
- package/dist/Helpers/InputNarrowing.d.ts +50 -0
- package/dist/Helpers/InputNarrowing.js +148 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +157 -74
- package/dist/Helpers/MailClientHelpers.d.ts +96 -0
- package/dist/Helpers/MailClientHelpers.js +211 -0
- package/dist/Helpers/MessageBackend.d.ts +152 -0
- package/dist/Helpers/MessageBackend.js +329 -0
- package/dist/Helpers/MessageHelpers.d.ts +160 -0
- package/dist/Helpers/MessageHelpers.js +573 -0
- package/dist/Helpers/MessageUnread.d.ts +95 -0
- package/dist/Helpers/MessageUnread.js +245 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +71 -0
- package/dist/Helpers/NexusDriveAdapter.js +236 -0
- package/dist/Helpers/NumberHelpers.d.ts +156 -0
- package/dist/Helpers/NumberHelpers.js +242 -0
- package/dist/Helpers/PagedPicker.d.ts +42 -0
- package/dist/Helpers/PagedPicker.js +85 -0
- package/dist/Helpers/Permissions.js +2 -1
- package/dist/Helpers/QrCode.d.ts +26 -0
- package/dist/Helpers/QrCode.js +463 -0
- package/dist/Helpers/RecordCards.d.ts +205 -0
- package/dist/Helpers/RecordCards.js +378 -0
- package/dist/Helpers/RequestCache.d.ts +14 -2
- package/dist/Helpers/RequestCache.js +22 -3
- package/dist/Helpers/RowsStore.d.ts +61 -2
- package/dist/Helpers/RowsStore.js +203 -16
- package/dist/Helpers/ServerPreview.d.ts +69 -0
- package/dist/Helpers/ServerPreview.js +99 -0
- package/dist/Helpers/TableColumns.js +4 -3
- package/dist/Helpers/TableExport.d.ts +7 -1
- package/dist/Helpers/TableExport.js +9 -3
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +38 -4
- package/dist/Helpers/TableHelpers.js +78 -10
- package/dist/Helpers/TableJoins.d.ts +13 -0
- package/dist/Helpers/TableJoins.js +34 -0
- package/dist/Helpers/TableStore.d.ts +28 -4
- package/dist/Helpers/TableStore.js +73 -11
- package/dist/Helpers/ToolbarFit.d.ts +23 -0
- package/dist/Helpers/ToolbarFit.js +30 -0
- package/dist/Helpers/TreeStore.d.ts +156 -6
- package/dist/Helpers/TreeStore.js +547 -18
- package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
- package/dist/Interfaces/ApiInterfaces.js +10 -1
- package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +539 -6
- package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
- package/dist/Interfaces/DashboardInterfaces.js +1 -0
- package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
- package/dist/Interfaces/DomainInterfaces.js +1 -0
- package/dist/Interfaces/FileInterfaces.d.ts +183 -0
- package/dist/Interfaces/FileInterfaces.js +6 -0
- package/dist/Interfaces/FormInterfaces.d.ts +460 -32
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +22 -3
- package/dist/Interfaces/MailInterfaces.d.ts +192 -0
- package/dist/Interfaces/MailInterfaces.js +1 -0
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +102 -3
- package/dist/Services/ExcelApi.js +5 -0
- package/dist/Services/FileApi.d.ts +13 -0
- package/dist/Services/FileApi.js +29 -0
- package/dist/Services/ImageEditorLoader.d.ts +14 -0
- package/dist/Services/ImageEditorLoader.js +51 -0
- package/dist/Services/JoinSources.d.ts +79 -6
- package/dist/Services/JoinSources.js +132 -21
- package/dist/Services/MessageApi.d.ts +18 -0
- package/dist/Services/MessageApi.js +64 -0
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Shared.Index.d.ts +28 -0
- package/dist/Shared.Index.js +28 -0
- package/package.json +13 -2
- package/src/Styles/Nexus.Avatar.css +6 -5
- package/src/Styles/Nexus.Button.css +39 -4
- package/src/Styles/Nexus.Chat.css +114 -22
- package/src/Styles/Nexus.Choice.css +16 -0
- package/src/Styles/Nexus.Crud.css +486 -21
- package/src/Styles/Nexus.Dashboard.css +763 -0
- package/src/Styles/Nexus.Domain.css +384 -0
- package/src/Styles/Nexus.Dropdown.css +8 -0
- package/src/Styles/Nexus.Editor.css +20 -0
- package/src/Styles/Nexus.Files.css +437 -0
- package/src/Styles/Nexus.Form.css +5 -2
- package/src/Styles/Nexus.Index.css +7 -0
- package/src/Styles/Nexus.Input.css +73 -0
- package/src/Styles/Nexus.Mail.css +1062 -0
- package/src/Styles/Nexus.Menu.css +10 -1
- package/src/Styles/Nexus.Popup.css +135 -1
- package/src/Styles/Nexus.Print.css +288 -0
- package/src/Styles/Nexus.QrCode.css +12 -0
- package/src/Styles/Nexus.Record.css +430 -0
- package/src/Styles/Nexus.Tab.Buttons.css +87 -22
- package/src/Styles/Nexus.Table.css +1458 -1387
- package/src/Styles/Nexus.Theme.Picker.css +1 -0
- package/src/Styles/Nexus.Themes.css +12 -6
- package/src/Styles/Nexus.Tokens.css +8 -3
- package/src/Styles/Nexus.Tree.css +41 -1
package/README.md
CHANGED
|
@@ -1,1123 +1,1911 @@
|
|
|
1
|
-
# nexus-shared
|
|
2
|
-
|
|
3
|
-
Lightweight React components, helpers, and design tokens for Nexus apps. No runtime dependencies besides
|
|
4
|
-
React and `nexus-icons`.
|
|
5
|
-
|
|
6
|
-
```
|
|
7
|
-
npm install nexus-shared nexus-icons
|
|
8
|
-
```
|
|
9
|
-
|
|
10
|
-
```tsx
|
|
11
|
-
// Once, in the root layout
|
|
12
|
-
import "nexus-shared/styles.css";
|
|
13
|
-
|
|
14
|
-
import { Button } from "nexus-shared";
|
|
15
|
-
import { InlineIcon, iconPlus } from "nexus-icons";
|
|
16
|
-
|
|
17
|
-
<Button variant="primary" icon={<InlineIcon icon={iconPlus} />}>New booking</Button>
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
## Entry points
|
|
21
|
-
|
|
22
|
-
| Import | Contains |
|
|
23
|
-
|---|---|
|
|
24
|
-
| `nexus-shared` | Components and helpers that work in server and client components |
|
|
25
|
-
| `nexus-shared/client` | Client components and browser services |
|
|
26
|
-
| `nexus-shared/server` | Server-only code |
|
|
27
|
-
| `nexus-shared/styles.css` | Tokens, base styles, and component styles |
|
|
28
|
-
|
|
29
|
-
## Themes
|
|
30
|
-
|
|
31
|
-
Seven themes, each with a light and a dark palette that meets WCAG AA contrast:
|
|
32
|
-
**Nexus** (default), **GitHub**, **Nord**, **Dracula**, **Solarized**, **Catppuccin**, and **Rosé Pine**.
|
|
33
|
-
The mode is Light, Dark, or System (follows the operating system).
|
|
34
|
-
|
|
35
|
-
```tsx
|
|
36
|
-
// app/layout.tsx
|
|
37
|
-
import "nexus-shared/styles.css";
|
|
38
|
-
import { ThemeScript } from "nexus-shared";
|
|
39
|
-
import { ThemeSwitcher } from "nexus-shared/client";
|
|
40
|
-
|
|
41
|
-
export default function RootLayout({ children }) {
|
|
42
|
-
return (
|
|
43
|
-
<html lang="en" suppressHydrationWarning>
|
|
44
|
-
<head>
|
|
45
|
-
<ThemeScript />
|
|
46
|
-
</head>
|
|
47
|
-
<body>
|
|
48
|
-
<header>… <ThemeSwitcher /></header>
|
|
49
|
-
{children}
|
|
50
|
-
</body>
|
|
51
|
-
</html>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
- `ThemeScript` applies the saved choices before the page paints, so there is no flash of the defaults. Options:
|
|
57
|
-
`defaultTheme`, `defaultMode`, `defaultDensity`, `defaultAnimations`, `grid`, `themes`, `nonce`. It sets
|
|
58
|
-
attributes on `<html>`, hence `suppressHydrationWarning`.
|
|
59
|
-
- `ThemeSwitcher` is a palette button with a popover; `ThemePicker` is the same picker placed in a page. Besides the
|
|
60
|
-
mode and the theme it offers **Density** (Compact, Cozy), **Animations** (On, Off, System) and, in a development
|
|
61
|
-
build only, the **Scale grid** (Off, px, mm); `showMode`, `showDensity`, `showAnimations`, `showGrid` turn them off.
|
|
62
|
-
- `useTheme()` returns `{ theme, mode, resolvedMode, density, animations, motion, grid }` and a setter for each.
|
|
63
|
-
- The choices are saved in `localStorage` (`nx-theme`, `nx-mode`, `nx-density`, `nx-animations`, `nx-grid`) and
|
|
64
|
-
shared by open tabs.
|
|
65
|
-
|
|
66
|
-
Without JavaScript, set the attributes yourself: `<html data-nx-theme="nord" data-nx-mode="dark">`.
|
|
67
|
-
Without `data-nx-mode`, the system setting applies. `data-nx-theme` also works on any element, which then
|
|
68
|
-
draws in that theme (the picker's previews use this).
|
|
69
|
-
|
|
70
|
-
**Density** is `data-nx-density="cozy"` on `<html>`: every control 4px taller (`--nx-control-sm/md/lg` 32/38/44) and
|
|
71
|
-
every list row 6px (`--nx-row` 38), so buttons, fields, table rows and headers, menus, dropdown options and the table's
|
|
72
|
-
footer follow at once. Compact, the default, is the sizes the package always had.
|
|
73
|
-
|
|
74
|
-
**Animations** come from one attribute too. `ThemeScript` works out `data-nx-motion` (`reduce` or `full`) from the
|
|
75
|
-
visitor's choice and the system's `prefers-reduced-motion`, and keeps it current when the system setting changes;
|
|
76
|
-
every stylesheet reads that attribute, never the media query. So **On** animates on a machine whose operating system
|
|
77
|
-
has animations turned off, **Off** never animates, and **System** (the default) does what the system says. Popups fade
|
|
78
|
-
in and out, and every popover panel (menus, the table's panels, pickers) fades briefly; nothing waits for a fade.
|
|
79
|
-
|
|
80
|
-
**Scroll bars** have one look everywhere, the design lab's: thin and quiet, with a thumb of the text colour at 22% on
|
|
81
|
-
no track (`Nexus.Base.css` sets `scrollbar-width` and `scrollbar-color` on every element). On Windows, Chrome and Edge
|
|
82
|
-
draw the bar 10px wide and Firefox 8px. Safari has no `scrollbar-color`, so it draws its own thin bar. A component
|
|
83
|
-
that needs another look sets the two properties on its own scroller: the time picker's columns hide the thumb until
|
|
84
|
-
the pointer or the keys are in them. An app overrides the bar the same way, from outside the layers. With forced
|
|
85
|
-
colours on, the system's colours come back.
|
|
86
|
-
|
|
87
|
-
### How it works
|
|
88
|
-
|
|
89
|
-
- `Nexus.Themes.css` gives each theme its colors once, as `light-dark(light, dark)`; the page's `color-scheme` picks the side.
|
|
90
|
-
- `Nexus.Tokens.css` holds sizes, spacing, and type, and derives hover colors, soft backgrounds, and the focus ring from the palette.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
| |
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
- `
|
|
145
|
-
`
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
|
160
|
-
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
193
|
-
| `
|
|
194
|
-
| `
|
|
195
|
-
| `
|
|
196
|
-
| `
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
201
|
-
| `
|
|
202
|
-
| `
|
|
203
|
-
| `
|
|
204
|
-
| `
|
|
205
|
-
| `
|
|
206
|
-
| `
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
- `
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
the
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
`
|
|
457
|
-
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
import {
|
|
483
|
-
|
|
484
|
-
<
|
|
485
|
-
<
|
|
486
|
-
<
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
- **
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
- **
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
- **
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
- **
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
- **
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
- **
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
- **
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
- **
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
- **
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
```
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
`
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
- **
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
- **
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
`
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
- **
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
`
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
{
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
as
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
the
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
- **
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
`
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
- **
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1
|
+
# nexus-shared
|
|
2
|
+
|
|
3
|
+
Lightweight React components, helpers, and design tokens for Nexus apps. No runtime dependencies besides
|
|
4
|
+
React and `nexus-icons`.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
npm install nexus-shared nexus-icons
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
// Once, in the root layout
|
|
12
|
+
import "nexus-shared/styles.css";
|
|
13
|
+
|
|
14
|
+
import { Button } from "nexus-shared";
|
|
15
|
+
import { InlineIcon, iconPlus } from "nexus-icons";
|
|
16
|
+
|
|
17
|
+
<Button variant="primary" icon={<InlineIcon icon={iconPlus} />}>New booking</Button>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Entry points
|
|
21
|
+
|
|
22
|
+
| Import | Contains |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `nexus-shared` | Components and helpers that work in server and client components |
|
|
25
|
+
| `nexus-shared/client` | Client components and browser services |
|
|
26
|
+
| `nexus-shared/server` | Server-only code |
|
|
27
|
+
| `nexus-shared/styles.css` | Tokens, base styles, and component styles |
|
|
28
|
+
|
|
29
|
+
## Themes
|
|
30
|
+
|
|
31
|
+
Seven themes, each with a light and a dark palette that meets WCAG AA contrast:
|
|
32
|
+
**Nexus** (default), **GitHub**, **Nord**, **Dracula**, **Solarized**, **Catppuccin**, and **Rosé Pine**.
|
|
33
|
+
The mode is Light, Dark, or System (follows the operating system).
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
// app/layout.tsx
|
|
37
|
+
import "nexus-shared/styles.css";
|
|
38
|
+
import { ThemeScript } from "nexus-shared";
|
|
39
|
+
import { ThemeSwitcher } from "nexus-shared/client";
|
|
40
|
+
|
|
41
|
+
export default function RootLayout({ children }) {
|
|
42
|
+
return (
|
|
43
|
+
<html lang="en" suppressHydrationWarning>
|
|
44
|
+
<head>
|
|
45
|
+
<ThemeScript />
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<header>… <ThemeSwitcher /></header>
|
|
49
|
+
{children}
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `ThemeScript` applies the saved choices before the page paints, so there is no flash of the defaults. Options:
|
|
57
|
+
`defaultTheme`, `defaultMode`, `defaultDensity`, `defaultAnimations`, `grid`, `themes`, `nonce`. It sets
|
|
58
|
+
attributes on `<html>`, hence `suppressHydrationWarning`.
|
|
59
|
+
- `ThemeSwitcher` is a palette button with a popover; `ThemePicker` is the same picker placed in a page. Besides the
|
|
60
|
+
mode and the theme it offers **Density** (Compact, Cozy), **Animations** (On, Off, System) and, in a development
|
|
61
|
+
build only, the **Scale grid** (Off, px, mm); `showMode`, `showDensity`, `showAnimations`, `showGrid` turn them off.
|
|
62
|
+
- `useTheme()` returns `{ theme, mode, resolvedMode, density, animations, motion, grid }` and a setter for each.
|
|
63
|
+
- The choices are saved in `localStorage` (`nx-theme`, `nx-mode`, `nx-density`, `nx-animations`, `nx-grid`) and
|
|
64
|
+
shared by open tabs.
|
|
65
|
+
|
|
66
|
+
Without JavaScript, set the attributes yourself: `<html data-nx-theme="nord" data-nx-mode="dark">`.
|
|
67
|
+
Without `data-nx-mode`, the system setting applies. `data-nx-theme` also works on any element, which then
|
|
68
|
+
draws in that theme (the picker's previews use this).
|
|
69
|
+
|
|
70
|
+
**Density** is `data-nx-density="cozy"` on `<html>`: every control 4px taller (`--nx-control-sm/md/lg` 32/38/44) and
|
|
71
|
+
every list row 6px (`--nx-row` 38), so buttons, fields, table rows and headers, menus, dropdown options and the table's
|
|
72
|
+
footer follow at once. Compact, the default, is the sizes the package always had.
|
|
73
|
+
|
|
74
|
+
**Animations** come from one attribute too. `ThemeScript` works out `data-nx-motion` (`reduce` or `full`) from the
|
|
75
|
+
visitor's choice and the system's `prefers-reduced-motion`, and keeps it current when the system setting changes;
|
|
76
|
+
every stylesheet reads that attribute, never the media query. So **On** animates on a machine whose operating system
|
|
77
|
+
has animations turned off, **Off** never animates, and **System** (the default) does what the system says. Popups fade
|
|
78
|
+
in and out, and every popover panel (menus, the table's panels, pickers) fades briefly; nothing waits for a fade.
|
|
79
|
+
|
|
80
|
+
**Scroll bars** have one look everywhere, the design lab's: thin and quiet, with a thumb of the text colour at 22% on
|
|
81
|
+
no track (`Nexus.Base.css` sets `scrollbar-width` and `scrollbar-color` on every element). On Windows, Chrome and Edge
|
|
82
|
+
draw the bar 10px wide and Firefox 8px. Safari has no `scrollbar-color`, so it draws its own thin bar. A component
|
|
83
|
+
that needs another look sets the two properties on its own scroller: the time picker's columns hide the thumb until
|
|
84
|
+
the pointer or the keys are in them. An app overrides the bar the same way, from outside the layers. With forced
|
|
85
|
+
colours on, the system's colours come back.
|
|
86
|
+
|
|
87
|
+
### How it works
|
|
88
|
+
|
|
89
|
+
- `Nexus.Themes.css` gives each theme its colors once, as `light-dark(light, dark)`; the page's `color-scheme` picks the side.
|
|
90
|
+
- `Nexus.Tokens.css` holds sizes, spacing, and type, and derives hover colors, soft backgrounds, and the focus ring from the palette.
|
|
91
|
+
A solid fill under the pointer is the fill with 12% black mixed in, in light and dark mode alike, so a light label on
|
|
92
|
+
it only gains contrast: a `--nx-primary-solid` or `--nx-danger-solid` whose label reads at rest reads hovered and
|
|
93
|
+
pressed too. `--nx-primary-text` and `--nx-danger-text` are therefore white (or near it) in both modes; a fill that
|
|
94
|
+
needs a dark label is too light to be a solid fill. In a dark theme the darker step moves the fill toward the panel,
|
|
95
|
+
so a fill keeps 2:1 on `--nx-surface` under the pointer as it does at rest.
|
|
96
|
+
- Components use only `--nx-*` variables, so every component follows the theme with no extra code.
|
|
97
|
+
- `npm run check:themes` checks every theme in both modes against WCAG contrast. Run it after changing a color.
|
|
98
|
+
|
|
99
|
+
### Custom themes
|
|
100
|
+
|
|
101
|
+
A theme is one CSS block. List it for the script and the picker:
|
|
102
|
+
|
|
103
|
+
```css
|
|
104
|
+
[data-nx-theme="brand"] {
|
|
105
|
+
--nx-primary: light-dark(#0f766e, #2dd4bf); /* the accent as text and lines */
|
|
106
|
+
--nx-primary-solid: light-dark(#0f766e, #0d7a70); /* the accent as a fill behind white text: buttons, checks */
|
|
107
|
+
--nx-primary-text: light-dark(#ffffff, #ffffff); /* light in both modes: hover and pressed darken the fill */
|
|
108
|
+
/* …any other --nx-* color: bg, surface, surface-2, surface-hover, border, border-strong,
|
|
109
|
+
text, text-muted, text-subtle, danger, danger-solid, danger-text, success, warning, info */
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Give a solid fill a light label at 4.6:1 or more in both modes, and keep it 2:1 off `--nx-surface` once 12% black is
|
|
114
|
+
mixed in (the pointer's step). A dark label on a light fill reads at rest and loses contrast under the pointer. The
|
|
115
|
+
example's dark fill, `#0d7a70`, holds white at 5.2:1 at rest and 6.8:1 hovered. `npm run check:themes` checks this
|
|
116
|
+
package's own themes, not an app's.
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
const themes = [...NEXUS_THEMES, { id: "brand", name: "Brand", description: "Our colors" }];
|
|
120
|
+
<ThemeScript themes={themes} defaultTheme="brand" />
|
|
121
|
+
<ThemeSwitcher themes={themes} />
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
All styles sit in `@layer nexus.*`, so an app's own CSS overrides any token or component style without `!important`.
|
|
125
|
+
|
|
126
|
+
### Quick preferences
|
|
127
|
+
|
|
128
|
+
Under the theme, the `ThemeSwitcher` panel holds the person's own settings (`PreferencesPicker`, or
|
|
129
|
+
`<ThemeSwitcher preferences={false} />` for the theme alone). Each applies at once to everything on the page, is saved
|
|
130
|
+
on this device, and reaches other open tabs:
|
|
131
|
+
|
|
132
|
+
| Section | Setting | Saved as | Read by |
|
|
133
|
+
|---|---|---|---|
|
|
134
|
+
| Language and region | Interface language: English, नेपाली | `nx-language` | `useUserPreferences().language` |
|
|
135
|
+
| | Calendar: AD, BS | `nx-date-calendar` | every date component (`useDatePreferences`) |
|
|
136
|
+
| Dates and times | Date picker language: English, नेपाली | `nx-date-script` | every date component |
|
|
137
|
+
| | Clock: 12-hour, 24-hour | `nx-time-format` | every time component |
|
|
138
|
+
| | Display date format | `nx-date-format` | every date component |
|
|
139
|
+
| Numbers | Number scale (the core's `ENumberScales`): International 1,234,567, South Asian 12,34,567 | `nx-number-scale` | `useNumberLocale()` |
|
|
140
|
+
| | Thousands separator: 1,200, 1.200 (decimal comma), 1 200, 1200 | `nx-thousands` | `useNumberLocale()` |
|
|
141
|
+
| Tables | Default page size | `nx-page-size` | every `DataTable` and `Crud` |
|
|
142
|
+
| | A row click opens the record | `nx-row-click` | every `DataTable` and `Crud` |
|
|
143
|
+
|
|
144
|
+
- **Numbers:** `useNumberLocale()` turns the two number settings into a locale (`numberLocale(scale, separator)`):
|
|
145
|
+
`en-US` and `en-IN` as they are, any other pair a key such as `"en-IN|.|,"` that `formatNumber`, `parseNumber` and
|
|
146
|
+
`getNumberSymbols` read. A `NumberBox`, a `Slider` and a table's number cells without a `locale` of their own use it;
|
|
147
|
+
a number box rewrites what it shows when the setting changes, so a typed value is never read in the wrong marks.
|
|
148
|
+
- **Tables:** once chosen, the page size and the row click go over what a table names itself (`pageSize`,
|
|
149
|
+
`openOnClick`); a page size picked in a table with a `stateKey` still wins there, and a size the table does not offer
|
|
150
|
+
is not forced on it. Until chosen (`null`), each table keeps its own.
|
|
151
|
+
- **Interface language** is stored and read through `useUserPreferences()`; the package's own words are English for
|
|
152
|
+
now, and it does not set `lang` on `<html>` until they are translated (a screen reader would read English in a Nepali
|
|
153
|
+
voice).
|
|
154
|
+
- `setUserPreferences({ … })` and `readUserPreferences()` (`nexus-shared/client`) change and read them from code, as
|
|
155
|
+
`setDatePreferences` does the date settings.
|
|
156
|
+
|
|
157
|
+
## Components
|
|
158
|
+
|
|
159
|
+
| Component | Import | Notes |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `Button` | `nexus-shared` | Variants `primary`, `secondary`, `ghost`, `danger`; sizes `sm`, `md`, `lg`; `icon`, `iconEnd`, `loading`, `block`; `hoverTone` |
|
|
162
|
+
| `TabButtons` | `nexus-shared/client` | Pick one option; sliding indicator; sizes, `tone="primary"`, `labels="selected"` (icons, label on the selected option), `block`, disabled options; `design="list"` for a vertical list with descriptions and badges; `multiple` to pick several, wrapping onto more rows |
|
|
163
|
+
| `Tabs`, `TabPanel` | `nexus-shared/client` | Tabs that switch panels: a `tablist` in the look of `TabButtons` (`design` `list`, `nav`, or `auto`: the list, a strip in a container under 720px), the chosen tab the only one in the Tab order, arrows, Home and End; `TabPanel` joins the panel to its tab |
|
|
164
|
+
| `SplitButton` | `nexus-shared/client` | A main action with a menu of alternatives; the pick becomes the main button, and `storageKey` remembers it for the next visit |
|
|
165
|
+
| `Menu`, `ContextMenu` | `nexus-shared/client` | A dropdown menu under a button, and a right-click menu: icons, shortcuts, descriptions, `tone="danger"`, links, `kind="radio"` or `"checkbox"` |
|
|
166
|
+
| `Toaster`, `toast` | `nexus-shared/client` | Toast messages from anywhere: `toast.success(title, message)`, `toast.promise`, `toast.loading`, actions, positions; one `<Toaster />` in the layout |
|
|
167
|
+
| `Popup` | `nexus-shared/client` | A modal window on the native dialog, with the buttons of a desktop window: maximize, close, and minimize (a bar at the bottom right, content kept) only when it asks (`minimizable`: a CRUD page's Add, Edit and row forms do); moved by its title bar inside the window; title, description, scrolling body, footer, sizes `xs` to `full`, close reasons |
|
|
168
|
+
| `PopupHost`, `openPopup` | `nexus-shared/client` | Popups opened from code with a handle (`close`, `update`, `minimize`, `maximize`, `restore`, `windowState`, `closed`), one per `key` (replaced or restored); one `<PopupHost />` in the layout, which also shows the minimized popups |
|
|
169
|
+
| `definePopupRules` | `nexus-shared/client` | Rules for popups by key or `prefix-*` pattern, set once: `reopen` (replace or restore), `minimizable`, `maximizable`, `movable`, how it closes, `size`, `defaultWindowState` |
|
|
170
|
+
| `useDraggable`, `makeDraggable`, `startDrag`, `moveWithin`, `clampToBounds` | `nexus-shared/client` | Move any element with the pointer (or the keyboard), kept inside bounds: the window, the parent, an element, or a box; by a handle, along one axis, partly outside; Esc puts it back |
|
|
171
|
+
| `messageBox` | `nexus-shared/client` | `alert`, `confirm`, `prompt`, and `show` with any buttons, each returning a promise |
|
|
172
|
+
| `TextBox` | `nexus-shared/client` | Text input: label designs `standard`, `outlined`, `inbox`; sizes; icons; `prefix`, `suffix`; `clearable`; show password; `showCount`; validation |
|
|
173
|
+
| `TextArea` | `nexus-shared/client` | Multi-line text with the same field features; `autoResize` between `rows` and `maxRows` |
|
|
174
|
+
| `CheckBox`, `Switch` | `nexus-shared/client` | One checkbox or on/off switch: `label`, `description`, `required` (must be checked), `indeterminate`, `readOnly`, `labelPosition` |
|
|
175
|
+
| `CheckBoxGroup` | `nexus-shared/client` | Many choices from `options`: `min`, `max` (disables the rest at the limit), `selectAll`, `columns`, `design="cards"` |
|
|
176
|
+
| `RadioGroup` | `nexus-shared/client` | One choice from `options`: arrow keys, `orientation`, `columns`, `design="cards"`, `design="tabs"` (nav-style tabs, descriptions as tooltips) |
|
|
177
|
+
| `Dropdown` | `nexus-shared/client` | One value, or several with `multiple`, from `options` or server pages (`loadOptions`: search, infinite scroll, loaders, Retry); rows as `columns` like a data table (avatar, image, icon, code, money); saved values the list no longer offers (`selectedOptions`, `resolveOptions`, inactive items) show and cannot be picked again |
|
|
178
|
+
| `Slider`, `RangeSlider` | `nexus-shared/client` | Pick a number or a range: `min`, `max`, `step`, number format with `prefix`/`suffix`, `marks`, `tooltip`, `minDistance`, `onValueCommit` |
|
|
179
|
+
| `NumberBox` | `nexus-shared/client` | Numbers only: `min`, `max`, `decimals`, `fixedDecimals`, `grouping` by `locale`, `prefix`, `suffix`, `stepper`, `clamp`; the same field shell |
|
|
180
|
+
| `DatePicker` | `nexus-shared/client` | Type or pick a date in the English (AD) or Nepali (BS) calendar from the user's settings; `min`, `max`, `isDateDisabled`; the value is always a Gregorian ISO date |
|
|
181
|
+
| `DateRangePicker` | `nexus-shared/client` | A start and an end in one box, picked in a two-month calendar or typed; `startName` and `endName` send two fields; `minDistance`, `maxDistance` in days; booked nights cannot be included |
|
|
182
|
+
| `TimePicker` | `nexus-shared/client` | Type or pick a time on a 12- or 24-hour clock from the user's settings: columns of hours and minutes or `view="list"` slots; `min`, `max` (past midnight too), `step` in seconds, `isTimeDisabled`; the value is always an ISO time |
|
|
183
|
+
| `DateTimePicker` | `nexus-shared/client` | Type or pick a day and a time: each user sees their own local time in their date and time settings, and the value is always the UTC moment; `timeZone` for a place's zone, `min`, `max`, `minTime`, `maxTime`, `step`, `isDateDisabled`, `isTimeDisabled` |
|
|
184
|
+
| `Calendar` | `nexus-shared/client` | The month grid on its own: single or `mode="range"`, `months={2}` side by side, month and year menus, arrow keys, `renderDay` for event markers, `weekStart` |
|
|
185
|
+
| `useDatePreferences`, `setDatePreferences` | `nexus-shared/client` | The user's calendar, format, script, and time format, saved in local storage and followed by every date and time component |
|
|
186
|
+
| `Form` | `nexus-shared/client` | A list of input params (`InputParams[]`) on a 12-column grid (`span`), with the values in a store outside React; `onFieldChange` for derived values, `validate` for rules across inputs, `draftKey` for unsaved changes that survive a reload, `loading` (the inputs disabled until the values land) |
|
|
187
|
+
| `SubmitForm` | `nexus-shared/client` | A `Form` that checks every value on Save and focuses the first problem, then runs `onSubmit` once at a time; a status line with links to what needs attention; Save, Cancel (asks first), Reset; Ctrl+S |
|
|
188
|
+
| `ApiForm` | `nexus-shared/client` | A `SubmitForm` that sends to an API (an endpoint such as `ROOMS.update`, `{ url, method }`, or the app's own client), loads the record first (`loadValue`), and shows the server's messages on their inputs: Nexus responses and ASP.NET Core problem details |
|
|
189
|
+
| `FormButtons` | `nexus-shared/client` | A form's status line and buttons on their own, such as in a popup's footer; `submitTone="danger"` for a save that removes something |
|
|
190
|
+
| `downloadText` | `nexus-shared/client` | Hands the user a file made from text in the browser (a CSV, recovery codes as a `.txt`), read as UTF-8 by Excel and Windows editors |
|
|
191
|
+
| `useForm`, `useFormValue`, `useFormValues`, `useFormStatus` | `nexus-shared/client` | A store for the page to hold, and hooks that render again when a value, any value, or the status changes |
|
|
192
|
+
| `FormStore` | `nexus-shared` | The store and the form's handle: `getValue`, `getChanges`, `set`, `load` (with `keepChanged`), `reset`, `validate`, `setErrors`, `focus`, `submit` |
|
|
193
|
+
| `DataTable`, `useTable` | `nexus-shared/client` | Rows with a search (rows per page inside it), sorting by column, pages, a selection with one split button of actions over the headings, icon buttons and a menu per row, columns the user orders, resizes, pins, and hides, a band of headings over the columns that belong together (`group`), an options menu (filters from `filterInputs`, CSV, print, import), and loading, empty, and failed states; rows given, loaded once, or a page per query from a server; page size, sort, and the columns remembered with `stateKey`; a picked row (`onPick`) for a list beside it, and a `caption` |
|
|
194
|
+
| `TableStore` | `nexus-shared` | The table's state and handle, outside React: `reload`, `upsert`, `patch`, `remove`, `select`, `getSelected`, `pick`, `setSearch`, `setFilters`, `setSort`, `setPage` |
|
|
195
|
+
| `Crud` | `nexus-shared/client` | A module's records in one component: a controller's endpoints, the table's columns, and the form's input params in; the lists (all, flagged, trash, deleted) and actions the user may use, the records the user pinned above the list, Add and Edit popups on `ApiForm`, details, actions on one record or many, and a cache of lists while the page is open; a list narrowed to the record picked in another (`narrow`); a list widened to other endpoints by a switch in its toolbar (`scopes`); row actions that open a form of their own for the record (`rowForms`) |
|
|
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
|
+
| `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
|
+
| `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` |
|
|
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 |
|
|
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 |
|
|
208
|
+
| `configureCrud`, `getCrudSettings` | `nexus-shared` | What every CRUD in the app follows: how many records a user may pin (100 by default) |
|
|
209
|
+
| `queryRows`, `filterRequest`, `formatCellText`, `sortRows`, `pageButtons`, `CrudCache`, `crudRowActions` | `nexus-shared` | Pure helpers behind the table and the CRUD: search, filter, sort, and page rows as the table does, the filters in a server's names, a value as its cell writes it, the page buttons, the cache, and which actions a list offers |
|
|
210
|
+
| `readApiResponse`, `readErrorMessages`, `sendApiRequest`, `matchFieldErrors`, `matchInputName`, `formErrorsFromResult` | `nexus-shared` | Pure helpers behind the API form: read an answer as success or failure, every problem it carries as one list, send a URL as it is, match server field names to inputs, and turn a failed answer into what a form shows |
|
|
211
|
+
| `api`, `apiOptionsLoader`, `configureApi` | `nexus-shared/client` | API calls from the browser, through the app's proxy or straight to a module: toasts worded by the message builder, `api.confirm` to ask first, one request for the same GET at once; a dropdown's `loadOptions` from a pagination endpoint |
|
|
212
|
+
| `serverApi`, `configureServerApi`, `createApiProxy` | `nexus-shared/server` | API calls from the server, straight to each module's root, with the user's token and Next.js caching; the proxy route the browser calls |
|
|
213
|
+
| `ApiModules`, `NexusApiModules`, `defineApiModules`, `ApiRegister` | `nexus-shared` | An app's API modules: its own module enum (named once in `ApiRegister`, so endpoints take only those), where each root is, and the proxy on or off. Extend `ApiModules` for another backend |
|
|
214
|
+
| `apiController`, `apiEndpoint`, `unwrapApiResult` | `nexus-shared` | Endpoints defined once by module: a controller's standard actions (`NormalListing`, `Detail/{id}`, `Create`, `Update`, `Delete/{id}`, each list's `lists.trash` and `pages.trash`, …); an answer's result, or an `ApiError` |
|
|
215
|
+
| `messages`, `buildMessage`, `configureMessages` | `nexus-shared` | Every success, failure, reason, loading, question, and button text from one table: "3 rooms deleted.", "You do not have permission to delete Room 204."; reworded all at once |
|
|
216
|
+
| `createApiClient` | `nexus-shared` | The engine behind `api` and `serverApi`, for an API of another shape |
|
|
217
|
+
| `Field` | `nexus-shared` | The frame of every input (label, box, icons, hint or error), for building custom inputs |
|
|
218
|
+
| `QrCodeImage` | `nexus-shared` | Any text as a QR code a phone can scan: one SVG path, black on white in every theme, 9rem wide (`nx-qr`); a server component can render it |
|
|
219
|
+
| `PrintDocument` | `nexus-shared` | A receipt, bill or statement by params (head, stamp, facts, lines, totals, footer) on A5 or an 80 mm roll; printed alone, black on white (`nx-print`) |
|
|
220
|
+
| `DocumentFrame` | `nexus-shared/client` | A whole HTML page a server wrote (a print layout's preview) in a sandboxed `<iframe srcdoc>` where no script runs: a bar with its status line and the page's buttons, slow and failed reads over the last page, `print()` through its handle (`nx-document-frame`) |
|
|
221
|
+
| `encodeQr`, `qrPath` | `nexus-shared` | The encoder behind it, written by hand (byte mode, versions 1 to 40, levels L, M, Q and H), and its modules as one SVG path |
|
|
222
|
+
| `validateText`, `TEXT_PATTERNS` | `nexus-shared` | Pure text validation and common patterns with their messages |
|
|
223
|
+
| `validateCheck`, `validateSelection` | `nexus-shared` | Pure checks for a checkbox and for chosen values (`required`, `min`, `max`) |
|
|
224
|
+
| `validateNumber`, `formatNumber`, `parseNumber` | `nexus-shared` | Pure number validation, and number text in any locale (`roundDecimal`, `stepNumber` too) |
|
|
225
|
+
| `formatDate`, `parseDateText`, `toIsoDate`, `validateDate` | `nexus-shared` | Pure date helpers: ISO dates in and out, either calendar, any format, Devanagari digits; `toCalendar` and `fromCalendar` convert AD and BS |
|
|
226
|
+
| `formatDateRange`, `parseDateRangeText`, `toDateRange`, `validateDateRange` | `nexus-shared` | The same for a range: "2026-09-17 – 2026-09-24" in and out, typed with a dash, an arrow, or "to"; rules on both ends and on the days between |
|
|
227
|
+
| `filterItems`, `matchesSearch`, `indexItems`, `optionKey`, `initials` | `nexus-shared` | Pure helpers behind the dropdown: search every word with or without accents, index items by value, compare ids from APIs and forms, and initials for avatars |
|
|
228
|
+
| `formatTime`, `parseTimeText`, `toIsoTime`, `validateTime` | `nexus-shared` | Pure time helpers: ISO times in and out, 12- or 24-hour formats, Devanagari digits, typed text on either clock; `addMinutes`, `isTimeInRange`, `listTimeSlots` |
|
|
229
|
+
| `toUtcDateTime`, `toLocalDateTime`, `fromLocalDateTime`, `formatDateTime`, `parseDateTimeText`, `validateDateTime` | `nexus-shared` | Pure date-time helpers: UTC values in and out, the date and time a moment shows in any IANA zone (daylight saving included), typed text in both calendars; `describeTimeZone`, `getBrowserTimeZone` |
|
|
230
|
+
| `ThemeScript` | `nexus-shared` | Applies the saved theme, mode, density and animations (and the scale grid in development) before paint; goes in `<head>` |
|
|
231
|
+
| `ThemeSwitcher` | `nexus-shared/client` | Palette button with the theme picker in a popover: mode, theme, density, animations, and the scale grid in development |
|
|
232
|
+
| `ThemePicker` | `nexus-shared/client` | Mode control and theme cards with live previews |
|
|
233
|
+
|
|
234
|
+
### Tab buttons
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
import { TabButtons } from "nexus-shared/client";
|
|
238
|
+
|
|
239
|
+
<TabButtons
|
|
240
|
+
aria-label="View"
|
|
241
|
+
value={view}
|
|
242
|
+
onChange={setView}
|
|
243
|
+
options={[
|
|
244
|
+
{ value: "grid", label: "Grid", icon: <InlineIcon icon={iconLayoutGrid} /> },
|
|
245
|
+
{ value: "list", label: "List", icon: <InlineIcon icon={iconList} /> },
|
|
246
|
+
]}
|
|
247
|
+
/>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- An indicator slides to the selected option. In light mode the track is a soft tint with no frame and the indicator
|
|
251
|
+
is lifted by a shadow; in dark mode the indicator is lighter than the track, with a visible edge. The selected icon
|
|
252
|
+
takes the primary color, so the choice is clear in every theme.
|
|
253
|
+
- `size` (`sm`, `md`, `lg`), `tone="primary"` for a solid primary indicator, `block` to fill the width, `disabled` per option.
|
|
254
|
+
- `labels="selected"`: icons only, and the selected option grows to show its label. Every option needs an icon;
|
|
255
|
+
hidden labels are still read by screen readers and shown as tooltips.
|
|
256
|
+
- `design="list"`: the options stacked as full-width rows, for settings pages and side panels. Each row takes an
|
|
257
|
+
`icon`, a `description` on a second line, and a `badge` (a count) at the end. The selected row is tinted, marked
|
|
258
|
+
by a bar at its start, and its label turns bold; `tone="primary"` tints it with the primary color. ↑ and ↓ move
|
|
259
|
+
the selection. The nav design is the old project's `nav-tabs`, this one its `list-tabs`.
|
|
260
|
+
- `multiple`: several at once. `value` is an array and `onChange` hands back the chosen values in option order.
|
|
261
|
+
Each chosen option is raised as the one choice's indicator is, the options wrap onto more rows when they do not
|
|
262
|
+
fit (never narrower than tall, so single letters line up), and on a touch screen each grows to a medium control's
|
|
263
|
+
height. They are checkboxes: Tab moves between them and Space toggles one.
|
|
264
|
+
- Native radio buttons: arrow keys move the selection, and `name` makes it part of a form. Needs `aria-label` or `aria-labelledby`.
|
|
265
|
+
`form` sets the form the choice belongs to, as in HTML; an id that matches no form keeps a switch that is not a
|
|
266
|
+
value (the Markdown editor's views) out of the form around it.
|
|
267
|
+
- For choosing a value. For switching page panels, use tabs with panels (planned).
|
|
268
|
+
|
|
269
|
+
### Split button
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
import { SplitButton } from "nexus-shared/client";
|
|
273
|
+
|
|
274
|
+
<SplitButton storageKey="pull-request" onAction={value => create(value)} options={[
|
|
275
|
+
{ value: "create", label: "Create pull request", description: "Open a pull request that is ready for review" },
|
|
276
|
+
{ value: "draft", label: "Create draft pull request", description: "Cannot be merged until marked ready for review" },
|
|
277
|
+
]} />
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- The main button runs the chosen action; the arrow opens the alternatives as a radio menu. Picking one makes it the
|
|
281
|
+
main button, and with `storageKey` the browser remembers it (`localStorage`, `nx-split:<key>`), so the next visit
|
|
282
|
+
starts with it. `runOnSelect` also runs the pick at once, for Save / Save and close.
|
|
283
|
+
- `variant`, `size`, `loading`, `disabled`, `block` as `Button`; `type="submit"` with `name` submits a form and sends
|
|
284
|
+
the chosen value; `value` and `onValueChange` for a controlled choice; `align` and `menuLabel` for the menu.
|
|
285
|
+
|
|
286
|
+
### Menus
|
|
287
|
+
|
|
288
|
+
```tsx
|
|
289
|
+
import { ContextMenu, Menu } from "nexus-shared/client";
|
|
290
|
+
|
|
291
|
+
<Menu items={[
|
|
292
|
+
{ value: "edit", label: "Edit", icon: <InlineIcon icon={iconPencil} />, shortcut: "Ctrl+E" },
|
|
293
|
+
{ separator: true },
|
|
294
|
+
{ value: "delete", label: "Delete", icon: <InlineIcon icon={iconTrash} />, tone: "danger" },
|
|
295
|
+
]} onSelect={value => run(value)}>
|
|
296
|
+
<Button iconEnd={<InlineIcon icon={iconChevronDown} />}>Actions</Button>
|
|
297
|
+
</Menu>
|
|
298
|
+
|
|
299
|
+
<ContextMenu items={rowItems} onSelect={value => run(value, row)}>
|
|
300
|
+
<table>…</table>
|
|
301
|
+
</ContextMenu>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
- **Native popovers.** The list is a `[popover]` element: it sits above everything, closes with Esc or a click
|
|
305
|
+
outside, and needs no library. It opens under its button (above it when there is no room), lined up with the
|
|
306
|
+
button's `align="start"` or `"end"` edge, and follows the page as it scrolls.
|
|
307
|
+
- **Items are params:** `{ value, label, description?, icon?, shortcut?, tone?: "danger", disabled?, href?, target?, checked?, title? }`,
|
|
308
|
+
plus `{ separator: true }` and `{ heading }`. An item with `href` is a link. The trigger is any element that
|
|
309
|
+
renders a button and passes its props on, such as `Button`.
|
|
310
|
+
- `kind="radio"` or `"checkbox"` adds a check column and `aria-checked`; `closeOnSelect={false}` keeps a checkbox
|
|
311
|
+
menu open while options are toggled.
|
|
312
|
+
- A `disabled` item fades its icon and label, not its `description`: give a switched-off item the reason it is off
|
|
313
|
+
there, and it is read at full strength. Descriptions wrap, even in a menu opened from a cell that does not.
|
|
314
|
+
- **A held item** - `disabled` with a `description` - says that reason when it is pressed (a click, Enter or Space),
|
|
315
|
+
in the note a read-only field and a table's held Add show (`.nx-field-notice`, a `status`), beside the item, and the
|
|
316
|
+
menu stays open; the arrow keys reach it, so the keyboard can press it too. The note goes with the menu. A CRUD's
|
|
317
|
+
locked actions (`rowLock`, `rowActionLock`, a row form's `lock`) are held items.
|
|
318
|
+
- **Keys:** arrows move between items (wrapping, skipping disabled ones without a reason), Home and End jump, a letter jumps to the
|
|
319
|
+
next item starting with it, Enter or Space chooses, Esc closes and returns focus to the button, Tab closes and
|
|
320
|
+
moves on. Focus follows a moving pointer, so the keys carry on from the hovered item.
|
|
321
|
+
- `ContextMenu` opens at the pointer on right-click, or under the focused element on Shift+F10. Its wrapper takes no
|
|
322
|
+
space (`display: contents`), so wrap a table once and note the clicked row in the row's own `onContextMenu`.
|
|
323
|
+
- A menu opens below its button, or above it when it fits only there, or on the side with more room when it fits on
|
|
324
|
+
neither, and is never taller than that room: its items scroll inside it, the scroll bar against the panel's right
|
|
325
|
+
wall. It opens scrolled to the top, and a hovered item never scrolls the list.
|
|
326
|
+
- `placeUnderAnchor`, `placeAtPoint`, and `followAnchor` (`nexus-shared/client`) place custom popovers the same way, and
|
|
327
|
+
`showPanel` opens one with its scroller at the top. A panel (`.nx-popover`) that marks its scrolling part
|
|
328
|
+
`.nx-popover__scroll` is capped to the room it was placed in and gets the scroll bar on its wall; set its padding with
|
|
329
|
+
`--nx-popover-pad`. `usePopoverPanel` is a panel a menu item opens under a button, as the table's Filters and Columns
|
|
330
|
+
open.
|
|
331
|
+
|
|
332
|
+
### Toast messages
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
import { Toaster, toast } from "nexus-shared/client";
|
|
336
|
+
|
|
337
|
+
// Once, in the root layout
|
|
338
|
+
<Toaster position="top-right" />
|
|
339
|
+
|
|
340
|
+
// Anywhere: components, services, fetch helpers
|
|
341
|
+
toast.success("Booking confirmed", "Room 204, 3 nights");
|
|
342
|
+
toast.error("Could not save", { message: error.message, action: { label: "Retry", onClick: save } });
|
|
343
|
+
toast.promise(saveBooking(), { loading: "Saving…", success: "Saved", error: e => `Not saved: ${e.message}` });
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
- `toast(title, options?)`, `toast.info`, `.success`, `.warning`, `.error`, `.loading` (spinner, no timer), and
|
|
347
|
+
`.promise`. Options: `message`, `duration` (default 5000 ms, 0 keeps it; pauses while hovered or focused), `action`,
|
|
348
|
+
`closable`, `icon`, `position`, `onClose`, and `id` to update a toast in place. `toast.dismiss(id?)` closes one or all.
|
|
349
|
+
- Each toast is a native popover of its own, so it sits above open dialogs; the newest is nearest the edge, and at
|
|
350
|
+
most `max` (default 5) show per position. Warnings and errors use `role="alert"`, the rest `role="status"`.
|
|
351
|
+
|
|
352
|
+
### Popup
|
|
353
|
+
|
|
354
|
+
```tsx
|
|
355
|
+
import { Popup, definePopupRules, openPopup } from "nexus-shared/client";
|
|
356
|
+
|
|
357
|
+
<Popup open={open} onClose={() => setOpen(false)} title="Edit booking" description="NX-2048 · Room 204"
|
|
358
|
+
footer={<><Button onClick={() => setOpen(false)}>Cancel</Button><Button type="submit" form="booking" variant="primary">Save</Button></>}>
|
|
359
|
+
<form id="booking" onSubmit={save}>…</form>
|
|
360
|
+
</Popup>
|
|
361
|
+
|
|
362
|
+
// From code, with a handle; needs <PopupHost /> in the layout. Never two popups with one key: opening the key again
|
|
363
|
+
// replaces its popup (a fresh one), or with reopen: "restore" brings it back as it was.
|
|
364
|
+
const popup = openPopup({ key: "room-edit-204", reopen: "restore", title: "Room 204", content: <RoomDetails id={204} />, footer: p => <Button onClick={() => p.close()}>Close</Button> });
|
|
365
|
+
popup.minimize(); popup.restore(); popup.maximize();
|
|
366
|
+
await popup.closed;
|
|
367
|
+
|
|
368
|
+
// Rules by key, once, in a client module; options given to openPopup win over them
|
|
369
|
+
definePopupRules({
|
|
370
|
+
"room-new": { reopen: "replace" }, // Add: a fresh form every time (the default)
|
|
371
|
+
"room-edit-*": { reopen: "restore" }, // Edit: back as it was, with what was typed
|
|
372
|
+
"night-report": { minimizable: false, maximizable: false, movable: false },
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// The page in charge of the window state
|
|
376
|
+
<Popup open={open} windowState={state} onWindowStateChange={setState} … />
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
- Built on the native `<dialog>` shown modally: above everything, the page behind dimmed and locked (without a layout
|
|
380
|
+
shift), Tab kept inside, focus returned on close. `onClose(reason)` says whether the close button, Esc, the
|
|
381
|
+
backdrop, or code asked; the page decides by setting `open`. `closable`, `closeOnEscape`, `closeOnBackdrop` turn
|
|
382
|
+
those off for a popup that must be answered.
|
|
383
|
+
- `size`: `xs` 360, `sm` 440, `md` 560, `lg` 720, `xl` 960, `full`. The body scrolls while the title and footer
|
|
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.
|
|
387
|
+
- Popups open other popups and message boxes above themselves; Esc closes the top one.
|
|
388
|
+
- A **title bar** like a desktop window's: 32px high (40px on touch screens), the icon, the title, and the description
|
|
389
|
+
on one line (cut with …), and the window buttons filling its top right corner, 46 x 32 each: **minimize** (only when it asks),
|
|
390
|
+
**maximize** (restore down when maximized), **close** (red on hover). `variant="dialog"` gives the larger heading
|
|
391
|
+
instead (the icon in a circle, the description under the title), which message boxes use. `minimizable` (default false: a popup has no minimize button unless it asks; a CRUD page's Add and Edit forms and its row forms ask, and a page's rule for their key may still say no; message boxes, viewers, galleries, histories, imports, record cards and the rest do not),
|
|
392
|
+
`maximizable` and `movable` (default true); message boxes have only close.
|
|
393
|
+
- **Minimized**, the dialog closes so the page can be used, and a bar waits at the bottom right with the popup's icon and
|
|
394
|
+
title; its content stays mounted, so typed text, scroll, and the focused field are there when the bar brings it back
|
|
395
|
+
(maximized again if it was). Several stack behind the newest with a count that opens the whole list (Esc or a press
|
|
396
|
+
elsewhere closes it). An open popup's backdrop covers the dock. `<PopupHost />` renders the dock; without a host
|
|
397
|
+
there is no minimize button. Bottom-right toasts stack above it (`--nx-dock-space`).
|
|
398
|
+
- **Maximized**, it fills the window edge to edge; a double-click on the title bar does the same, and restore down puts
|
|
399
|
+
it back where it was.
|
|
400
|
+
- **Moved** by its title bar (not its buttons), kept inside the window, pulled back in when the window shrinks; Esc
|
|
401
|
+
during a drag puts it back; dragging a maximized popup restores it under the pointer, as desktop windows do. Each
|
|
402
|
+
opening starts centered in `defaultWindowState`.
|
|
403
|
+
- **Opened again** while it is still open or minimized, a popup never doubles up. `openPopup` with a `key` whose popup
|
|
404
|
+
is there closes that popup (`onClose` and `closed` say `replaced`) and opens a fresh one: `reopen: "replace"`, the
|
|
405
|
+
default, right for an Add form, so Add, minimize, Add again cannot pile up popups. `reopen: "restore"` brings the
|
|
406
|
+
old one back as it was and returns its handle, right for editing one record. A page popup opens again through the
|
|
407
|
+
button that opened it, with the same `reopen` choice. Popups without a key are separate every time.
|
|
408
|
+
- **Asking before a close**: `openPopup({ beforeClose })` runs when the user closes the popup (its close button, Esc,
|
|
409
|
+
the backdrop, or its bar on the dock); return `false`, or a promise of it, to keep it open, such as after
|
|
410
|
+
`confirmDiscard(form)` asked "Discard your changes?". Closing from code does not ask.
|
|
411
|
+
- **Rules by key**: `definePopupRules({ "booking-new": {…}, "booking-edit-*": {…} })` sets `reopen`, `minimizable`,
|
|
412
|
+
`maximizable`, `movable`, `closable`, `closeOnEscape`, `closeOnBackdrop`, `size`, and `defaultWindowState` for every
|
|
413
|
+
`openPopup` with that key. A name ending in `*` matches keys that start with the rest (`*` alone, every key); all
|
|
414
|
+
matching rules apply, an exact key over a pattern and a longer pattern over a shorter one; options given to
|
|
415
|
+
`openPopup` win. It returns a function that removes the rules; `popupRuleFor(key)` shows what a key gets.
|
|
416
|
+
- With motion, minimize flies to the dock and maximize grows from the old box; with reduced motion, both happen in the
|
|
417
|
+
same frame.
|
|
418
|
+
|
|
419
|
+
### Drag helpers
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
import { clampToBounds, makeDraggable, moveWithin, useDraggable, viewportBounds } from "nexus-shared/client";
|
|
423
|
+
|
|
424
|
+
useDraggable(cardRef, { bounds: "parent" }); // the card moves, kept inside its positioned parent
|
|
425
|
+
useDraggable(barRef, { target: () => cardRef.current, bounds: "parent", axis: "x", keepVisible: 40 });
|
|
426
|
+
const stop = makeDraggable(titleBar, { target: panel, bounds: document.querySelector(".board") }); // without React
|
|
427
|
+
moveWithin(card, { x: 10, y: 0 }, { bounds: "parent" }); // the keyboard way
|
|
428
|
+
clampToBounds({ left: -20, top: 40, width: 300, height: 200 }, viewportBounds()); // { x: 0, y: 40 }
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
- The popup's title bar is built on these; any component can use them. `bounds`: `viewport` (default), `parent`,
|
|
432
|
+
an element (inside its borders), a box, a function, or `null`. `margin`, `keepVisible` (how much must stay
|
|
433
|
+
inside when it may partly leave), `axis`, `threshold` (default 3 px, so clicks stay clicks), `cancel` (controls in
|
|
434
|
+
the handle that do not drag).
|
|
435
|
+
- The position is an offset from the element's place in the layout, written to CSS `translate` by default, or to CSS
|
|
436
|
+
variables (`cssVariablePosition`), or anywhere through `position: { get, set }`, so a move never shifts the layout.
|
|
437
|
+
- `onStart` may cancel or change the target first; `onEnd` says whether Esc cancelled. A drag selects no text, and
|
|
438
|
+
the click that ends it does not reach what lies under the pointer. The handle needs `touch-action: none`
|
|
439
|
+
(`makeDraggable` and `useDraggable` set it).
|
|
440
|
+
|
|
441
|
+
### Message box
|
|
442
|
+
|
|
443
|
+
```tsx
|
|
444
|
+
import { messageBox } from "nexus-shared/client";
|
|
445
|
+
|
|
446
|
+
if (await messageBox.confirm({ title: "Delete booking NX-2048?", message: "This cannot be undone.", tone: "danger", confirmLabel: "Delete" })) …
|
|
447
|
+
await messageBox.alert({ title: "Saved", tone: "success" });
|
|
448
|
+
const name = await messageBox.prompt({ title: "Rename the room", label: "Name", defaultValue: room.name });
|
|
449
|
+
const choice = await messageBox.show({ title: "Unsaved changes", tone: "warning", buttons: [{ value: "discard", label: "Don't save" }, { value: "save", label: "Save" }] });
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
- Small popups through `<PopupHost />`, the tone in the icon: `info` (alert default), `question` (confirm default),
|
|
453
|
+
`success`, `warning`, `danger`, or `neutral` for none. A danger box makes the last button red and focuses the first
|
|
454
|
+
one, so Enter cannot delete by accident.
|
|
455
|
+
- `confirm` resolves `false` and `show` resolves `null` when the box is dismissed with Esc, the close button, or a
|
|
456
|
+
click outside; `prompt` resolves `null` when cancelled and refuses an empty answer while `required` (the default).
|
|
457
|
+
- `details` puts what the question is about after the message, in the normal text colour (the message is muted), and
|
|
458
|
+
scrolls with the body while the buttons stay: `details: <CrudDetails row={order} columns={ORDER_COLUMNS} />` with a
|
|
459
|
+
`size` of `lg` or `xl`. `closeOnBackdrop: false` keeps a box with much to read open through a stray click (Esc and ×
|
|
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.
|
|
464
|
+
|
|
465
|
+
### Button hover tones
|
|
466
|
+
|
|
467
|
+
`hoverTone` keeps a `secondary` or `ghost` button neutral until it is hovered or focused from the keyboard, then
|
|
468
|
+
colors its border, a light background, and its text. Tones: `primary`, `danger`, `success`, `warning`, `info`.
|
|
469
|
+
Good for rows of actions, where full-color buttons would be too loud:
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
<Button hoverTone="danger" icon={<InlineIcon icon={iconTrash} />}>Delete</Button>
|
|
473
|
+
<Button variant="ghost" size="sm" hoverTone="primary" aria-label="Edit" icon={<InlineIcon icon={iconPencil} />} />
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Every tone meets WCAG AA contrast on its light background in all themes.
|
|
477
|
+
|
|
478
|
+
### Text box
|
|
479
|
+
|
|
480
|
+
```tsx
|
|
481
|
+
import { TEXT_PATTERNS } from "nexus-shared";
|
|
482
|
+
import { TextBox, TextArea } from "nexus-shared/client";
|
|
483
|
+
|
|
484
|
+
<form onSubmit={save}>
|
|
485
|
+
<TextBox name="email" type="email" label="Email" required pattern={TEXT_PATTERNS.email} icon={<InlineIcon icon={iconMail} />} />
|
|
486
|
+
<TextBox name="password" type="password" label="Password" required minLength={8} />
|
|
487
|
+
<TextArea name="notes" label="Notes" maxLength={500} showCount autoResize />
|
|
488
|
+
<TextArea name="html" label="Layout HTML" rows={18} code />
|
|
489
|
+
<Button type="submit" variant="primary">Save</Button>
|
|
490
|
+
</form>
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
- **Uncontrolled by default**, so typing re-renders nothing. `onValueChange(value)` reports each change; `value` makes it controlled.
|
|
494
|
+
- **Code text: `TextArea code`** (in a form's inputs, `{ type: EInputTypes.TextArea, code: true }`). HTML or JSON in the
|
|
495
|
+
monospace font, lines kept as typed (`wrap="off"`: a long line scrolls sideways inside the box), a tab two spaces
|
|
496
|
+
wide, and no spellcheck, capitals, corrections, suggestions or translation. Tab still leaves the box, so a keyboard
|
|
497
|
+
is never trapped in it; read-only and disabled code keep the look.
|
|
498
|
+
- **Validation:** `required`, `minLength`, `maxLength`, `pattern` (a `TEXT_PATTERNS` entry carries its own message), and
|
|
499
|
+
`validate(value)` for custom rules. Messages show after a field is left with text in it (`validateOn`: `blur`, `change`,
|
|
500
|
+
`submit`). Inside a `<form>`, an invalid field blocks submit, every message shows, and the first invalid field takes focus.
|
|
501
|
+
`error` shows a message from outside, such as the server. `invalid` (on `Field` and `Dropdown`) draws the invalid look
|
|
502
|
+
with no message line, for a field the page says why about elsewhere: point its `aria-describedby` at those words.
|
|
503
|
+
- **Read-only says why.** Every input with `readOnly` shows a lock, and a short note for 2.5 seconds when someone tries
|
|
504
|
+
to change it anyway, so a click that does nothing does not look like a bug. The value is still sent with a form, and
|
|
505
|
+
selecting and copying text work as usual. Esc, leaving the field, or the timeout hide the note; `readOnlyMessage`
|
|
506
|
+
changes its text.
|
|
507
|
+
|
|
508
|
+
| Input | Lock | Tries that show the note | Default note |
|
|
509
|
+
|---|---|---|---|
|
|
510
|
+
| `TextBox`, `TextArea` | End of the box | A click, typing, Backspace, Delete, Ctrl+V, Ctrl+X (and Enter in a text area) | Read-only: this field cannot be changed. |
|
|
511
|
+
| `NumberBox` | In place of the − and + buttons | The same, and ↑ ↓ Page Up Page Down | Read-only: this number cannot be changed. |
|
|
512
|
+
| `DatePicker`, `DateRangePicker` | In place of the calendar button | The same as a text box, and ↓ | Read-only: this date (these dates) cannot be changed. |
|
|
513
|
+
| `TimePicker` | In place of the clock button | The same as a text box, and ↓ | Read-only: this time cannot be changed. |
|
|
514
|
+
| `DateTimePicker` | In place of the calendar and clock button | The same as a text box, and ↓ | Read-only: this date and time cannot be changed. |
|
|
515
|
+
| `Dropdown` | In place of the list button; chips lose their remove buttons | A click on the box or a chip, a typed key, ↓ or ↑ | Read-only: this choice (these choices) cannot be changed. |
|
|
516
|
+
| `CheckBox`, `Switch` | After the label | A click or Space | Read-only: this option (this setting) cannot be changed. |
|
|
517
|
+
| `CheckBoxGroup`, `RadioGroup` | After the group label | A click, Space, or an arrow key, under that option | Read-only: these options (this choice) cannot be changed. |
|
|
518
|
+
| `Slider`, `RangeSlider` | After the label | A press on the track or a mark, the arrow keys, Page Up/Down, Home, End | Read-only: this value (this range) cannot be changed. |
|
|
519
|
+
|
|
520
|
+
Nothing renders until an attempt, and an attempt re-renders only the note, so read-only forms of any size cost
|
|
521
|
+
nothing extra. `useReadOnlyNotice` gives a custom control the same lock and note.
|
|
522
|
+
- **Props are params:** plain, named like HTML attributes, and serializable, so forms and CRUD pages can describe
|
|
523
|
+
fields as objects. Design notes for inputs, forms, and CRUD: [src/Components/Inputs/README.md](src/Components/Inputs/README.md).
|
|
524
|
+
|
|
525
|
+
### Number box
|
|
526
|
+
|
|
527
|
+
```tsx
|
|
528
|
+
import { NumberBox } from "nexus-shared/client";
|
|
529
|
+
|
|
530
|
+
<NumberBox name="nights" label="Nights" stepper="split" required min={1} max={30} defaultValue={1} />
|
|
531
|
+
<NumberBox name="rate" label="Rate per night" prefix="Rs" required min={500} decimals={2} fixedDecimals grouping locale="en-IN" />
|
|
532
|
+
<NumberBox name="discount" label="Discount" suffix="%" stepper min={0} max={50} step={5} />
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
- **Only a number gets in.** Typing accepts digits, one decimal separator, and a minus sign when `min` is below 0 (or
|
|
536
|
+
not set). `decimals` stops typing after that many decimals; pasted text such as "Rs 1,250.50" keeps its number, and
|
|
537
|
+
text with no single clear number is ignored rather than guessed at.
|
|
538
|
+
- **Formats by locale:** `grouping` shows 12,50,000.00 (`en-IN`) or 1.250.000,00 (`de-DE`) while the box is not
|
|
539
|
+
being edited; separators come off on focus. Without a `locale`, the box follows the person's Number scale and
|
|
540
|
+
Thousands separator (`useNumberLocale`, see Quick preferences), which is `en-US` until they choose, so the server
|
|
541
|
+
renders that and the browser takes the saved choice right after hydration.
|
|
542
|
+
Inside a `<form>`, a grouped box sends the plain number through a hidden input with its `name`.
|
|
543
|
+
- **Steps:** ↑ and ↓ (Shift or Page Up/Down for ten), and `stepper` buttons that repeat while held. Values move along
|
|
544
|
+
the grid of `step` from `min` and stay within the limits.
|
|
545
|
+
- **Values:** `onValueChange(value)` gets a `number`, or `null` when empty; `validate(value)` too. `clamp` moves an
|
|
546
|
+
out-of-range value to the nearest limit on blur instead of showing a message.
|
|
547
|
+
- `npm run test:numbers` checks the number helpers (formatting, parsing, paste, rounding, stepping, messages).
|
|
548
|
+
|
|
549
|
+
### Checkbox and radio
|
|
550
|
+
|
|
551
|
+
```tsx
|
|
552
|
+
import { CheckBox, CheckBoxGroup, RadioGroup, Switch } from "nexus-shared/client";
|
|
553
|
+
|
|
554
|
+
<RadioGroup name="roomType" label="Room type" required design="cards" columns={3} options={roomTypes} />
|
|
555
|
+
<CheckBoxGroup name="amenityIds" label="Amenities" columns={3} selectAll options={amenities.map(a => ({ value: a.id, label: a.name }))} />
|
|
556
|
+
<Switch name="breakfast" label="Breakfast included" labelPosition="start" />
|
|
557
|
+
<CheckBox name="terms" label="I accept the booking terms" required />
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
- **Native inputs, drawn with CSS.** Each option is one `<input>`, so forms, arrow keys (radios), and screen readers
|
|
561
|
+
work as usual. Box edges use the subtle text color, which meets 3:1 contrast on panels in every theme.
|
|
562
|
+
- **Options are params:** `{ value, label, description?, icon?, disabled? }`. Values can be strings or numbers and
|
|
563
|
+
come back from `onValueChange` with their type.
|
|
564
|
+
- **Limits:** `required`, `min`, `max`, `validate`. At `max` a checkbox group disables the other options and shows a
|
|
565
|
+
count. Messages follow the text box: after leaving a changed group, or on submit, where the first invalid option takes focus.
|
|
566
|
+
- **Fast:** uncontrolled by default, so clicking re-renders nothing; 10,000 checkboxes mount in 0.5 to 0.7 s
|
|
567
|
+
in the development build.
|
|
568
|
+
|
|
569
|
+
**A choice drawn as tabs.** `<RadioGroup design="tabs" />` (or `design: "tabs"` in its params) draws the options as
|
|
570
|
+
nav-style tabs on one `TabButtons` track - still a radio group: arrow keys, `required`, read-only and disabled work as
|
|
571
|
+
in the other designs, and the tabs wrap on a phone. Each option's `description` is its tooltip and what a screen reader
|
|
572
|
+
hears with it; the chosen one's shows under the track.
|
|
573
|
+
|
|
574
|
+
```tsx
|
|
575
|
+
{ type: EInputTypes.RadioGroup, name: "counterType", label: "What is it?", required: true, design: "tabs", options: [
|
|
576
|
+
{ value: 0, label: "Payment", description: "Renew: the dates move, the money is taken now." },
|
|
577
|
+
{ value: 1, label: "Activate", description: "Switch the service on for a time, with no money." },
|
|
578
|
+
] }
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Slider
|
|
582
|
+
|
|
583
|
+
```tsx
|
|
584
|
+
import { RangeSlider, Slider } from "nexus-shared/client";
|
|
585
|
+
|
|
586
|
+
<Slider name="discount" label="Discount" max={50} suffix="%" defaultValue={10} />
|
|
587
|
+
<RangeSlider name="price" label="Price per night" min={1000} max={30000} step={500} minDistance={1000}
|
|
588
|
+
prefix="Rs " grouping locale="en-IN" onValueCommit={([from, to]) => loadRooms(from, to)} />
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
- **Native range inputs** under a drawn track, so arrow keys, Page Up/Down, Home/End, touch, and screen readers work.
|
|
592
|
+
Each thumb announces its formatted value (`aria-valuetext`).
|
|
593
|
+
- **Values read like a number box:** `prefix`, `suffix`, `decimals`, `grouping`, `locale`, or a `format` function.
|
|
594
|
+
- **`onValueChange`** follows every step; **`onValueCommit`** fires once when a thumb is released or a key step ends,
|
|
595
|
+
the moment to call an API from a filter.
|
|
596
|
+
- **Range:** two thumbs that never cross and keep `minDistance` apart; a click on the track moves the nearer thumb.
|
|
597
|
+
A form receives two values under the name.
|
|
598
|
+
- **Marks** label points under the track and move the thumb when clicked; **`tooltip`** shows the value above the thumb.
|
|
599
|
+
- Dragging writes the fill and the text straight to the DOM, so nothing re-renders.
|
|
600
|
+
|
|
601
|
+
### Date picker and date range picker
|
|
602
|
+
|
|
603
|
+
```tsx
|
|
604
|
+
import { todayIsoDate } from "nexus-shared";
|
|
605
|
+
import { Calendar, DatePicker, DateRangePicker, setDatePreferences } from "nexus-shared/client";
|
|
606
|
+
|
|
607
|
+
<DatePicker name="checkIn" label="Check-in" required min={todayIsoDate()} onValueChange={iso => …} />
|
|
608
|
+
<DatePicker name="fiscalYearStart" label="Fiscal year start" calendar="bs" format="YYYY/MM/DD" />
|
|
609
|
+
<DateRangePicker startName="checkIn" endName="checkOut" label="Stay" required min={todayIsoDate()} minDistance={1} maxDistance={30} />
|
|
610
|
+
<Calendar mode="range" months={2} range={range} onRangeChange={setRange} min={todayIsoDate()} />
|
|
611
|
+
|
|
612
|
+
// A settings page
|
|
613
|
+
setDatePreferences({ calendar: "bs", format: "D MMMM YYYY", script: "devanagari" });
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
- **The value is a day, not a time.** An ISO date `"2026-09-17"` in the Gregorian calendar, whatever calendar is
|
|
617
|
+
shown: what a form sends (through a hidden input with the field's `name`), what an API stores (`DateOnly`), and
|
|
618
|
+
what means the same day in every time zone. `toIsoDate` reads a `Date` in local time and an ISO date-time with a
|
|
619
|
+
zone as the local day of that instant; `isoDateToDate(iso)` gives UTC midnight, `isoDateToDate(iso, "local")` local midnight.
|
|
620
|
+
- **English or Nepali from the user's settings.** `useDatePreferences` reads `nx-date-calendar` (`ad` or `bs`),
|
|
621
|
+
`nx-date-format`, and `nx-date-script` (`latin` or `devanagari`) from local storage, with the English calendar in
|
|
622
|
+
`YYYY-MM-DD` as the default; `setDatePreferences` changes every date component on the page, and other tabs follow.
|
|
623
|
+
A box can fix its own `calendar`, `format`, or `script`.
|
|
624
|
+
- **Formats** use the tokens `YYYY` `YY` `MMMM` `MMM` `MM` `M` `DD` `D` `dddd` `ddd`. Typing follows the format's
|
|
625
|
+
order and is lenient: `17/9/2026`, `Sep 17, 2026`, `1 asoj 2083`, `२०८३-०६-०१`; a four-digit number is the year wherever it stands.
|
|
626
|
+
- **Bikram Sambat** covers BS 1975 to 2100 (AD 1918 to 2044), as the majority of five published tables
|
|
627
|
+
(`NepaliCalendar.ts`), converted through day numbers with no `Date` objects. `npm run test:dates` checks known
|
|
628
|
+
dates and round-trips every day.
|
|
629
|
+
- **DateRangePicker** holds a start and an end in one box, written as "2026-09-17 – 2026-09-24" in the user's
|
|
630
|
+
format. Pick the start and then the end in a calendar of two months (one when the window is narrow), with the
|
|
631
|
+
days between lit as the pointer moves, or type both dates around a dash, an arrow, or "to". The values are two
|
|
632
|
+
ISO dates: `onValueChange` gives `[start, end]` (`null` for an end not chosen yet), and a form sends `startName`
|
|
633
|
+
and `endName` as two fields, or both under `name`. Rules: `required`, `min`, `max`, `minDistance` and
|
|
634
|
+
`maxDistance` (days from start to end, the nights of a stay), and `isDateDisabled`, which also refuses a range
|
|
635
|
+
that includes such a day (a booked night). `validateDateRange` is the pure check behind it.
|
|
636
|
+
- **Read-only** boxes have no calendar: a lock sits where the calendar button was, and a click, a typed key, or ↓
|
|
637
|
+
shows a note under the box, as in every read-only input (see [Text box](#text-box)).
|
|
638
|
+
- **Calendar** is the pickers' panel and a component of its own: month and year menus, arrow keys (Page Up and Page
|
|
639
|
+
Down for months, with Shift for years), `min`, `max`, `isDateDisabled`, `weekStart`, `weekendDays` (Saturday is
|
|
640
|
+
red in the Nepali calendar), `mode="range"` with a live preview, `months={2}` side by side (a page passes 1 on
|
|
641
|
+
a phone; the calendar does not measure), `month` and `onMonthChange` to load a month's events, and `renderDay`
|
|
642
|
+
to draw markers in a cell.
|
|
643
|
+
|
|
644
|
+
### Time picker
|
|
645
|
+
|
|
646
|
+
```tsx
|
|
647
|
+
import { TimePicker, setDatePreferences } from "nexus-shared/client";
|
|
648
|
+
|
|
649
|
+
<TimePicker name="arrival" label="Arrival" required min="14:00" max="22:00" step={1800} view="list" onValueChange={iso => …} />
|
|
650
|
+
<TimePicker name="shiftStart" label="Shift start" min="22:00" max="06:00" format="HH:mm" />
|
|
651
|
+
<TimePicker name="spa" label="Spa slot" view="list" step={1800} isTimeDisabled={iso => booked.has(iso)} />
|
|
652
|
+
|
|
653
|
+
// A settings page
|
|
654
|
+
setDatePreferences({ timeFormat: "HH:mm" });
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
- **The value is a time of day, not an instant.** An ISO time on a 24-hour clock, `"14:30"` (`"14:30:15"` when
|
|
658
|
+
`step` has seconds), with no date and no zone: what a form sends through a hidden input with the field's `name`,
|
|
659
|
+
and what a .NET `TimeOnly` stores. `toIsoTime` reads `"9:05"`, `"14:30:00.000"`, a `Date` (its local time), and an
|
|
660
|
+
ISO date-time (with a zone, the local time of that instant; without one, its time part).
|
|
661
|
+
- **12 or 24 hours from the user's settings.** `nx-time-format` in local storage, next to the date settings, holds
|
|
662
|
+
a format of tokens `HH` `H` `hh` `h` `mm` `m` `ss` `s` `A` `a`; the default is `hh:mm A` (02:30 PM).
|
|
663
|
+
`useDatePreferences` returns it as `timeFormat`, and `setDatePreferences({ timeFormat })` changes every time picker
|
|
664
|
+
on the page. Nepali dates in Devanagari bring Nepali digits and पूर्वाह्न/अपराह्न. A box can fix its own `format` or `script`.
|
|
665
|
+
- **Typing** works on either clock: `2:30 pm`, `2.30p`, `230pm`, `14:30`, `1430`, `9`, `१४:३०`. Without AM or PM the
|
|
666
|
+
hours are read on a 24-hour clock. Leaving the box writes the time in the format.
|
|
667
|
+
- **Columns** (the default) show hours, minutes, seconds when the step needs them, and AM/PM; the chosen items line
|
|
668
|
+
up on the middle row, a pick in any column changes the time at once, and ↑ ↓ ← → Home End Page Up/Down move through
|
|
669
|
+
them. **`view="list"`** shows one slot per `step` (every 30 minutes for steps under 5 minutes); a click or Enter
|
|
670
|
+
picks and closes.
|
|
671
|
+
- **Rules:** `required`, `min` and `max` (with `max` earlier than `min` the range runs past midnight, 22:00 to 06:00),
|
|
672
|
+
`step` in seconds counted from midnight as in HTML, `isTimeDisabled` for booked slots, and `validate`. Items outside
|
|
673
|
+
the rules are dimmed in the panel; a typed time outside them gets a message such as "Arrival must be in steps of 30
|
|
674
|
+
minutes, like 02:00 PM or 02:30 PM." `validateTime` is the pure check behind it. `npm run test:times` checks the helpers.
|
|
675
|
+
- **Read-only** boxes have no panel: a lock sits where the clock button was, as in every read-only input.
|
|
676
|
+
|
|
677
|
+
### Date and time picker
|
|
678
|
+
|
|
679
|
+
```tsx
|
|
680
|
+
import { DateTimePicker } from "nexus-shared/client";
|
|
681
|
+
|
|
682
|
+
<DateTimePicker name="pickupAt" label="Airport pickup" required min={new Date()} step={900} minTime="05:00" maxTime="23:00" />
|
|
683
|
+
<DateTimePicker name="checkInAt" label="Check-in" timeZone="Asia/Kathmandu" defaultTime="14:00" />
|
|
684
|
+
<DateTimePicker name="spaAt" label="Spa slot" view="list" step={1800} isTimeDisabled={(time, date) => booked.has(`${date} ${time}`)} />
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
- **The value is a moment, in UTC.** `"2026-09-17T08:45:00.000Z"`, the shape `toISOString` writes, is what the hidden
|
|
688
|
+
input sends and `onValueChange` gives, whatever the user sees; the same moment is 02:30 PM in Kathmandu and 04:45 AM
|
|
689
|
+
in New York, so no time zone can move a saved value. Store it as UTC: a .NET `DateTimeOffset`, or a `DateTime` of
|
|
690
|
+
kind `Utc`. For a day with no time use `DatePicker`; for a time of day with no date, `TimePicker`.
|
|
691
|
+
- **Values in** (`toUtcDateTime`): an ISO date-time with `Z` or an offset is that moment; one **without a zone is read
|
|
692
|
+
as UTC**, the way a UTC `DateTime` loaded from a database often arrives without its `Z`; a date alone is the first
|
|
693
|
+
moment of that day where the user is; a `Date` or milliseconds are that moment. .NET's seven digits of fractions are read.
|
|
694
|
+
- **Shown in the user's zone and settings.** The browser's time zone, and the date (`nx-date-calendar`,
|
|
695
|
+
`nx-date-format`, `nx-date-script`) and time (`nx-time-format`) settings of the other pickers: "2026-09-17 02:30 PM",
|
|
696
|
+
or "२०८३-०६-०१ ०२:३० अपराह्न". The panel names the zone ("Kathmandu · UTC+05:45"). `timeZone` fixes a zone, such as a
|
|
697
|
+
hotel's, and writes its city in the box. The server does not know the user's zone, so a box shows its text right
|
|
698
|
+
after hydration rather than another zone's time for a moment; the hidden input holds the value from the start.
|
|
699
|
+
- **No data loss.** A value changes only when the user changes what it shows: seconds and milliseconds the box does not
|
|
700
|
+
show stay as they came (a stamp from the server is sent back exactly), and retyping or re-picking what it shows keeps
|
|
701
|
+
the moment. Where clocks go back, one hour happens twice: a box holds whichever it was given, an edit inside that hour
|
|
702
|
+
keeps its offset, and a new pick is the first of the two. Where clocks go forward, a skipped time moves on (02:30
|
|
703
|
+
becomes 03:30), as `Date` and Temporal do.
|
|
704
|
+
- **Typing:** a date in the user's format, then a time on either clock: `2026-09-17 2:30 pm`, `17/09/2026 14:30`,
|
|
705
|
+
`Sep 17, 2026 at 2:30 PM`, `2026-09-17T14:30`, `२०८३-०६-०१ १४:३०`. A date alone takes `defaultTime` (default
|
|
706
|
+
`minTime`, or midnight). Leaving the box writes it in the settings.
|
|
707
|
+
- **The panel** is the calendar and the time columns (or `view="list"` slots) side by side, as tall as each other, with
|
|
708
|
+
Now, the zone, and Done. A picked day keeps the time; ↓ opens it on the chosen day, Enter on a day moves on to the
|
|
709
|
+
hours, and Tab runs through the calendar, the columns, and the buttons. On a phone the time goes under the calendar,
|
|
710
|
+
and a panel taller than the room scrolls, moving to the time once a day is picked.
|
|
711
|
+
- **Rules:** `required`; `min` and `max` as moments (days outside are dimmed, and times outside on the first and last
|
|
712
|
+
day); `minTime` and `maxTime` as opening hours on every day (past midnight when `maxTime` is earlier); `step` in
|
|
713
|
+
seconds from each midnight; `isDateDisabled(date)` and `isTimeDisabled(time, date)` with ISO dates and times where the
|
|
714
|
+
user is; `validate`. Messages read like the box: "Airport pickup must be between 05:00 AM and 11:00 PM."
|
|
715
|
+
`validateDateTime(value, rules, fieldName, options)` is the pure check. `npm run test:datetimes` checks the helpers
|
|
716
|
+
against `Date` in eight zones, including half-hour daylight saving and a skipped day.
|
|
717
|
+
- **Read-only** boxes have no panel: a lock sits where the button was, as in every read-only input.
|
|
718
|
+
|
|
719
|
+
### Dropdown
|
|
720
|
+
|
|
721
|
+
```tsx
|
|
722
|
+
import { Dropdown } from "nexus-shared/client";
|
|
723
|
+
|
|
724
|
+
<Dropdown name="roomType" label="Room type" required options={roomTypes} />
|
|
725
|
+
<Dropdown name="amenities" label="Amenities" multiple max={6} options={amenities} /> // { value, label, icon: "wifi" }
|
|
726
|
+
|
|
727
|
+
// Rows like a data table
|
|
728
|
+
<Dropdown name="assignee" label="Assign to" options={staff} valueKey="id" labelKey="fullName"
|
|
729
|
+
columns={[{ key: "photo", type: "avatar" }, { key: "username", header: "Username" }, { key: "fullName", header: "Full name" }]} />
|
|
730
|
+
<Dropdown name="country" label="Country" options={countries} valueKey="code" labelKey="name"
|
|
731
|
+
columns={[{ key: "flag", type: "image" }, { key: "code", header: "Code", type: "code" }, { key: "name", header: "Country" }]} />
|
|
732
|
+
|
|
733
|
+
// A server list, editing a saved value
|
|
734
|
+
<Dropdown name="guestId" label="Guest" valueKey="id" labelKey="fullName" defaultValue={booking.guestId} selectedOptions={[booking.guest]}
|
|
735
|
+
loadOptions={({ search, skip, pageSize, signal }) => api.guests({ search, skip, take: pageSize }, signal)} /> // { items, total }
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
- **Options are any objects.** `{ value, label, description, icon, disabled }` by default (a `ChoiceOption`), or API rows
|
|
739
|
+
read through `valueKey`, `labelKey`, `descriptionKey`, and `iconKey`. Values keep their type; a number and its text
|
|
740
|
+
are the same value, so id 7 from an API matches "7" from a form. A form gets hidden inputs named `name`, one per value.
|
|
741
|
+
- **Columns** turn rows into cells that line up down the list (one grid with subgrid rows), with headings that stay on top.
|
|
742
|
+
Types: `text`, `muted`, `code`, `avatar` (a photo URL, or initials of the label on a soft tone), `image` (4:3: flags,
|
|
743
|
+
logos), `icon` (a Tabler name), and `money` (a figure with 2 places in the person's number format, at the end of its
|
|
744
|
+
column in even digits: a fee's price beside its name); `width`, `align`, and `render` for the rest. The first
|
|
745
|
+
picture leads the value in the box and in chips.
|
|
746
|
+
- **Search** matches every typed word in any order, with or without Latin accents, in the label, description, value, and
|
|
747
|
+
text columns (`searchKeys` to change). It is on with `loadOptions` or more than 8 options; without it, letters jump to
|
|
748
|
+
an option as in a native select. A local list renders 100 rows and adds more while it scrolls: 10,000 options open in
|
|
749
|
+
about 30 ms.
|
|
750
|
+
- **Server pages:** `loadOptions({ search, page, pageSize, skip, signal })` returns `{ items, total?, hasMore? }`. The first
|
|
751
|
+
page loads when the list opens (placeholder rows, a spinner in the box), the next near the end ("Loading more…"), a new
|
|
752
|
+
search 250 ms after typing stops, aborting the request before it. A failure shows Retry. `loadKey` drops the loaded
|
|
753
|
+
pages, for a list that depends on another field. `loading` shows the same loaders when the page fetches `options` itself.
|
|
754
|
+
- **Saved values the list can no longer offer** always show, and are never offered again once removed:
|
|
755
|
+
- on a server page not loaded: `selectedOptions` gives their items (a record usually carries the name),
|
|
756
|
+
`peekOption(key)` gives one the page already holds while it is fresh (no call; `crudPicker` gives it), or
|
|
757
|
+
`resolveOptions(values)` looks them up (a placeholder shows until it answers) - one the page marked old too;
|
|
758
|
+
- inactive items (`inactive: true` or `isActive: false`, or `isOptionInactive`): never in the list, shown only as a value;
|
|
759
|
+
- values missing from `options`: tagged **Unavailable**, or **Not found** when nothing is known but the value.
|
|
760
|
+
|
|
761
|
+
In the list they sit on top under **Selected** with their tag; in the box they carry the tag, or show as dashed chips.
|
|
762
|
+
A value only on another server page has no tag: a search finds it as a normal option.
|
|
763
|
+
- **Rules:** `required`, `min` and `max` for several (at `max` the other options are disabled), and `validate`, through
|
|
764
|
+
`validateSelection`. A pick follows the same message timing as typing. `npm run test:dropdown` checks the helpers.
|
|
765
|
+
- **Keys:** ↓ ↑ Page Up/Down Home End move past rows that cannot be picked, Enter picks, Space picks without search,
|
|
766
|
+
Backspace in an empty box removes the last value, Esc closes (then clears the search), Tab closes and moves on.
|
|
767
|
+
- **Read-only** boxes have no list: a lock sits where the list button was, chips lose their remove buttons, and a click or
|
|
768
|
+
a key shows the note, as in every read-only input.
|
|
769
|
+
|
|
770
|
+
### Markdown editor
|
|
771
|
+
|
|
772
|
+
```tsx
|
|
773
|
+
import { MarkdownView } from "nexus-shared";
|
|
774
|
+
import { MarkdownEditor } from "nexus-shared/client";
|
|
775
|
+
|
|
776
|
+
<MarkdownEditor name="description" label="Room description" defaultValue={room.description} defaultMode="split"
|
|
777
|
+
height={320} heightKey="room-description" maxLength={5000} required />
|
|
778
|
+
|
|
779
|
+
// Wherever the text is shown, also in a server component
|
|
780
|
+
<MarkdownView value={room.description} />
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
- **Write, split, and preview.** `defaultMode` (or `mode` with `onModeChange`) picks the view, and `modes` the views
|
|
784
|
+
offered. The switch is small tab buttons with `labels="selected"`: icons, and the label of the selected view. Split puts the preview beside the text and keeps the two scrolled together; under 560 px wide it stacks
|
|
785
|
+
them. The preview renders as a React transition, so typing stays within a frame in a 2,000-line document.
|
|
786
|
+
- **Toolbar and shortcuts:** heading, bold (Ctrl+B), italic (Ctrl+I), strikethrough (Ctrl+Shift+X), quote
|
|
787
|
+
(Ctrl+Shift+.), code (Ctrl+E), code block, link (Ctrl+K), image, bulleted, numbered, and task lists (Ctrl+Shift+8,
|
|
788
|
+
7, L), table, and horizontal line. Marks wrap the selection or the word at the caret and come off when applied
|
|
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.
|
|
793
|
+
- **While typing:** Enter continues a list (the next number, an empty task box) and ends it on an empty item; a URL
|
|
794
|
+
pasted over selected text links it; a task ticked in the preview is ticked in the text.
|
|
795
|
+
- **Height handle:** the status bar under the editor drags the height between `minHeight` and `maxHeight`, showing
|
|
796
|
+
the height as it changes; its grip takes ↑ ↓ (Shift for more), Page Up/Down, Home, and End; a double-click goes back
|
|
797
|
+
to `height`. `heightKey` remembers the user's height in local storage. Dragging writes straight to the DOM.
|
|
798
|
+
- **Full screen** (`fullscreen`, on by default) lifts the editor into the top layer over a dimmed page, without
|
|
799
|
+
moving it in the DOM, so undo history and the form stay. Tab stays inside, and Esc closes it.
|
|
800
|
+
- **A form field like `TextArea`:** `name`, `required`, `minLength`, `maxLength`, `pattern`, `validate`, the status bar's
|
|
801
|
+
word and character count, and read-only (a lock in the bar and a note under it). An invalid submit while the preview
|
|
802
|
+
shows brings the text back and focuses it. The view switch belongs to no form (`TabButtons` `form`), so it is never sent.
|
|
803
|
+
- **Safe to show anyone's text.** `parseMarkdown` (GitHub-flavored: tables, tasks, strikethrough, autolinks) never
|
|
804
|
+
renders raw HTML, and `safeUrl` keeps only http, https, mailto, tel, and relative addresses for links, and raster
|
|
805
|
+
data URLs for images. Links to other sites get `rel="nofollow noopener noreferrer"`.
|
|
806
|
+
- **Helpers:** `markdownToPlainText` for excerpts and search, and the editing commands `applyMarkdownTool`,
|
|
807
|
+
`continueMarkdownList`, `toggleMarkdownTask`, and `linkPastedUrl` for a custom editor. `npm run test:markdown`
|
|
808
|
+
checks the parser and every command.
|
|
809
|
+
|
|
810
|
+
### QR code
|
|
811
|
+
|
|
812
|
+
```tsx
|
|
813
|
+
import { encodeQr, qrPath, QrCodeImage } from "nexus-shared";
|
|
814
|
+
|
|
815
|
+
<QrCodeImage value={setup.otpauthUri} label="QR code to add your account to an authenticator app" />
|
|
816
|
+
const { version, size, mask } = encodeQr("https://nexus.example", "H");
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
- **What a camera reads:** black modules on a white square that holds the quiet zone, in every theme and in forced
|
|
820
|
+
colours. `nx-qr` makes it 9rem wide with a control's corners; the page's own class places it.
|
|
821
|
+
- **The encoder** is QR Code Model 2, written by hand: the text as UTF-8 in byte mode, the smallest of versions 1 to 40
|
|
822
|
+
that holds it at the level asked (L, M, Q, H restore about 7%, 15%, 25%, 30%), Reed-Solomon, and the eight masks scored
|
|
823
|
+
by the four penalty rules. A text past version 40 throws, saying how much the level holds (2,331 bytes at M).
|
|
824
|
+
- `npm run test:qr` checks its shape; `Project-Skills/Checks/qr-code-check.mjs` reads every code back with an independent
|
|
825
|
+
decoder (jsQR, installed outside the repository).
|
|
826
|
+
|
|
827
|
+
### Printable document
|
|
828
|
+
|
|
829
|
+
```tsx
|
|
830
|
+
import { formatMoney, PRINT_PAPERS, PrintDocument, type PrintPaper } from "nexus-shared";
|
|
831
|
+
|
|
832
|
+
<TabButtons<PrintPaper> aria-label="Paper" options={PRINT_PAPERS} value={paper} onChange={setPaper} size="sm" />
|
|
833
|
+
<Button variant="primary" size="sm" onClick={() => window.print()}>Print</Button>
|
|
834
|
+
<PrintDocument
|
|
835
|
+
paper={paper} // "a5" (default) or "thermal", an 80 mm roll
|
|
836
|
+
logo={billLogo ? { src: billLogo.url, alt: company.name } : undefined} // or any node; logoOnThermal={false} leaves it off a roll
|
|
837
|
+
aria-label={`Receipt ${receipt.number}`}
|
|
838
|
+
title={company.name}
|
|
839
|
+
subtitles={[branch.name]}
|
|
840
|
+
kind={onCredit ? "Bill on credit" : "Receipt"}
|
|
841
|
+
stamp={voided ? `Voided: ${reason}` : undefined}
|
|
842
|
+
blocks={[
|
|
843
|
+
{ facts: [{ label: "Receipt no.", value: receipt.number }, phone && { label: "Phone", value: phone }] },
|
|
844
|
+
{ columns: [{ label: "For" }, { label: "Amount" }], lines: [{ cells: [item, money(amount)], note: `Paid up to ${day}` }], totals: [{ label: "Total paid", value: formatMoney(net, { currency, locale }) }] },
|
|
845
|
+
]}
|
|
846
|
+
footer={settings.receiptFooter}
|
|
847
|
+
/>
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
- **Params, not markup.** A head (`title`, `subtitles`, `kind`), a `stamp` across it, `blocks` in order - facts (a
|
|
851
|
+
label and a value per line, the labels in one column) and lines (a table, the last column lined up at the end, a
|
|
852
|
+
`note` under a line's first cell, `totals` under a rule) - and a `footer` whose line breaks are kept. A falsy entry,
|
|
853
|
+
or one with `hidden: true`, is left out.
|
|
854
|
+
- **Printed alone.** On screen it reads in the theme's colours at its paper's width (A5: 128 mm of text; the roll: 72
|
|
855
|
+
mm). `window.print()` prints only the document, black on white, at the paper's edge, on the paper picked: the
|
|
856
|
+
component writes the page's `@page` rule while it is on the page. Any other page prints as it is. No PDF library.
|
|
857
|
+
- **A logo** (`logo`: `{ src, alt }`, or any node). On A5 it sits beside the title lines in a 24 mm column (at most 16
|
|
858
|
+
mm tall), the lines still centred, so the head grows no taller and a one-page receipt stays one page; on a roll, or
|
|
859
|
+
an A5 document shown narrower than 100 mm, it sits small and centred above them (at most 36 x 10 mm on a roll).
|
|
860
|
+
`logoOnThermal={false}` leaves it off a roll. Printed in black and white. An image must have loaded before printing.
|
|
861
|
+
|
|
862
|
+
### Document frame
|
|
863
|
+
|
|
864
|
+
```tsx
|
|
865
|
+
import { DocumentFrame, type DocumentFrameHandle } from "nexus-shared/client";
|
|
866
|
+
|
|
867
|
+
const frame = useRef<DocumentFrameHandle>(null);
|
|
868
|
+
<DocumentFrame
|
|
869
|
+
ref={frame}
|
|
870
|
+
title="Voucher preview" // the frame's title, its name to a screen reader
|
|
871
|
+
html={preview} // the server's whole page, <!DOCTYPE html> to </html>; null while none
|
|
872
|
+
loading={reading}
|
|
873
|
+
loadingMessage="Loading the preview…" // default "Loading…"
|
|
874
|
+
error={problem} // why it could not be shown
|
|
875
|
+
emptyMessage="Pick a layout." // default "Nothing to show yet."
|
|
876
|
+
printable // print() may open the dialog
|
|
877
|
+
actions={<Button onClick={() => frame.current?.print()}>Print</Button>}
|
|
878
|
+
/>
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
- **A page that is not this page's.** A server's whole document (its own `@page` sheet, screen style and CSP) goes
|
|
882
|
+
into an `<iframe srcdoc>`, never into this page's DOM or styles. **No script ever runs in it**: the sandbox is `""`
|
|
883
|
+
(an opaque origin; not even this page can reach in), or with `printable` `"allow-same-origin allow-modals"` - the
|
|
884
|
+
parent may call the frame's `print()`, and `print()` may open its dialog (Chrome ignores it without `allow-modals`).
|
|
885
|
+
Never `allow-scripts`, forms, popups or top navigation. A frame that becomes printable is made again, as a new
|
|
886
|
+
sandbox counts only from the frame's next page.
|
|
887
|
+
- **Its height is its container's**: it grows in a flex column (or stretches in a grid row) and is never under 22rem.
|
|
888
|
+
The page scrolls inside the frame; an A4 sheet wider than the frame scrolls sideways there, never the page.
|
|
889
|
+
- **A bar over the page**, the form's bar: the status line, and `actions` at its end, which wrap under the line on a
|
|
890
|
+
phone. It shows only with actions or something to say; out of sight it stays a live region.
|
|
891
|
+
- **States.** `loading` makes the page area `aria-busy` at once; a read that lasts 0.4s dims the page and says
|
|
892
|
+
`loadingMessage` (a quick one says nothing, held 0.3s once shown, as a CRUD's details footer). `error` is a line with
|
|
893
|
+
`role="alert"` over the last page, which stays in view. While loading or failed, an `html` of null keeps the last page.
|
|
894
|
+
A printable page read again keeps its scroll while the `title` stays the same.
|
|
895
|
+
- **`print()`** answers `false` when it cannot: not printable, no page yet, or the frame not loaded.
|
|
896
|
+
|
|
897
|
+
### Forms
|
|
898
|
+
|
|
899
|
+
```tsx
|
|
900
|
+
import { EInputTypes, TEXT_PATTERNS, type InputParams } from "nexus-shared";
|
|
901
|
+
import { ApiForm, SubmitForm } from "nexus-shared/client";
|
|
902
|
+
|
|
903
|
+
const ROOM_INPUTS: InputParams[] = [
|
|
904
|
+
{ type: EInputTypes.Text, name: "number", label: "Room number", required: true, pattern: TEXT_PATTERNS.digits, span: 4 },
|
|
905
|
+
{ type: EInputTypes.Number, name: "floor", label: "Floor", required: true, min: 0, span: 4 },
|
|
906
|
+
{ type: EInputTypes.Dropdown, name: "type", label: "Type", required: true, options: ROOM_TYPES, span: 4 },
|
|
907
|
+
{ type: EInputTypes.Tabular, name: "beds", label: "Beds", itemLabel: "Bed", inputs: BED_INPUTS }, // a list is an input too
|
|
908
|
+
];
|
|
909
|
+
|
|
910
|
+
<ApiForm
|
|
911
|
+
inputs={ROOM_INPUTS}
|
|
912
|
+
loadValue={signal => api.get(ROOMS.details, { params: { id }, signal, toast: false }).then(unwrapApiResult)}
|
|
913
|
+
api={ROOMS.update} // or in place: { module: NexusModule.Reservation, path: "Room/Update", method: "PUT" }
|
|
914
|
+
draftKey={`hotel-3:user-8:room:${id}`}
|
|
915
|
+
onSuccess={room => table.updateRow(room)}
|
|
916
|
+
onCancel={close}
|
|
917
|
+
/>
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
- **An input's `type` is a number**, a member of `EInputTypes` (since 4.0.0; a string is refused): text boxes 1–19
|
|
921
|
+
(`Text` 1, `Email` 2, `Password` 3, `Search` 4, `Tel` 5, `Url` 6, `TextArea` 7, `Code` 8), choices 20–39 (`CheckBox`
|
|
922
|
+
20, `Switch` 21, `Radio` 22, `CheckBoxGroup` 23, `RadioGroup` 24, `Dropdown` 25, one item or many by `multiple`),
|
|
923
|
+
dates and times 40–59 (`Date` 40, `DateRange` 41, `Time` 42, `DateTime` 43), numbers and sliders 60–79 (`Number` 60,
|
|
924
|
+
`Slider` 61, `RangeSlider` 62), editors 80–99 (`Markdown` 80, `RichText` 81) and structures 100–119 (`Tree` 100,
|
|
925
|
+
`Tabular` 101, `Group` 102, `Picture` 103). 0 is never a kind, and a number is never reused, so a database or a C# enum can hold it.
|
|
926
|
+
- **Params in, values out.** Every input is an entry of `inputs`, so a page, a CRUD component, or a server can describe a
|
|
927
|
+
form with plain objects. Values come in as each input reports them (a date-time from an API becomes a date, "12.5"
|
|
928
|
+
becomes 12.5), and fields the record came with (an `id`) stay in the values.
|
|
929
|
+
- **Fast at any size.** The values live in a `FormStore` outside React. Typing renders only the input typed in; an input
|
|
930
|
+
renders again only when a message it shows changes, or code sets its value. A keystroke costs about 5 µs in the store
|
|
931
|
+
for a form of 200 inputs, and a list of 10,000 rows is not checked again on each keystroke (it checks itself).
|
|
932
|
+
- **Checks on Save.** Each input's own rules (they also catch text that is not a date yet), the form's `validate` across
|
|
933
|
+
inputs, and rows not on the page. Every message shows, the first problem takes focus (a table scrolls to its row), and
|
|
934
|
+
the status line lists what needs attention, each name a link to its input. A rule message on an input shows once the
|
|
935
|
+
input was left, and on Save.
|
|
936
|
+
- **An input shown by another's value** (`showWhen`, Form only): `{ name: "counterType", in: [0, 1] }`, `notIn`, or
|
|
937
|
+
`filled: true | false`, or a list that must all hold (`[{ name: "counterType", in: [2] }, { name: "feeId", filled:
|
|
938
|
+
false }]`). The input comes and goes as the other changes, keeps what was typed, and is not checked while hidden; no
|
|
939
|
+
getter, no state in the page. `dependsOn` remains for anything else a getter reads (a `label`, `options`, a `max`).
|
|
940
|
+
- **An input read-only by another's value** (`readOnlyWhen`, Form only): the same conditions. A CRUD's Edit form holds
|
|
941
|
+
the record's key (`rowKey`, default `id`) and its Add form does not, so a field picked on Add and fixed once saved is
|
|
942
|
+
`readOnlyWhen: { name: "id", filled: true }`, with `readOnlyMessage` saying why. It shows its value (a dropdown its
|
|
943
|
+
saved label) with the lock and the note, sends it with the rest, and is not checked while read-only.
|
|
944
|
+
- **A label that follows another input** (`labelWhen`, Form only): `{ name: "registrationType", labels: { 1: "PAN
|
|
945
|
+
number", 2: "VAT number" }, otherwise: "PAN/VAT number", placeholders: { 1: "9 digits" } }`. The label (and the
|
|
946
|
+
placeholder, where listed) is the one for the other input's value as it is now, compared as text, else `otherwise`,
|
|
947
|
+
else its own; its messages name it the same way; the field draws again with no remount, keeping what was typed.
|
|
948
|
+
- **A width that follows a value** (`spanWhen: { when, span }`, Form only): `span` columns while every condition of
|
|
949
|
+
`when` holds (`showWhen`'s conditions; the input's own name follows its own typing), else its own `span`. A Bill date
|
|
950
|
+
that is the whole row until a date is put, then half with the switch that counts from it beside it, so nothing after
|
|
951
|
+
it moves: `span: 6, spanWhen: { when: { name: "billDate", filled: false }, span: 12 }`. The cell takes the new width
|
|
952
|
+
on the key that makes or unmakes the value, with no remount: the text, the caret, the focus and an open calendar
|
|
953
|
+
stay. A narrow form still gives every input the whole row.
|
|
954
|
+
- **An input worked out from others** (`compute: { from, value }`, Form only): shown, never typed - a price's
|
|
955
|
+
Calculated amount after Price, Discount and VAT. It is worked out when the form opens and whenever an input in `from`
|
|
956
|
+
changes (typed, set from code, loaded, reset), and is no form value: never sent, never checked, never a change,
|
|
957
|
+
never remembered or kept in a draft. Its box is an `<output>` on the read-only tint, in the person's number format,
|
|
958
|
+
with no lock, no steppers and no Tab stop; blank for null, "" or NaN, and while the values load. The form's
|
|
959
|
+
**`computeWith`** (state or a `useMemo` value, compared by identity) is what the page knows beside the values, such
|
|
960
|
+
as a server's preview: `compute: { from: [], value: (_values, line) => line?.netAmount }`.
|
|
961
|
+
- **The money presets**: `crudInputs.netAmount()` is that box ("Calculated amount", span 3, blank while the price
|
|
962
|
+
is empty); `...crudColumns.amounts()` gives a list Price, Discount, VAT and Calculated amount under "Amount
|
|
963
|
+
details"; `crudColumns.priceLadder(row, { locale })` gives a record's details a receipt's lines (Rate, Amount,
|
|
964
|
+
"Discount (5%)", "VAT (13%)", Calculated amount; a zero line left out). `PriceKeys` names other fields.
|
|
965
|
+
- **`priceAmounts({ price, quantity, discountPercentage, vatPercentage })`** is the Nexus server's money rule in the
|
|
966
|
+
browser: the amount, the discount off it, the VAT on the rest, each rounded half away from zero on exact decimals,
|
|
967
|
+
so a page's figure equals the server's to the paisa. `priceLadder(row, { locale })` gives the ladder as lines.
|
|
968
|
+
- **An input that remembers** (`remember: { key, viewer, preferred, per, when }`, a Form's input or a row input):
|
|
969
|
+
when the form's values leave it empty (a new record), it opens on the company's `preferred` value (a setting the page
|
|
970
|
+
reads), else on the person's last saved choice, kept in this browser under `nx-input:{key}:{viewer}` each time a form
|
|
971
|
+
holding it saves; each only while the input still offers it (a disabled or inactive option, or one gone, is dropped
|
|
972
|
+
without a word and a kept one forgotten), else its own `defaultValue`. It is the value the form opened with, not a
|
|
973
|
+
change; a form reset after a save opens on the new choice. No `viewer`, nothing kept.
|
|
974
|
+
`{ type: EInputTypes.RadioGroup, name: "paidBy", ..., remember: { key: "counter-paid-by", viewer: `${userId}|${companyId}`, preferred: settings.defaultPaymentMode } }`.
|
|
975
|
+
- **A picker** (`...crudPicker(...)`, options from `loadOptions`) confirms a kept value by its key before it opens -
|
|
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.
|
|
980
|
+
- **`per: "subscriptionGroupId"`** keeps one value per value of that input (`nx-input:{key}-{group}:{viewer}`): a
|
|
981
|
+
package per group. **`when: { name: "id", filled: false }`** remembers on a CRUD's Add only (Edit holds the key).
|
|
982
|
+
- **In rows** (a tabular or group form): a new row's input opens on the kept value (give its first choice as the
|
|
983
|
+
input's `defaultValue`, not in `newRow`, which wins), and a save keeps the value of the last row added.
|
|
984
|
+
- **Ask before saving** (`confirm`: SubmitForm, ApiForm, and a CRUD's `form.confirm`): after every value has passed its
|
|
985
|
+
checks, a question with what will be saved in full - `{ title: "Place this order?", message, details: (values, body) =>
|
|
986
|
+
({ row: body, columns, currency }), confirmLabel: "Place order", size: "xl", when }`. `body` is what is sent: an
|
|
987
|
+
ApiForm's `transform` runs once, before the question, and that very body is sent after Confirm. The details are drawn
|
|
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
|
|
991
|
+
was; a click outside leaves it open. While it asks nothing else submits and nothing says "Saving…"; a throw while it
|
|
992
|
+
is prepared shows under the form and asks nothing. `when` limits it to some saves.
|
|
993
|
+
- **One save at a time.** Save waits with a spinner; another click, Enter, or Ctrl+S does nothing until it ends. A save
|
|
994
|
+
makes the values the form's own ("Unsaved changes" clears; values typed meanwhile stay changes). `onSubmit` returns
|
|
995
|
+
`{ ok: false, errors, message }` to say why not, and a thrown error shows its message.
|
|
996
|
+
- **Server messages.** The server's own words are what the user reads. `ApiForm` shows **every** message a refusal
|
|
997
|
+
carried - a Nexus answer's `errorMessages` (a list, or one message object, or one message and its codes at the top
|
|
998
|
+
level) and ASP.NET Core problem details (`errors` by field) - each on the input its `inputName` names, matched without
|
|
999
|
+
case and by the first part of a path (`Addresses[1].City`). Names that match no input of this form, messages that name
|
|
1000
|
+
none, and failures without fields show beside the buttons; nothing is swallowed and nothing is shown twice. A sentence
|
|
1001
|
+
of ours by HTTP status ("Someone else changed this record…") is the fallback for an answer that explained nothing.
|
|
1002
|
+
`messageCode` and `errorCode` ride along in `answer.errorMessages` for the log and for `onError` to react to, and are
|
|
1003
|
+
never shown as the sentence. An input's message clears when its value changes.
|
|
1004
|
+
- **Nothing lost.** `draftKey` keeps unsaved changes in IndexedDB (after a 600 ms pause, and at once when the page hides
|
|
1005
|
+
or the form leaves) and brings them back with a note and Discard; drafts changed on the server since are named. Cancel
|
|
1006
|
+
asks before it drops changes, and the page asks before it closes with some (`confirmLeave`, on without a draft).
|
|
1007
|
+
The same form on the page also asks "Discard your changes?" before a navigation inside the app takes it away: every
|
|
1008
|
+
same-origin link (next/link or a plain `<a>`: a side menu, the brand, a trail, a menu's link row) is held in the
|
|
1009
|
+
capture phase while one has changes, and followed once Discard is chosen; Keep editing stays. A navigation of the
|
|
1010
|
+
app's own asks through `confirmPageLeave()` (`if (await confirmPageLeave()) router.push(href)`), as nexus-admin's
|
|
1011
|
+
module and account menus do; a move to the page in view asks nothing (`keepsPage(href)`), nor does a link inside text
|
|
1012
|
+
being edited. A form in a `PopupHost` popup is left out: the popup stays open through a navigation, and keeps its own
|
|
1013
|
+
close question. A confirmed navigation is not asked about again by the browser's close question. A move that loads a
|
|
1014
|
+
whole page (a sign-out, `location.assign`) asks with `confirmPageLeave({ unload: true })`: about every form, the
|
|
1015
|
+
popups' too, and once confirmed nothing asks again until the page goes (`endPageLeave()` if it did not).
|
|
1016
|
+
`form.load(values, { keepChanged: true })` refreshes a record without touching what the user changed.
|
|
1017
|
+
- **A save that removes something** (remove an app, turn a setting off, replace codes) says so on its button:
|
|
1018
|
+
`submitTone="danger"` fills it with the danger colour, as every solid danger fill is. `aria-describedby` on the form
|
|
1019
|
+
points at the line that says what goes, so a screen reader hears it when the focus lands straight in the password box.
|
|
1020
|
+
- `npm run test:forms` checks the store and the API helpers; `scripts/browser-checks/forms.cjs` in the template checks the
|
|
1021
|
+
three demo pages.
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
**A preview the server works out as the person types** (`useServerPreview`, `nexus-shared/client`): a counter's
|
|
1025
|
+
amount and dates, a layout's printed page. `request(values)` on each change asks a moment after the last one (`delay`,
|
|
1026
|
+
300 ms); an older question is aborted and its late answer never shown; `result` (and the `question` it answers) stays
|
|
1027
|
+
while a newer one runs and after one fails (`error`, the thrown message); `waiting` during the pause and `loading`
|
|
1028
|
+
while it is with the server; `cancel()` drops what waits or runs, `clear()` the answer too; leaving the page stops it.
|
|
1029
|
+
`ServerPreviewStore` is the same outside React.
|
|
1030
|
+
|
|
1031
|
+
```tsx
|
|
1032
|
+
const preview = useServerPreview(async (body: CounterBody, signal) => {
|
|
1033
|
+
const answer = await api.send<CounterPreview>(COUNTER_PREVIEW, { body, signal, toast: false });
|
|
1034
|
+
if (!answer.ok || !answer.result) throw new Error(answer.message ?? "This cannot be worked out just now.");
|
|
1035
|
+
return answer.result;
|
|
1036
|
+
});
|
|
1037
|
+
<Form inputs={inputs} onValueChange={values => (missingFor(values) ? preview.cancel() : preview.request(counterBody(values)))} />
|
|
1038
|
+
<DocumentFrame html={preview.result} loading={preview.loading} error={preview.error} /> // a layout editor's page
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
### API calls
|
|
1042
|
+
|
|
1043
|
+
```ts
|
|
1044
|
+
// api-modules.ts: the app's modules and where each one's root is (shared by the browser and the server)
|
|
1045
|
+
import { defineApiModules, NexusApiModules, NexusModule } from "nexus-shared";
|
|
1046
|
+
|
|
1047
|
+
export const MODULES = defineApiModules(new NexusApiModules({ useProxy: process.env.NEXT_PUBLIC_API_PROXY !== "false" }));
|
|
1048
|
+
export const ROOMS = MODULES.controller(NexusModule.Reservation, "Room", "room");
|
|
1049
|
+
|
|
1050
|
+
// In the browser
|
|
1051
|
+
import { api } from "nexus-shared/client";
|
|
1052
|
+
const rooms = await api.get<Room[]>(ROOMS.listing); // GET /api/app/reservation/Room/NormalListing
|
|
1053
|
+
await api.put(ROOMS.update, values, { subject: "Room 204" }); // toast: "Room 204 saved." or what failed and why
|
|
1054
|
+
await api.confirm(ROOMS.delete, { params: { id }, subject: "Room 204" }); // asks "Delete Room 204?" first
|
|
1055
|
+
|
|
1056
|
+
// On the server (a server component, a route handler, a server action)
|
|
1057
|
+
import { serverApi } from "nexus-shared/server";
|
|
1058
|
+
const list = await serverApi.get<Room[]>(ROOMS.listing, { revalidate: 60, tags: ["rooms"] }); // straight to API_RESERVATION
|
|
1059
|
+
|
|
1060
|
+
// app/api/app/[module]/[...path]/route.ts: the browser's way to every module
|
|
1061
|
+
export const { GET, HEAD, POST, PUT, PATCH, DELETE } = createApiProxy({ token: readAccessToken });
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
- **Modules are the app's.** `ApiModules<TModule>` is an abstract class over the app's own module enum: implement
|
|
1065
|
+
`modules` (each module's value, its key in proxy URLs, its label for messages, where its root comes from) and
|
|
1066
|
+
`serverRoot(module)` (from the environment, a settings service, anything; may be async). Register it once with
|
|
1067
|
+
`defineApiModules`, and define endpoints through it (`MODULES.controller`, `MODULES.endpoint`), so a file that calls
|
|
1068
|
+
an endpoint has registered the modules on either side. `NexusApiModules` is the Nexus set (roots from `API_<MODULE>`,
|
|
1069
|
+
as in the previous project; `NexusModule.None` for the app's own `/api` routes) and runs when nothing is registered.
|
|
1070
|
+
The list is checked: keys and values must be unique, and keys fit in a URL.
|
|
1071
|
+
- **`NexusModule`'s numbers are the backend's `EModules` flags**, and a permission is a bitmask of them, so a value
|
|
1072
|
+
that disagrees with the backend by one bit grants or denies the wrong module in silence. They changed after
|
|
1073
|
+
2.0.0: `Chat` took bit 6 and `Accounting` through `Portfolio` each moved up one. Code that uses the names needs
|
|
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.
|
|
1077
|
+
- **Module types from one place.** An app names its enum once, next to `defineApiModules`:
|
|
1078
|
+
`declare module "nexus-shared" { interface ApiRegister { module: HrModule } }`. Every endpoint, call, `ApiForm`,
|
|
1079
|
+
and CRUD then takes only those modules (`AppModule`), so `{ module: NexusModule.Reservation, … }` in an HR app is a
|
|
1080
|
+
type error. Without it, the modules are `NexusModule`.
|
|
1081
|
+
- **The proxy, on or off.** With `useProxy` (the default), the browser calls `/api/app/<module>/<path>` and the proxy
|
|
1082
|
+
route sends it on to the module's root with the user's token, which never reaches the browser. Off, the browser
|
|
1083
|
+
calls each module's `browserRoot` (public) and the proxy serves none. `proxies(module)` mixes the two, such as
|
|
1084
|
+
uploads straight to the Drive module and the rest through the proxy.
|
|
1085
|
+
- **The proxy hides the backend.** To the browser the app is the backend, and nothing in an answer names the real one:
|
|
1086
|
+
- **No redirect reaches the browser.** One that stays inside the module (the root's origin, or its host moved to
|
|
1087
|
+
https by the backend's HTTPS redirection, under the root's path in any casing) is followed on the server as
|
|
1088
|
+
`fetch` follows it: 307 and 308 keep the method and body, a 303 and a POST's 301 or 302 become a GET, up to
|
|
1089
|
+
`maxRedirects` (3). Any other answers 502 `BACKEND_REDIRECT_REFUSED`: following it would carry the token elsewhere.
|
|
1090
|
+
- **Response headers by an allowlist**, `DEFAULT_PROXY_RESPONSE_HEADERS` (Content-Type, Content-Disposition,
|
|
1091
|
+
Content-Language, Content-Range, Accept-Ranges, Cache-Control, Expires, ETag, Last-Modified, Vary, Retry-After),
|
|
1092
|
+
the Nexus backend's `X-Nexus-*`, and the names in `responseHeaders`. Server, X-Powered-By, X-AspNet-*,
|
|
1093
|
+
X-SourceFiles, Via, Alt-Svc, Strict-Transport-Security, Report-To, NEL, CORS, Location, Link and WWW-Authenticate
|
|
1094
|
+
never pass, even named; Set-Cookie only with `passCookies`. Every answer gets `X-Content-Type-Options: nosniff`,
|
|
1095
|
+
and `Cache-Control: private, no-store` unless the backend said how to cache it.
|
|
1096
|
+
- **Error pages replaced.** An error answer that is not JSON (an HTML or text page, a stack trace), JSON that is a
|
|
1097
|
+
stack trace or repeats the user's token, or one over 1 MB, becomes the failure envelope with the backend's status
|
|
1098
|
+
and plain words (`BACKEND_ERROR_PAGE`). JSON refusals (the Nexus envelope, problem details) and empty ones pass as
|
|
1099
|
+
they came, so the backend's own words still reach the form.
|
|
1100
|
+
- **The request.** The browser's own `X-Forwarded-*`, `Forwarded`, `X-Real-IP` and the like never go on, nor its
|
|
1101
|
+
cookies, a method or path override (`X-HTTP-Method-Override`, `X-Original-URL`) or Next.js's own headers. The
|
|
1102
|
+
proxy writes X-Forwarded-For (the last address the app received, which Next.js or the app's reverse proxy wrote;
|
|
1103
|
+
`clientAddress` reads it elsewhere; an IPv4 or IPv6 address or nothing, never other text) and X-Forwarded-Host
|
|
1104
|
+
and -Proto (the app's configured origin, else the request's). A module whose root is a path on the app (a pretend
|
|
1105
|
+
backend) is called at this server's own host and port, over http: the request URL's, which Next.js builds from
|
|
1106
|
+
where it listens. Never at the configured origin, so an app whose `origin` reads the Host header cannot let a client
|
|
1107
|
+
choose another port on the machine; `serverApi`, which has no request, uses that `origin`, so build it from
|
|
1108
|
+
configuration, or take the Host only when it names this server's own port. A body over `maxBodySize` (100 MB) is
|
|
1109
|
+
refused with 413 before the backend sees it, and a page of another site cannot call through the proxy
|
|
1110
|
+
(`Sec-Fetch-Site: cross-site`, other than a link followed) unless `allowCrossSite`.
|
|
1111
|
+
- **A server call can say who the client is too.** `lastForwardedAddress` and `readIpAddress` (`nexus-shared/server`)
|
|
1112
|
+
read the client's address as the proxy does, from anything with a `get` (Next.js's `headers()` included). The
|
|
1113
|
+
template's `api-setup.ts` gives Identity calls, and only them, `X-Forwarded-For` and the browser's `User-Agent`
|
|
1114
|
+
through `configureServerApi({ headers })`: sign-in leaves from the server, and Identity's rate limit counts per
|
|
1115
|
+
address. Only Identity's, because reading the request makes a page render per request.
|
|
1116
|
+
- **Refusals** are the Nexus envelope, `{ isSuccess: false, code, message, messageCode, errorCode, errorMessages }`
|
|
1117
|
+
(`failure` builds another backend's; `PROXY_REFUSALS` lists the codes), and the real cause goes to the server's
|
|
1118
|
+
log (`onProblem`, default `console.error`), never with the token or a query string.
|
|
1119
|
+
- **Two things the proxy cannot do for an app.** Node.js does not trust a development certificate (ASP.NET Core's)
|
|
1120
|
+
unless it starts with `NODE_USE_SYSTEM_CA=1`, so a module whose http root redirects to such an https port answers
|
|
1121
|
+
502 until then, and the log says so. And Next.js keeps an X-Forwarded-For a client sent when nothing stands in
|
|
1122
|
+
front of the app, so only behind a reverse proxy of its own is the client's address certain.
|
|
1123
|
+
- **One engine, two clients.** `api` and `serverApi` are two settings of `createApiClient`: it fills `{id}` in the path
|
|
1124
|
+
from `params` or the body, adds the query, headers, and token, waits up to `timeout` (60 s), tries a failed GET once
|
|
1125
|
+
more (a save never twice), and reads every answer as an `ApiResult` (`ok`, `status`, `result`, `message`,
|
|
1126
|
+
`errorMessages`, `errors`, `messageCode`, `errorCode`, `network`, `timeout`, `aborted`). A failed call is an answer,
|
|
1127
|
+
never a throw; an aborted one shows nothing. Next.js's own signals (`headers()` while prerendering, `redirect()`) pass
|
|
1128
|
+
through untouched.
|
|
1129
|
+
- **What the server said wins.** A refusal is shown in the backend's own words whenever it sent any:
|
|
1130
|
+
`{ isSuccess, code, errorMessages: [{ message, messageCode, errorCode, inputName }] }` becomes
|
|
1131
|
+
`result.errorMessages` in that order (one message object instead of a list, a single `errorMessage`, and an older
|
|
1132
|
+
answer's one message at the top level all read into the same list), `result.errors` holds the ones that name an input,
|
|
1133
|
+
and `result.message` is all of them joined for a toast. `messageCode` and `errorCode` are for the log and for a page to
|
|
1134
|
+
react to - never the sentence a user reads. The messages built from the HTTP status are the fallback for an answer that
|
|
1135
|
+
explained nothing.
|
|
1136
|
+
- **Answers of another shape.** Nexus answers and ASP.NET Core problem
|
|
1137
|
+
details are read out of the box. A backend that answers its own envelope registers one reader for the whole app:
|
|
1138
|
+
|
|
1139
|
+
```ts
|
|
1140
|
+
// Reads { success, message, data, error: { code, message, fields } } — the answer becomes ok, result, message, errors
|
|
1141
|
+
const read: ApiResponseReader = (body, status) => {
|
|
1142
|
+
const answer = body as BackendAnswer | null;
|
|
1143
|
+
if (answer?.success) return { ok: true, status, result: answer.data, message: answer.message };
|
|
1144
|
+
const fields = Object.entries(answer?.error?.fields ?? {}).map(([name, list]) => [name, list.join(" ")]);
|
|
1145
|
+
return { ok: false, status, message: answer?.error?.message, messageCode: answer?.error?.code, errors: Object.fromEntries(fields) };
|
|
1146
|
+
};
|
|
1147
|
+
configureApi({ read }); // the browser
|
|
1148
|
+
configureServerApi({ read }); // and the server, so both sides read one envelope
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
It is the fallback of every call that does not bring a `read` of its own, which is what the components that call
|
|
1152
|
+
inside themselves need: a `Crud`'s lists and actions and an `ApiForm`'s save have no place to pass one. A call's own
|
|
1153
|
+
`read`, and an `ApiForm`'s `readResponse`, still win.
|
|
1154
|
+
- **Toasts in the browser.** Errors always, successes for POST, PUT, PATCH, and DELETE; the same failure twice at once
|
|
1155
|
+
shows once; `toast: false`, `true`, or `{ success, error, loading }` per call; `configureApi({ toast, headers, read,
|
|
1156
|
+
onSignedOut })` for the app. The server shows nothing and logs network failures, timeouts, and 5xx.
|
|
1157
|
+
- **One message builder.** `buildMessage(kind, { action, subject, count, status })` words every message, and
|
|
1158
|
+
`messages.success`, `.failure`, `.reason`, `.loading`, `.confirm`, `.label`, and `.forResult` are short ways to call
|
|
1159
|
+
it: "3 rooms deleted.", "Could not move Room 204 to the trash.", "You do not have permission to delete Room 204.". The
|
|
1160
|
+
texts and action words are one table: `configureMessages` rewords or translates all of them at once, and an action
|
|
1161
|
+
of the app's own is its words (`{ verb: "check in", done: "checked in", doing: "Checking in", object: "the guest" }`).
|
|
1162
|
+
- **A controller's lists.** `apiController` names the lists as the Nexus backend does: `listing` and `pagination` are the
|
|
1163
|
+
active records' (`NormalListing`, `NormalPagination`), and `lists.flag`, `lists.trash`, `pages.trash`, … the others
|
|
1164
|
+
(`FlagListing`, `TrashListing`, `TrashPagination`, …), for the CRUD component.
|
|
1165
|
+
- `npm run test:api` checks the builder, routes, modules (another app's enum, the proxy on and off), the engine, the
|
|
1166
|
+
server client, and the proxy's routing; `npm run test:proxy` checks how the proxy hides the backend (redirects,
|
|
1167
|
+
headers, error pages, forwarded headers, paths, the body limit, other sites, the log) against a fake backend;
|
|
1168
|
+
`scripts/browser-checks/api-calls.cjs` in the template checks `/demo/api`.
|
|
1169
|
+
|
|
1170
|
+
### Data table
|
|
1171
|
+
|
|
1172
|
+
```tsx
|
|
1173
|
+
import type { TableColumn } from "nexus-shared";
|
|
1174
|
+
import { DataTable, useTable } from "nexus-shared/client";
|
|
1175
|
+
|
|
1176
|
+
const COLUMNS: TableColumn<Booking>[] = [
|
|
1177
|
+
{ key: "no", label: "Booking", width: "7rem", movable: false, pinnable: false }, // stays first, and stays put
|
|
1178
|
+
{ key: "guest.name", label: "Guest" }, // a path into the row
|
|
1179
|
+
{ key: "amount", label: "Amount", format: "money", prefix: "Rs. ", group: "Amount details" }, // number, money, percent, date, …
|
|
1180
|
+
{ key: "due", label: "Due", format: "money", prefix: "Rs. ", group: "Amount details" }, // one heading over both: the header is two rows
|
|
1181
|
+
{ key: "status", label: "Status", options: [{ value: 1, label: "Confirmed", tone: "success" }] }, // a tag
|
|
1182
|
+
{ key: "paid", label: "Paid", format: "boolean", resizable: false }, // keeps its width
|
|
1183
|
+
{ key: "created", label: "Booked", format: "date-time", hidden: true }, // shown from the Columns menu
|
|
1184
|
+
];
|
|
1185
|
+
|
|
1186
|
+
const table = useTable<Booking>();
|
|
1187
|
+
<DataTable table={table} aria-label="Bookings" columns={COLUMNS} rows={bookings} selectable massActions={MASS} rowActions={row => ACTIONS(row)} stateKey="bookings" />
|
|
1188
|
+
<DataTable columns={COLUMNS} paging="server" load={(query, signal) => fetchPage(query, signal)} /> // { search, sort, page, pageSize, filters }
|
|
1189
|
+
<DataTable columns={COLUMNS} rows={bookings} onImport={askForFile} moreActions={[{ id: "report", label: "Monthly report", onSelect: open }]} />
|
|
1190
|
+
|
|
1191
|
+
table.upsert(saved); table.patch(7, { paid: true }, { flash: true }); table.remove([7, 8]); await table.reload();
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
- **Columns are params**, one plain object each, as inputs are: `key` (a field or a path), `label`, `group`, `format`,
|
|
1195
|
+
`decimals`, `prefix`, `suffix`, `currency`, `options` (labels for an enum's values; a `tone` draws a tag with a dot of its color),
|
|
1196
|
+
`value`, `render`, `text`, `width`, `minWidth`, `align`, `wrap`, `sortable`, `sortKey`, `searchable`, `hidden`,
|
|
1197
|
+
`hideable`, `movable`, `resizable`, `pinnable`, `pinned`. Numbers line up at the end in tabular figures; dates and
|
|
1198
|
+
times follow the user's settings; moments show in the user's zone once the page has hydrated; long text ends in … at
|
|
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.
|
|
1201
|
+
- **Money in the company's currency.** `<DataTable currency="Rs." />` (a `Crud`'s `table.currency`) writes every
|
|
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
|
|
1203
|
+
column's own `currency` (a text, or `row => row.currencyText`) wins. `formatMoney(value, { currency, locale })` writes
|
|
1204
|
+
the same anywhere else, a dashboard's figure or a receipt's total.
|
|
1205
|
+
- **Columns in bands.** `group` is the heading above a column (`{ key: "amount", label: "Total", group: "Amount
|
|
1206
|
+
details" }`); the columns next to each other that name the same one share it, and the header reads in two rows, the
|
|
1207
|
+
bands on top and the columns' own headings under them. A column with no group keeps one heading as tall as both rows,
|
|
1208
|
+
and so do the numbers and checkbox column and the actions. A band is a heading and nothing more: it sorts nothing, has
|
|
1209
|
+
no menu and no edge to drag, and the columns stay one flat list, so hiding, ordering, widths, pins and the export are
|
|
1210
|
+
as they were. The bands are read off the order on screen: a column dragged out of its run takes its band's heading
|
|
1211
|
+
with it, and the run it left draws that heading again. With no group anywhere the header is one row.
|
|
1212
|
+
- **Rows three ways.** Given (`rows`) or loaded once (`load`), the table searches, sorts, and pages them itself: every word
|
|
1213
|
+
must match, in any column, with or without accents, and 10,000 rows search in about 0.5 ms a keystroke. A page at a time
|
|
1214
|
+
(`load` with `paging="server"`), each search (after 300 ms without typing, or Enter), sort, and page is one request; a
|
|
1215
|
+
newer query stops the one before, so a late answer never shows. A loader may answer without a promise (a cache), and
|
|
1216
|
+
the rows then paint with the table. `loadKey` starts over for another list behind the same table.
|
|
1217
|
+
- **What it shows while it waits.** Placeholders in the shape of the rows for the first rows; afterwards the rows stay,
|
|
1218
|
+
with a thin bar under the headings, and dim only if a load takes a moment. A failed load says why, with Retry; an empty
|
|
1219
|
+
list has its own message and action; a search or filters that find nothing offer Clear search or Clear filters.
|
|
1220
|
+
- **The columns are the user's.** Drag a heading to another place, drag its right edge for its width (double-click the
|
|
1221
|
+
edge, or "Fit to content", to fit it, never under the column's `minWidth`), and use the caret on the heading to sort,
|
|
1222
|
+
pin it left or right, move it, or hide it; the Columns menu shows and hides them. A column opts out with
|
|
1223
|
+
`movable`, `resizable`, or `pinnable: false`, and `pinned` starts it on a side. The table itself can refuse all three
|
|
1224
|
+
(`reorderable`, `resizable`, `pinnable`). Once a width is set the columns are laid out from those widths, so one edge
|
|
1225
|
+
moves alone; "Reset columns" in the options menu puts everything back.
|
|
1226
|
+
- **The bar** is one line on a wide table: the Add section (`add`, then the page's `actions`) and the lists
|
|
1227
|
+
(`toolbar`) on the left; Reload, the options menu (⋮) and the search, rows per page inside it, on the right. A
|
|
1228
|
+
narrower table moves whole parts, never a lone button, by its own width (a container query, so a table in a narrow
|
|
1229
|
+
panel does the same): under 670px the search takes the top line (with nothing on the left, it leads the buttons'
|
|
1230
|
+
line instead), under 375px Reload moves into ⋮, and under 325px, beside lists, the Add section takes the last line,
|
|
1231
|
+
its buttons sharing it. On the admin shell, whose menu leaves the screen at 900px, those are windows of 695, 400 and
|
|
1232
|
+
350px. A bar wider than those widths expect (a long Add label, the page's own buttons, cozy density) takes the next
|
|
1233
|
+
step as soon as a line would break (`data-squeeze`). Each item of the Add section keeps its own width on a shared
|
|
1234
|
+
line (a switch's label never breaks between its words, nothing runs into the lists); on the line the section takes
|
|
1235
|
+
of its own its items share it, and one that would not fit its share takes a line of its own, whole. A `Crud`'s lists
|
|
1236
|
+
name every tab from 900px and only the chosen one below.
|
|
1237
|
+
- **One options menu (⋮)**: **Filters** (`filterInputs`, input params as a form takes them, each named after the
|
|
1238
|
+
column it filters: a small form under ⋮, applied as it changes; a server gets them flat by name, see `filterRequest`),
|
|
1239
|
+
the columns shown as a CSV (`exportable`, named by `exportName` or the table's label), a
|
|
1240
|
+
print view of the same in a frame of its own (`printable`), Import (`onImport`), any `moreActions` of the page, and
|
|
1241
|
+
Reload, last, on a table too narrow for its button. A client table writes every row the search left; a server table
|
|
1242
|
+
writes the page it has, and says so.
|
|
1243
|
+
- **Selection.** The first column counts the rows (`numbered`, on by default when `selectable`) and turns into the row's
|
|
1244
|
+
checkbox under the pointer; once anything is selected every row shows its checkbox and the heading shows the box that
|
|
1245
|
+
selects the page (partly checked when some are). Shift+click selects a range, up to `maxSelection` (500). While rows
|
|
1246
|
+
are selected, a bar with the count, one split button holding every `massAction`, and Clear covers the headings, so
|
|
1247
|
+
nothing moves: the menu makes the action picked the button, and the next click runs it, with a spinner while it does.
|
|
1248
|
+
- **Row actions**: `inline` ones as icon buttons, the rest in the row's menu; while one runs (a promise), the row is busy.
|
|
1249
|
+
`onRowOpen` for a double-click. A row added or saved is highlighted for a moment (steadily, with reduced motion).
|
|
1250
|
+
- **A picked row** (`onPick`), for a page that shows the picked one's records beside the table. A click picks a row, and
|
|
1251
|
+
a double-click still opens it. The keyboard reaches the rows through a radio button each, out of sight in the first
|
|
1252
|
+
column and named with the row: Tab to the picked one, and the arrow keys move the pick.
|
|
1253
|
+
- The picked row wears a selected row's tint, and a ring round it while its radio has the focus.
|
|
1254
|
+
- The pick is the store's (`TableState.picked`, `table.pick(row)`) and follows the record: it is kept through a search
|
|
1255
|
+
or another page, handed over anew after an edit, and let go once the record leaves the rows.
|
|
1256
|
+
- **`caption`**: a line inside the frame, over the bar, saying what the rows are. **An Add that waits**:
|
|
1257
|
+
`add.disabled` with a `description` stays in the Tab order, and a press shows why in a note under it, unless
|
|
1258
|
+
`add.onDisabledPress` answers it first (returns true): a CRUD's narrowing box takes the cursor instead.
|
|
1259
|
+
- **A pinned section.** `pinnedRows` puts rows in a band above the page with a button that folds them open and shut
|
|
1260
|
+
(`pinnedLabel`, `pinnedMax` beside the count, `pinnedLoading` while they come); with a `stateKey` the choice is
|
|
1261
|
+
remembered on the device. They are the same records as in the list below, and each wears a pin where the rows of the
|
|
1262
|
+
page show their number.
|
|
1263
|
+
- **A `stateKey` that changes** (a `Crud` scope's list) reads that key's settings as the first draw does - its page
|
|
1264
|
+
size, sort, hidden columns, order, widths and pins, each column's own default where nothing is kept - and the
|
|
1265
|
+
settings on screen are never written under the new key.
|
|
1266
|
+
- **Wide tables** scroll inside their frame, with the numbers and checkbox column on the left, the actions on the right,
|
|
1267
|
+
any pinned columns beside them, and the headings on top staying in place, and a soft shadow where columns scroll under
|
|
1268
|
+
them. On a narrow table the pinned columns give way: the columns that stay take at most 60% of the width on screen
|
|
1269
|
+
(`PINNED_SHARE`), pins kept from the outer edge in, and the others scroll with the rest - keeping their side and their
|
|
1270
|
+
menu's Unpin - until there is room again. Under the table: "Showing 41–50 of 234" (and how many the search and filters left out) on the left, "Page 5 of
|
|
1271
|
+
24" and the pager on the right; rows per page sits inside the search box. At 520px and less the page buttons go.
|
|
1272
|
+
- **Where the paging lives** (`pager`). `"bar"` (the default) is the above. `"footer"` puts rows per page beside the
|
|
1273
|
+
pager, so a table with nothing else on top (no search, no ⋮, no lists) draws no bar. `"none"` is for a short list
|
|
1274
|
+
read whole, such as a record's notes (at most 50): every row on one page, no pager, no rows per page, and no footer
|
|
1275
|
+
unless a search or the filters leave rows out; the count is still read to a screen reader. With `searchable={false}`,
|
|
1276
|
+
`columnsMenu={false}`, `exportable={false}` and `printable={false}` such a table is its rows alone, in its frame. A
|
|
1277
|
+
server's pages cannot all be shown, so `paging="server"` takes `"none"` as `"footer"`. A `Crud` passes it as
|
|
1278
|
+
`table: { pager }`.
|
|
1279
|
+
|
|
1280
|
+
```tsx
|
|
1281
|
+
<DataTable aria-label="Notes" columns={NOTE_COLUMNS} rows={notes} pager="none" searchable={false} columnsMenu={false} exportable={false} printable={false} />
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
- `npm run test:table` checks the helpers, the store, and the CRUD rules; `scripts/browser-checks/data-table.cjs` in the
|
|
1285
|
+
template checks `/demo/data-table`.
|
|
1286
|
+
|
|
1287
|
+
### CRUD
|
|
1288
|
+
|
|
1289
|
+
```tsx
|
|
1290
|
+
import { Crud } from "nexus-shared/client";
|
|
1291
|
+
|
|
1292
|
+
export const GUESTS = MODULES.controller(NexusModule.Reservation, "Guest", "guest"); // every list and action
|
|
1293
|
+
|
|
1294
|
+
<Crud<Guest>
|
|
1295
|
+
api={GUESTS}
|
|
1296
|
+
columns={GUEST_COLUMNS} // as a DataTable takes them
|
|
1297
|
+
inputs={GUEST_INPUTS} // or { create, edit }, or (action, row) => inputs
|
|
1298
|
+
paging="server" // default "client": each list loaded once
|
|
1299
|
+
form={{ load: "details", newValues: { country: "Nepal" } }}
|
|
1300
|
+
details={{ columns: GUEST_DETAILS }}
|
|
1301
|
+
onChange={({ action, rows, mode }) => audit(action, rows)}
|
|
1302
|
+
/>
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
- **Lists.** All (`NormalListing` or `NormalPagination`), Flagged, Trash, and Deleted, as tabs after Add: every one
|
|
1306
|
+
named on a table 900px wide or more, icons with the chosen one named below. A list shows when it is in `modes`, has
|
|
1307
|
+
its endpoint, and the user may call it.
|
|
1308
|
+
- **Pinned records.** `PinListing` answers the records the user pinned: they sit in a section above the active list that
|
|
1309
|
+
folds open and shut, and in the list and its pages as well. One limit holds for every CRUD in the app
|
|
1310
|
+
(`configureCrud({ maxPins: 100 })`, 100 by default). Pinning a selection bigger than the room left pins as many as fit,
|
|
1311
|
+
in the order they were picked, and says so; lowering the limit never unpins anything, so a user over it can still
|
|
1312
|
+
unpin and can pin again once they are under it.
|
|
1313
|
+
- **Actions.** On a record in the lists of active records: View details, Edit (an icon on the row), Flag, Pin,
|
|
1314
|
+
Move to trash; in the trash: Recover, Delete permanently; in the deleted list: Move back to trash. On selected records: the same, plus a new Description for all of them. Each shows only when it is in
|
|
1315
|
+
`actions`, has its endpoint, and is allowed. A controller without a trash (no endpoint, or left out of `actions`) offers
|
|
1316
|
+
Delete where Move to trash would be; a trash the user may not use never becomes Delete.
|
|
1317
|
+
- **Questions and messages.** Move to trash and Delete ask first ("Move 3 guests to the trash?", "This cannot be
|
|
1318
|
+
undone."); Flag, Pin, and Recover are undone by a click and do not ask. Toasts come from the message
|
|
1319
|
+
builder ("Sita Sharma moved to the trash.", "3 guests unpinned.").
|
|
1320
|
+
- **Rows from the answers.** A record that moves to another list leaves the one in view (and the pinned section); flags,
|
|
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
|
|
1322
|
+
answer lists the records it changed, only those change.
|
|
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
|
|
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
|
|
1327
|
+
now; an edit keeps them and was changed now), and the names the database joins written again by `crudJoinedNames` for
|
|
1328
|
+
each `crudColumns.dbJoin` column given `from` (the key's field): from the record the user picked in the form, one the
|
|
1329
|
+
page holds (`peekOption`), or the name the row had when the key did not change. An edit takes the status, the marks
|
|
1330
|
+
and, with `trees`, the child count from the row the page holds now, over its form's older copies; the parents' own rows
|
|
1331
|
+
on the table count their children too. Read back once (`details`), and only then: a record of a backend whose save
|
|
1332
|
+
says no status, or one with a joined name nothing names (`crudJoinsMissing`) - a `dbJoin` column without `from`, or a
|
|
1333
|
+
new key no picked or held record names.
|
|
1334
|
+
- **Add and Edit** open popups built on `ApiForm`, with the Save bar in the footer and the server's messages on their
|
|
1335
|
+
inputs. Keys keep them one each: pressing Add again brings back an Add form with changes (minimized or not), or opens a
|
|
1336
|
+
fresh one in place of every untouched one; a record has one Edit popup, brought back as it was when Edit is pressed
|
|
1337
|
+
again - from a table or a tree. Closing a form with changes (its close
|
|
1338
|
+
button, Esc, the dock) asks "Discard your changes?". Edit opens with the row, the `details` endpoint's record
|
|
1339
|
+
(`form.load: "details"`, for fields a list leaves out), or a loader of your own.
|
|
1340
|
+
- **Details** (View, or a double-click) shows every column, hidden ones too, and the full record once `details` answers,
|
|
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.
|
|
1348
|
+
- **A record's workspace** (`details.profile`, `details.tabs`, `details.remember`): View, and a click on a row under
|
|
1349
|
+
`table.openOnClick`, open the record as a wide popup (`full` unless `size` says otherwise) - who it is and its tabs in
|
|
1350
|
+
a 20rem column on the left, the chosen tab on the right, scrolling inside itself. A page passes it in a few lines:
|
|
1351
|
+
|
|
1352
|
+
```tsx
|
|
1353
|
+
details={{
|
|
1354
|
+
profile: row => ({
|
|
1355
|
+
picture: { ...GUEST_PICTURE, ownerId: String(row.id) }, // the picture column's params: a click opens the pictures
|
|
1356
|
+
title: row.fullName,
|
|
1357
|
+
subtitle: row.email,
|
|
1358
|
+
tags: [{ label: "Gold", tone: "warning" }],
|
|
1359
|
+
}),
|
|
1360
|
+
tabs: [
|
|
1361
|
+
// `details` (the fields, with `aside` under them) comes first unless listed: { id: "details" } moves it.
|
|
1362
|
+
{ id: "stays", label: "Stays", icon: <InlineIcon icon={iconCalendar} />, endpoint: STAYS.create,
|
|
1363
|
+
render: ({ row, refresh }) => <StayForm guest={row} onSaved={refresh} /> },
|
|
1364
|
+
{ id: "bills", label: "Bills", icon: <InlineIcon icon={iconReceipt} />, badge: row => row.openBills,
|
|
1365
|
+
render: ({ row }) => <Bills guestId={row.id} /> },
|
|
1366
|
+
{ id: "gold", label: "Gold desk", hidden: row => row.vipLevel !== 2, render: context => <GoldDesk {...context} /> },
|
|
1367
|
+
],
|
|
1368
|
+
remember: { key: "guest-tab", viewer }, // the last tab, in this browser, for this viewer
|
|
1369
|
+
}}
|
|
1370
|
+
```
|
|
1371
|
+
|
|
1372
|
+
- A tab that names an `endpoint` shows only to a person who may call it; `hidden(row)` hides it for a record. Only
|
|
1373
|
+
the chosen tab is drawn, and a switch never changes the popup's size.
|
|
1374
|
+
- `profile.fields` puts a few of the record's fields under the tags, a label and a value each, drawn as the table
|
|
1375
|
+
draws them (the person's date format, an option's tone, the column's `tag` after the value). A field is a column
|
|
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
|
|
1379
|
+
phone they follow the tags as one wrapped line.
|
|
1380
|
+
|
|
1381
|
+
```tsx
|
|
1382
|
+
profile: row => ({ title: row.fullName, tags: [{ label: "Due", tone: "warning" }], fields: [EXPIRY_COLUMN, "inactiveDate"] }),
|
|
1383
|
+
```
|
|
1384
|
+
- `render` is handed `row` (as the list has it now), `form` (the record: the row, then the `details` endpoint's, then
|
|
1385
|
+
each refresh's), `popup`, `tab`, `select(tabId)` and `refresh()`. Call `refresh()` after a tab changed the record on
|
|
1386
|
+
the server (a stay recorded): it reads the record again and writes it into the table, the pinned band, every list
|
|
1387
|
+
kept and every tree, so the row's visits and dates change with one request and no list read again - in place on
|
|
1388
|
+
every page kept, also for a record opened from the pinned band, and also when the popup closed before it landed.
|
|
1389
|
+
- A `SubmitForm` or `ApiForm` in a tab joins the workspace by itself: while it has changes, choosing another tab or
|
|
1390
|
+
closing the popup (Close, Edit, its X, Esc, its bar on the dock) asks "Discard your changes?" first.
|
|
1391
|
+
- A tab's form longer than the window puts its line and Save last, in a **`CrudTabFooter`**: it stays at the bottom
|
|
1392
|
+
of the tab while the form scrolls and sits in its place at the end, across the tab's whole width under a hairline;
|
|
1393
|
+
a field the keyboard moves to is scrolled clear of it, and on a phone its words and buttons wrap as whole groups.
|
|
1394
|
+
|
|
1395
|
+
```tsx
|
|
1396
|
+
function StayTab({ refresh }: CrudDetailsTabContext<Guest>) { // { id: "stay", render: context => <StayTab {...context} /> }
|
|
1397
|
+
const form = useForm<Stay>();
|
|
1398
|
+
const stay = useFormValues(form);
|
|
1399
|
+
return (
|
|
1400
|
+
<>
|
|
1401
|
+
<ApiForm form={form} buttons={false} api={STAYS.create} inputs={STAY_INPUTS} onSuccess={() => void refresh()} />
|
|
1402
|
+
<CrudTabFooter>
|
|
1403
|
+
<p role="status" aria-live="polite">{stay.nights} nights · To pay Rs. {formatNumber(price(stay), { decimals: 2 })}</p>
|
|
1404
|
+
<FormButtons form={form} submitLabel="Save" />
|
|
1405
|
+
</CrudTabFooter>
|
|
1406
|
+
</>
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
```
|
|
1410
|
+
- The built-in `details` tab: list `{ id: "details", label: "Profile" }` to move or rename it, add
|
|
1411
|
+
`hidden: () => true` to leave it out, or a `render` to replace it. Without a second tab for the person, there is no
|
|
1412
|
+
tab list: the profile and the details.
|
|
1413
|
+
- Its `aside` (a subscriber's notes) goes **under** the fields, each across the whole width, the panel as tall as it
|
|
1414
|
+
needs and scrolled with the fields (`details.asidePlace`, `"below"` by default in a workspace); `asidePlace:
|
|
1415
|
+
"beside"` puts it beside them from 1000px, as a plain details popup does by default.
|
|
1416
|
+
- Below 720px of popup width (a phone) the profile is one row and the tabs one strip above the tab. The tabs are a
|
|
1417
|
+
`Tabs` tablist: the focus opens on the chosen tab, the arrow keys, Home and End move and open, Tab goes into the tab.
|
|
1418
|
+
- **A list beside the record picked in another** - a person's places beside the people. The page holds the picked record
|
|
1419
|
+
and passes it on; every behaviour is the `Crud`'s:
|
|
1420
|
+
|
|
1421
|
+
```tsx
|
|
1422
|
+
const [person, setPerson] = useState<CompanyUser | null>(null);
|
|
1423
|
+
<AdminSplit>
|
|
1424
|
+
<Crud<CompanyUser> api={COMPANY_USERS} columns={PEOPLE} inputs={PEOPLE_INPUTS} onPick={setPerson} />
|
|
1425
|
+
<Crud<BranchUser> api={BRANCH_USERS} columns={PLACES} inputs={PLACE_INPUTS} narrow={{ picked: person, key: "userId", labelKey: "fullname" }} />
|
|
1426
|
+
</AdminSplit>
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
- The narrowed list shows the records whose `key` field holds the picked record's value (its `valueKey` field, `key`
|
|
1430
|
+
by default), under a caption naming it ("Places of: Ram Thapa"). With nothing picked it says so and asks for nothing.
|
|
1431
|
+
- Paged in the browser, it keeps the whole list and reads each pick's rows from it, with no request. Paged on the
|
|
1432
|
+
server, it sends the value as a filter named `key`.
|
|
1433
|
+
- Add New fixes the value in the new record, in a form titled for whom it adds. It waits, saying why, while nothing
|
|
1434
|
+
is picked or the picked record may take nothing: `addLock`, by default the backend's rule, so a Nexus record that is
|
|
1435
|
+
pending, trashed or deleted takes no new records.
|
|
1436
|
+
- A save lands in the list of the record it names, and in no other, even when another record is picked by then.
|
|
1437
|
+
- **Pickers of another controller's records** (`crudPicker(STAFF, { labelKey, valueKey, find })`): the active list read
|
|
1438
|
+
once and searched in the browser, and a saved value it does not offer looked up by key through `findListing`. A
|
|
1439
|
+
`valueKey` other than `id` - a person's account rather than their row's key - is looked up through `find`, an endpoint
|
|
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.
|
|
1447
|
+
- **A record nobody may change here** (`rowLock={row => row.isLocked ? row.lockReason : null}`): its Edit, Flag, Move to
|
|
1448
|
+
trash, Recover and the deletes are listed switched off with that sentence under each, its details have no Edit, the
|
|
1449
|
+
handle's `edit` opens its details, and a selection holding it has those mass actions switched off. View, History, Pin
|
|
1450
|
+
and Favourite stay. The page's own `rowActions` are its own to switch off; the backend still decides.
|
|
1451
|
+
- **One action locked on a record** (`rowActionLock={row => row.isHeadOffice ? { trash: "The head office stays open." } : null}`):
|
|
1452
|
+
only the actions named are listed switched off, each with its sentence, and switched off in a selection that holds
|
|
1453
|
+
the record; a locked `edit` opens the details instead. The rest still run.
|
|
1454
|
+
- **A list narrowed by a box of its own** (`narrowPicker={{ picker: crudPicker(GROUPS, { labelKey: "groupName" }), label: "Group", key: "subscriptionGroupId", labelKey: "groupName", remember: { key: "subscribers-group", viewer }, onPick }}`):
|
|
1455
|
+
the CRUD draws the box in its toolbar and narrows by the pick as `narrow` does. `remember` keeps the pick in this
|
|
1456
|
+
browser per viewer (pass a short hash of the person and the company, so a shared PC never opens on another's pick)
|
|
1457
|
+
and opens it again while the box still offers it; only the record's key is stored, and nothing without a `viewer`.
|
|
1458
|
+
`onPick` tells the page, for inputs that follow it. The empty list, Add and the Add form's fixed field name the box
|
|
1459
|
+
("Pick a group in the Group box to see its subscribers."); `emptyMessage`, `addMessage` and `fixedMessage` replace them.
|
|
1460
|
+
While nothing is picked the box asks for a pick: as the page opens it settles (a spinner in it, the list waiting) on
|
|
1461
|
+
the remembered pick, or else on the one record it offers, picked by itself and remembered as a person's pick is; with
|
|
1462
|
+
neither it is required and drawn with a field's invalid edge (`Dropdown` / `Field` `invalid`), the list's words read
|
|
1463
|
+
with it, and it takes the cursor - unless the person is already at work on the page, a popup is open, the box is
|
|
1464
|
+
below the fold, or the screen is touch-only (a cursor would raise the keyboard over the list). The empty list has a
|
|
1465
|
+
button to the box ("↑ Pick a group"), and Add pressed with nothing picked sends the cursor there and nudges the box
|
|
1466
|
+
(still, with reduced motion) while a screen reader hears Add's reason instead of a note under Add.
|
|
1467
|
+
- **A list that can be widened** (`scopes={{ options: [{ id: "all", label: "All branches", description: "Every branch's people", api: ALL_BRANCH_USERS, columns, inputs, form: { newValues }, "aria-label" }], remember: { key: "branch-users-scope", viewer }, onChange }}`):
|
|
1468
|
+
the CRUD's own props are its own list (one branch's places); each scope is another list of the same records on
|
|
1469
|
+
endpoints of its own, with its own `columns`, `inputs`, `form` (over the CRUD's, field by field), `table` settings and
|
|
1470
|
+
name where they differ. One scope is a switch after Add, its `description` the tooltip; two or more are tabs, the
|
|
1471
|
+
CRUD's own list first (`label`, `description` for its tab). A scope is offered only to a person who may call one of
|
|
1472
|
+
its lists and passes its `when` (the page's own rule), again whenever their rights change. Switching reads the new
|
|
1473
|
+
list on its endpoints with the CRUD left in place (the switch keeps the focus, popups stay open); each scope keeps
|
|
1474
|
+
its lists while the page is open, so going back shows them at once, and a change made on one drops the others' (only
|
|
1475
|
+
the server knows which hold the record). Each scope keeps its own table settings (`{stateKey}:{id}`, or its own
|
|
1476
|
+
`table.stateKey`). `remember` keeps the choice in this browser per viewer (`nx-crud-scope:{key}:{viewer}`), read after
|
|
1477
|
+
the first draw and before any list is asked for; a kept scope not offered now opens the CRUD's own list and stays kept.
|
|
1478
|
+
An answer that lands after a switch writes nothing into the list on screen, which is read again; it still marks the
|
|
1479
|
+
lookups of both lists' endpoints old and drops a tree's kept pages. A form belongs to the scope it was opened on: an
|
|
1480
|
+
Edit pressed in details opened on another scope is that scope's form on its endpoints, and each scope keeps its own
|
|
1481
|
+
Add and its draft. The handle's `scope` and `setScope(id | null)` read and set it; `onChange` hears every change of
|
|
1482
|
+
the list shown.
|
|
1483
|
+
- **A row's own form** (`rowForms={[{ id: "permissions", label: "Permissions", icon, inline: true, modes: ["normal"], size: "lg", load: SETS.details, submit: SETS.update, inputs: (row, loaded) => setInputs(loaded), transform: (values, row) => ({ branchUserId: row.id, ...values }), writes: ["assignedPermissionCount"], lock, when, target, title, submitLabel, onSaved }]}`):
|
|
1484
|
+
a row action (an icon beside Edit with `inline`, else a line of the row's menu) that opens a form of its own for the
|
|
1485
|
+
record in a popup - one per record, brought back if open - after the built-in actions and before `rowActions`. It is
|
|
1486
|
+
offered on the lists that offer Edit unless `modes` says otherwise, to a person who may call `submit` and, when it is
|
|
1487
|
+
an endpoint, `load`, and by the page's `when`; `lock` lists it switched off with its sentence. `load` reads the values
|
|
1488
|
+
(an endpoint called with `{id}`, the record's key or `target`, or a function that must reject on a failure), and the
|
|
1489
|
+
inputs may be made from what was read; until it is, Save waits, and a read that fails - an answer that is not ok, or
|
|
1490
|
+
no record - shows "Could not load the values" with Retry and **no Save**, so an empty form is never saved over the
|
|
1491
|
+
record. `writes` names the answer's fields that change the record (or is a function of the answer): only those, merged
|
|
1492
|
+
over the record as the CRUD holds it when the answer lands (an Edit made while the popup was minimized stays), written
|
|
1493
|
+
into the table, every list kept and the pinned sections with no list read again. The handle's `openRowForm(id, row)`
|
|
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
|
|
1506
|
+
Package list only of the Group picked. Static `options` are filtered by the records' `key` field (default the other
|
|
1507
|
+
input's name); `loadOptions` is asked with `request.narrow`, which `crudPicker` answers from its kept list, or from
|
|
1508
|
+
the server with `crudPicker(PACKAGES, { labelKey, narrowQuery: "subscriptionGroupId" })`. While the other input is
|
|
1509
|
+
empty nothing is offered ("Pick a group first"), and a person changing it clears the value picked here.
|
|
1510
|
+
**Or by a fixed value** the page knows when the form opens (`narrowedBy: { key: "subscriptionGroupId", value:
|
|
1511
|
+
subscriber.subscriptionGroupId }`): nothing is followed or cleared, and a `null` value offers nothing. `orUnset: true`
|
|
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 }`).
|
|
1519
|
+
- **Cache.** Lists are kept for 5 minutes while the page is open (`cache={{ lifetime }}`, or `false`): going back to a
|
|
1520
|
+
list paints it at once. After a change, the list in view keeps its rows and the others load again when shown; Reload
|
|
1521
|
+
and the handle's `reload()` go past the cache. The cache goes when the page does.
|
|
1522
|
+
- **Permissions** come from the app's rules: `definePermissions(new NexusPermissions())` once, then `setActions(accessActions)`
|
|
1523
|
+
after sign-in. While the rules load, the table shows placeholders; a user with no list sees a message. Rules for
|
|
1524
|
+
another backend extend `Permissions` (`can(endpoint)`), and its page and mass shapes extend `CrudBackend`.
|
|
1525
|
+
- `ref` gives `table`, `mode`, `setMode`, `reload`, `create(values)`, `edit(row)`, and `view(row)`.
|
|
1526
|
+
- `scripts/browser-checks/crud.cjs` in the template checks `/demo/crud`: every list and action, the popups, the roles, the
|
|
1527
|
+
cache, a phone, and contrast in every theme.
|
|
1528
|
+
|
|
1529
|
+
### CRUD in a tree
|
|
1530
|
+
|
|
1531
|
+
```tsx
|
|
1532
|
+
import { CrudTree } from "nexus-shared/client";
|
|
1533
|
+
|
|
1534
|
+
// Where the records sit in the tree: one definition for the tree page and for a Crud whose changes it must follow.
|
|
1535
|
+
export const REGION_TREE: CrudTreePlacement<Region, RegionItem> = {
|
|
1536
|
+
cacheKey: "regions-tree",
|
|
1537
|
+
rules: { valueKey: "key", hasChildrenKey: "childCount", compare: bySortOrderThenName },
|
|
1538
|
+
itemOf: region => ({ key: `r${region.id}`, label: region.name, childCount: region.childCount ?? 0, region }),
|
|
1539
|
+
parentOf: region => `r${region.parentId}`,
|
|
1540
|
+
recordOf: item => (item.region ? { ...item.region, childCount: item.childCount } : null), // the count goes back
|
|
1541
|
+
childValues: parent => ({ parentId: parent.region?.id }),
|
|
1542
|
+
placeKeys: ["parentId"], // where a record sits: fixed in the tree's forms, moved only on the data table page
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
<CrudTree<Region, RegionItem>
|
|
1546
|
+
api={REGIONS} inputs={REGION_INPUTS} columns={REGION_COLUMNS} form={REGION_FORM} place={REGION_TREE}
|
|
1547
|
+
tree={{ loadChildren, fill: true, "aria-label": "Regions" }}
|
|
1548
|
+
details={({ row, body, actions }) => <AdminPanel title={row?.name ?? "Details"} action={actions}>{body}</AdminPanel>}
|
|
1549
|
+
/>
|
|
1550
|
+
<Crud<Region> api={REGIONS} columns={REGION_COLUMNS} inputs={REGION_INPUTS} trees={[REGION_TREE]} /> // the data table page
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
- **Actions.** Add New under any row (Insert), Edit (F2) and Move to trash (Delete) on a record's row, all three in its
|
|
1554
|
+
context menu, Add New in the toolbar; the picked record's panel offers what its list offers - Recover and Delete
|
|
1555
|
+
permanently once it is in the trash. The forms, questions and calls are the `Crud`'s own.
|
|
1556
|
+
- **The selected row.** A click or Enter on a row selects it (`TreeView` `selectedValue`): tinted, and staying so while
|
|
1557
|
+
focus, the arrow keys and the chevrons move on. A record's row shows the record in the panel; a row that is no record
|
|
1558
|
+
(a country) empties the panel and opens.
|
|
1559
|
+
- **A fixed place** (`placeKeys`). Every Add goes inside a row - the toolbar's inside the selected row, the one the panel
|
|
1560
|
+
shows; with none (or one in the trash) a press says why - and the fields that say where a record sits show read-only in
|
|
1561
|
+
the Add and Edit forms, with the lock and a note on any try: from the row added inside (`childValues`; a field it
|
|
1562
|
+
leaves out is fixed empty), or the record's own. A record moves only where those fields are offered, such as the data
|
|
1563
|
+
table's form. An Add started inside one row is a popup of its own, titled with its place ("Inside Bagmati", on the dock
|
|
1564
|
+
too): it never comes back inside another row. A record's Edit stays one popup: asked for from the other page with
|
|
1565
|
+
changes in it, it comes back as it is and says why, so an older form never undoes a newer save.
|
|
1566
|
+
- **Nothing read again.** A change is written into the tree and the pages it keeps (`writeTreeChanges`): an added record
|
|
1567
|
+
goes where `compare` puts it among its loaded siblings and is shown there - or, under a parent never opened, into its
|
|
1568
|
+
count, the parent left closed, since opening it would read its page - an edit is written in place or moved, a trashed
|
|
1569
|
+
one leaves, and each parent's count and chevron follow. A save is not read back either: the record is built as `Crud`
|
|
1570
|
+
builds it, its joined names from the form, the page, or the parent the tree holds.
|
|
1571
|
+
- **Another page's changes.** A `Crud` given the same placement in `trees` writes its changes into the tree's kept pages,
|
|
1572
|
+
so the tree opened next shows them without a call.
|
|
1573
|
+
|
|
1574
|
+
### Excel import and export
|
|
1575
|
+
|
|
1576
|
+
A model's records in and out of a spreadsheet, against the backend's Excel controller bases
|
|
1577
|
+
(`NexusExcelController<TContract>` and `NexusExcelExportController<TContract>`): `Excel/Settings`, `Excel/Template`,
|
|
1578
|
+
`Excel/Verify`, `Excel/Import`, and the model's own export action.
|
|
1579
|
+
|
|
1580
|
+
```tsx
|
|
1581
|
+
export const CITIES = MODULES.controller(NexusModule.Configuration, "City", "city");
|
|
1582
|
+
export const CITY_SHEET = excelEndpoints(CITIES, { subject: "city" }); // the four, and Excel/Export
|
|
1583
|
+
|
|
1584
|
+
openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload }); // the panel as a window
|
|
1585
|
+
<ExcelImport endpoints={CITY_SHEET} subject="city" onImported={reload} /> // or in the page
|
|
1586
|
+
openExcelImport({ endpoints: REGION_SHEET, subject: "region", mode: "bulk", onImported: reload }); // rows typed in
|
|
1587
|
+
|
|
1588
|
+
await downloadExcelTemplate(CITY_SHEET);
|
|
1589
|
+
await exportExcel(CITY_SHEET, { subject: "city", rows: table.matches, query: { search, sort: "cityName", direction: "asc", filters } });
|
|
1590
|
+
```
|
|
1591
|
+
|
|
1592
|
+
- **The template** is the backend's file, downloaded under the name its `Content-Disposition` gives it. Every list in it
|
|
1593
|
+
is a dropdown and the ids beside a choice fill themselves, so the person types no code and pastes no id.
|
|
1594
|
+
- **A verify writes nothing.** The file goes up as form data, and the answer is the rows, judged: what would be added,
|
|
1595
|
+
what is already a record, and what is waiting on a fix.
|
|
1596
|
+
- **The review is two editors** over those rows - **New rows** (which includes the rows to fix, since a row with a
|
|
1597
|
+
problem is not yet a record) and **Already there**, which folds away - built from the sheet's columns with the rules
|
|
1598
|
+
the sheet told the person about, so a fix made here is checked the way the file was. What the server found wrong is
|
|
1599
|
+
listed under the rows, each entry naming the cell as a spreadsheet does (`B7 - City name`) and taking the caret to it.
|
|
1600
|
+
Rows can be added to the new rows (Add, Insert, Duplicate); one added and left empty is not sent, and an added row is
|
|
1601
|
+
numbered after every row of the file, so no two rows share a number. A sheet with a required list takes no added
|
|
1602
|
+
rows: a list's key is picked in the spreadsheet, and a row added here could never have one.
|
|
1603
|
+
- **A bulk insert is an import with no file** (`mode: "bulk"`): the new rows' editor opens with one empty row, the
|
|
1604
|
+
person types the rows, and they go to the same `Excel/Import` with the same checks - a problem then names its row
|
|
1605
|
+
(`Row 3 - Region name`). A sheet with a required list cannot be typed in, and the panel says so.
|
|
1606
|
+
- **Nothing typed is lost unasked**: closing the window or Start over asks first, and the entry picked again while its
|
|
1607
|
+
window is minimized (by a page's rule or from code: the window has no minimize button of its own) brings that window back. An import that wrote nothing keeps the rows, and lists under them what
|
|
1608
|
+
the backend refused and why.
|
|
1609
|
+
- **The import sends rows, never the file again**, or every fix made on screen would be lost; every check runs again on
|
|
1610
|
+
the way in. **A duplicate is never updated**: one left alone is not sent at all, and editing one sends it as a new
|
|
1611
|
+
record. An import writes what it can and names the rest; only one that wrote nothing at all is a failure.
|
|
1612
|
+
- **A choice travels as its key**, out and back. The plain id in the sheet is for the person to read and is never read
|
|
1613
|
+
back, so nothing copied out of a file or out of an answer can be posted to an endpoint as an id.
|
|
1614
|
+
- **The export is the server's file**, written from the search, the sort and the filters on screen - not a CSV of the
|
|
1615
|
+
rows this browser holds. The row limit is the backend's (`Excel/Settings`, 5,000 by default, kept for the page's life);
|
|
1616
|
+
over it the person is asked first, and the answer's own headers say what was written and what was left out.
|
|
1617
|
+
- **Generic, like the rest.** `ExcelBackend` is the class an app on another backend registers
|
|
1618
|
+
(`defineExcelBackend(new MyExcelBackend())`): the request shapes, how the answers read, the upload field, and the names
|
|
1619
|
+
of a download's headers. `NexusExcelBackend` is the default. `readExcelResponse` reads a refusal's `errorMessages`, so
|
|
1620
|
+
"no city could be written" reaches the person in the backend's own words.
|
|
1621
|
+
- **A column draws its own input**: text, note, whole number, decimal, amount, date, time, Yes/No, and a choice shown
|
|
1622
|
+
read-only with the words the person picked. A **date and time** is a text box, not a picker: the sheet's moment is a
|
|
1623
|
+
zone-less wall clock while a picker's value is a UTC moment, so a picker would shift every value by the reader's offset.
|
|
1624
|
+
- `scripts/test-excel.mts` checks the endpoints, the answers, the inputs and the round trip; the template's
|
|
1625
|
+
`scripts/browser-checks/excel.cjs` checks `/demo/excel`: every state, the two rules about the wire, both downloads, the
|
|
1626
|
+
warning before a capped export, a phone, and contrast in every theme.
|
|
1627
|
+
|
|
1628
|
+
In a table, `excel={{ endpoints: CITY_SHEET, subject: "city" }}` is the whole of it: the options menu (⋮) then offers
|
|
1629
|
+
**Download template**, **Import from a spreadsheet** (the panel as a window, after which the table reads its rows
|
|
1630
|
+
again), **Bulk insert** (the same with no file, beside an Add only; `bulk: false` leaves it out) and **Export to
|
|
1631
|
+
Excel**, which sends the table's own search, filters and sort with the page's `filters()`. A `Crud` passes it through
|
|
1632
|
+
as `table={{ excel }}` and drops the lists it had kept once an import has written. It replaces `onImport`, which stays
|
|
1633
|
+
for a page that imports its own way. Each item asks the app's permissions (`canCall`) for every endpoint it needs, the
|
|
1634
|
+
settings included - the template and the export their own, the import and the bulk insert Verify and Import - so a
|
|
1635
|
+
person the backend would refuse is not offered it. A download whose settings call fails stops there: one toast.
|
|
1636
|
+
|
|
1637
|
+
Planned, following the previous Nexus project: a page status layout, and in the CRUD, audit logs in the details view.
|
|
1638
|
+
|
|
1639
|
+
### Pictures and documents
|
|
1640
|
+
|
|
1641
|
+
A record's pictures and documents are the files of an **owner** (a row of a module) in a **category** (what they are
|
|
1642
|
+
for: the kinds it takes, the size, how many an owner keeps, the thumbnail's size). A page declares them by params - the
|
|
1643
|
+
owner's `module`, the `category`, the `ownerId`, and for a picture its `aspect`, `shape`, `maxWidth`/`maxHeight`,
|
|
1644
|
+
`type` and `quality` - and every piece runs on the app's `FileAdapter`:
|
|
1645
|
+
|
|
1646
|
+
| Piece | From | What it is |
|
|
1647
|
+
|---|---|---|
|
|
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". |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
1653
|
+
| `loadImageEditor`, `preloadImageEditor` | `nexus-shared/client` | The image editor, downloaded from the file backend the first time it is needed; never in the bundle |
|
|
1654
|
+
| `FileAdapter`, `NexusDriveAdapter`, `defineFileAdapter` | `nexus-shared` / `/client` | Where files are kept: the Nexus Drive by default, any other backend by one class |
|
|
1655
|
+
|
|
1656
|
+
```tsx
|
|
1657
|
+
import { EInputTypes, NexusModule } from "nexus-shared";
|
|
1658
|
+
import { openPictureGallery, pictureColumn } from "nexus-shared/client";
|
|
1659
|
+
|
|
1660
|
+
const LOGO = { module: NexusModule.Enterprise, category: "company-logo", aspect: 1 };
|
|
1661
|
+
inputs: (action, row) => [{ type: EInputTypes.Picture, name: "logo", label: "Logo", ...LOGO, ownerId: row?.id, subject: "company" }, ...];
|
|
1662
|
+
columns: [pictureColumn({ ...LOGO, rowTitle: row => row.companyName }), ...];
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
- **Every picture goes through the editor before it is stored**: the picture input's Upload and Edit, and the gallery's
|
|
1666
|
+
Upload one picture at a time ("Picture 2 of 5" in its title; Cancel skips one and asks "Go on with the next file?" -
|
|
1667
|
+
Next file or Stop here). Documents (a PDF) are sent as they are. **The browser makes what is stored, within the
|
|
1668
|
+
category's picture limits**, which Drive's `File/List` answers from its settings (`pictureMaxSide`, `pictureQuality`,
|
|
1669
|
+
`pictureMaxBytes`; `pictureLimits` gives `PICTURE_LIMITS`, 1920 px, 80% and 300 KB, when a backend says none): the
|
|
1670
|
+
editor crops to the shape, brings the long edge down to `pictureMaxSide` (less when the params say), starts the
|
|
1671
|
+
quality at most at `pictureQuality` (its slider's top), and writes the file under `pictureMaxBytes` - a PNG with
|
|
1672
|
+
nothing see-through as a JPEG, one with see-through parts as a WebP where the browser writes it (else a smaller PNG),
|
|
1673
|
+
then a lower quality step by step, then a smaller picture - showing the real size in its Save panel and saying when it
|
|
1674
|
+
had to shrink; the toast says so too. It writes only kinds the category takes (`types`): where it takes neither JPEG
|
|
1675
|
+
nor WebP, a PNG too large is made smaller. A picture the browser could not type (a phone's share, a name with no
|
|
1676
|
+
extension) is typed from its bytes (`typedFile`) and goes through the editor too. When the editor cannot be
|
|
1677
|
+
downloaded, the picture input and the gallery say why under their buttons until a try opens it. It makes the thumbnail at the category's size. A kind the category refuses is
|
|
1678
|
+
said before anything is made; a picture's size is checked against `pictureMaxBytes`, a document's against the
|
|
1679
|
+
category's own (`fileRefusal`). The gallery's line says both. The backend checks the bytes again.
|
|
1680
|
+
- **A gallery opened without `view`** takes its title and icon from the category once it is read: "Documents" for one
|
|
1681
|
+
that takes PDF. A page's rows whose read is refused or fails show no picture for 30 s (`retryAfter`), not one call
|
|
1682
|
+
per redraw.
|
|
1683
|
+
- **With Nexus** (`NexusDriveAdapter`), the owner's module decides who may change the files: the adapter asks it for a
|
|
1684
|
+
write ticket (`{module}/Files/Ticket/{category}/{ownerId}`, `me` for a person's own), keeps it until a minute before
|
|
1685
|
+
it runs out and asks again once when Drive says it ran out (`DRV_X011`); a person refused a write ticket sees the
|
|
1686
|
+
files read only through a read ticket (`Files/Owners/{category}`), which also reads a page's rows at once. The bytes
|
|
1687
|
+
are Drive's `File/View/{id}/{Original|Thumbnail}` through the app's proxy.
|
|
1688
|
+
- **The editor** is served by the backend at a versioned address (Drive: `Assets/File/nexus-image-editor.txt` names the
|
|
1689
|
+
current `nexus-image-editor.<hash>.js`), imported with the bundler told to leave it alone, once per page
|
|
1690
|
+
(`importModule`, built without the import-extension rewrite so `dist` keeps a plain `import(url)` that webpack,
|
|
1691
|
+
Turbopack and Vite leave to the browser; `test-dist-loader` guards it). When it cannot load (the network, a 404, a
|
|
1692
|
+
bundler), the person gets a toast in plain words and the console the cause.
|
|
1693
|
+
- **Read only, and why.** `lockReason: "The branch is closed."` makes the files read only with that sentence under
|
|
1694
|
+
them; a person the backend lets only look is told the backend's words (`OwnerFiles.readOnlyReason`), and the files
|
|
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.
|
|
1702
|
+
- **Initials.** `pictureColumn({ ..., nameKey: "fullname" })` (or `OwnerPicture name`) shows an owner with no picture
|
|
1703
|
+
as the name's initials, on the shared `Avatar`'s tone for that name.
|
|
1704
|
+
- **The keyboard's focus stays.** While a change saves, the buttons stay focusable (`aria-disabled`, the press ignored:
|
|
1705
|
+
`busyProps`), and when the focused one goes with the change (Remove, a star, a deleted file's) the focus moves to the
|
|
1706
|
+
first control left in its tile, else in the input (`useFocusThroughChange`), never to the page.
|
|
1707
|
+
- **Another backend**: `defineFileAdapter(new MyFiles())`, a class with `list`, `primaries`, `upload`, `setPrimary`,
|
|
1708
|
+
`rename`, `remove`, `fileUrl` and `editorModuleUrl`, answering `ApiResult`s; nothing else changes.
|
|
1709
|
+
|
|
1710
|
+
### A record's page of cards
|
|
1711
|
+
|
|
1712
|
+
`RecordCards` draws one record that is not a list - a person's profile, my company, a subscriber's details - as a hero
|
|
1713
|
+
across the top and cards under it, from params alone. **The page never saves**: a card's Edit, a list's Add and an
|
|
1714
|
+
item's icon actions open popups, and each answer is written into the card at once (every card on the same source
|
|
1715
|
+
follows, the hero too, with nothing read again).
|
|
1716
|
+
|
|
1717
|
+
```tsx
|
|
1718
|
+
<RecordCards
|
|
1719
|
+
name="profile"
|
|
1720
|
+
sources={{ profile: { read: ACCOUNT_API.profile, held: { fullName: user.fullName } }, emails: ACCOUNT_API.emails }}
|
|
1721
|
+
hero={{ source: "profile", title: p => p.fullName, lines: p => [`@${p.username}`], picture: p => ({ ...OWN_PICTURE, ownerId: p.id }) }}
|
|
1722
|
+
signedOut={{ onSignIn: () => window.location.assign(signInHref("profile")) }}
|
|
1723
|
+
cards={[
|
|
1724
|
+
fieldsCard<Profile>({ id: "personal", source: "profile", title: "Personal details", fields: [FULL_NAME, DATE_OF_BIRTH, GENDER], edit: { submit: ACCOUNT_API.saveProfile } }),
|
|
1725
|
+
itemsCard<ContactList, Contact>({
|
|
1726
|
+
id: "emails", source: "emails", title: "Emails", items: "items", itemKey: "id", limit: "maxCount",
|
|
1727
|
+
item: e => ({ title: e.address, marks: e.isPrimary ? [{ label: "Primary", tone: "primary" }] : [] }),
|
|
1728
|
+
add: { title: "Add an email", inputs: [EMAIL], submit: ACCOUNT_API.addEmail },
|
|
1729
|
+
itemActions: [{ id: "remove", label: "Remove", icon: <InlineIcon icon={iconTrash} />, tone: "danger",
|
|
1730
|
+
held: e => (e.isPrimary ? "The primary email cannot be removed." : null),
|
|
1731
|
+
confirm: { title: ({ item }) => `Remove ${item!.address}?`, submit: ACCOUNT_API.removeEmail, transform: ({ item }) => ({ id: item!.id }) } }],
|
|
1732
|
+
}),
|
|
1733
|
+
]}
|
|
1734
|
+
/>
|
|
1735
|
+
```
|
|
1736
|
+
|
|
1737
|
+
- **Sources** are read once each (GET through `api`, or a function answering an `ApiResult`); `held` is drawn while a
|
|
1738
|
+
read runs. A card names its `source` (default the first).
|
|
1739
|
+
- **Fields cards** draw each value by the `InputParams` that edit it: a choice by its label, a date in the person's
|
|
1740
|
+
format, Yes or No, chips (`look: "chips"`, a multiple choice), long text on its lines, "Not said" (`empty`) when empty.
|
|
1741
|
+
`edit` opens a popup of those inputs on the record; the values are sent merged over the record as held now, so an
|
|
1742
|
+
endpoint that takes the whole record works.
|
|
1743
|
+
- **Items cards** list rows with icon actions on the right, each named as a tooltip and to a screen reader with its
|
|
1744
|
+
item, hidden (`hidden`), or held with its reason (`held`: aria-disabled, said in a note when pressed). `add` sits in
|
|
1745
|
+
the heading, held at the `limit` ("5 of 5").
|
|
1746
|
+
- **Popups** are one step or `steps` (`start`, `next`): a sentence (`lead`), inputs, `submit`, `transform`, `writes`, a
|
|
1747
|
+
button that calls and stays (`also`, held while the server says to wait), and `done` to run another action next
|
|
1748
|
+
(`run("verify", added)`). A form opens only on the plain values its inputs name (and a rows input on its rows).
|
|
1749
|
+
`confirm` asks, then calls.
|
|
1750
|
+
- **A step's form**: each step is handed its `form`, and `inputs` as a function gets it, so a label or `hidden` can
|
|
1751
|
+
follow another input as typed (`inputs: ({ form }) => addressInputs(form)`, getters with `dependsOn`). `validate`
|
|
1752
|
+
is the step's own rule over its values; `fresh: true` (or `goTo(id, { fresh: true })`) shows a step reached again
|
|
1753
|
+
with nothing of before (an old code, a password). A password or a code typed is never asked about before it goes.
|
|
1754
|
+
- **Heading buttons** are named with their card ("Edit: Personal details"), held ("Still being read.") until the data is
|
|
1755
|
+
read - their `hidden` and `held` are asked only then, `loading: "hidden"` draws one only then - and go under the
|
|
1756
|
+
title where the card is narrow. `heldRows`, `heldRowLines` and a field's `heldLines` hold what the data usually
|
|
1757
|
+
takes, so nothing under a card moves when it comes.
|
|
1758
|
+
- **`writes`**: the answer merged over the data (default), the values, one item replaced or added (`"item"`), the item
|
|
1759
|
+
removed (`"remove"`), a read again, named fields, or a function.
|
|
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
|
|
1761
|
+
Try again, `readOnly` with no button anywhere, and `signedOut` - an answer that ended the session closes the popups
|
|
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.
|