nexus-shared 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +442 -0
  2. package/README.md +1123 -1000
  3. package/dist/Client.Index.d.ts +6 -0
  4. package/dist/Client.Index.js +6 -0
  5. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  6. package/dist/Components/Chats/ChatParts.js +3 -7
  7. package/dist/Components/Chats/ChatThread.js +3 -3
  8. package/dist/Components/Documents/Button.d.ts +6 -1
  9. package/dist/Components/Documents/Button.js +2 -2
  10. package/dist/Components/Documents/Menu.js +21 -9
  11. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  12. package/dist/Components/Documents/SplitButton.js +7 -5
  13. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  14. package/dist/Components/Documents/TabButtons.js +2 -2
  15. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  16. package/dist/Components/Forms/ApiForm.js +1 -1
  17. package/dist/Components/Forms/Crud.js +486 -206
  18. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  19. package/dist/Components/Forms/CrudHistory.js +140 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  21. package/dist/Components/Forms/CrudParts.js +38 -5
  22. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  23. package/dist/Components/Forms/ExcelImport.js +189 -60
  24. package/dist/Components/Forms/Form.js +9 -14
  25. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  26. package/dist/Components/Forms/SubmitForm.js +23 -12
  27. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  28. package/dist/Components/Inputs/CodeBox.js +316 -0
  29. package/dist/Components/Inputs/DatePicker.js +4 -1
  30. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  31. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  32. package/dist/Components/Inputs/Dropdown.js +4 -1
  33. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  34. package/dist/Components/Inputs/DropdownParts.js +4 -6
  35. package/dist/Components/Inputs/InputRenderer.js +3 -0
  36. package/dist/Components/Inputs/NumberBox.js +26 -2
  37. package/dist/Components/Inputs/RowsInput.js +2 -2
  38. package/dist/Components/Inputs/Slider.js +4 -1
  39. package/dist/Components/Inputs/TabularForm.js +42 -10
  40. package/dist/Components/Inputs/TextArea.js +4 -1
  41. package/dist/Components/Inputs/TextBox.js +4 -1
  42. package/dist/Components/Inputs/TimePanel.js +4 -3
  43. package/dist/Components/Inputs/TimePicker.js +4 -1
  44. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  45. package/dist/Components/Inputs/TreeView.js +372 -23
  46. package/dist/Components/Layouts/Popup.js +1 -4
  47. package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
  48. package/dist/Components/Layouts/ThemePicker.js +89 -6
  49. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  50. package/dist/Components/Layouts/ThemeScript.js +4 -2
  51. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  52. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  53. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  54. package/dist/Components/Viewers/Avatar.js +21 -0
  55. package/dist/Components/Viewers/DataTable.js +252 -62
  56. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  57. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  58. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  59. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  60. package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
  61. package/dist/Components/Viewers/DataTableParts.js +42 -13
  62. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  63. package/dist/Helpers/AnimationHelpers.js +20 -2
  64. package/dist/Helpers/ApiModules.d.ts +3 -34
  65. package/dist/Helpers/ApiModules.js +3 -3
  66. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  67. package/dist/Helpers/ApiRoutes.js +31 -27
  68. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  69. package/dist/Helpers/ArrayHelpers.js +2 -0
  70. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  71. package/dist/Helpers/CodeHelpers.js +332 -0
  72. package/dist/Helpers/CrudBackend.d.ts +101 -8
  73. package/dist/Helpers/CrudBackend.js +172 -11
  74. package/dist/Helpers/CrudColumns.d.ts +51 -0
  75. package/dist/Helpers/CrudColumns.js +69 -0
  76. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  77. package/dist/Helpers/CrudHelpers.js +109 -12
  78. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  79. package/dist/Helpers/ExcelBackend.js +8 -2
  80. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  81. package/dist/Helpers/ExcelHelpers.js +55 -13
  82. package/dist/Helpers/FormStore.d.ts +9 -5
  83. package/dist/Helpers/FormStore.js +25 -5
  84. package/dist/Helpers/InputParamsHelpers.js +8 -0
  85. package/dist/Helpers/MessageBuilder.js +3 -0
  86. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  87. package/dist/Helpers/NexusHelpers.js +1 -0
  88. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  89. package/dist/Helpers/NumberHelpers.js +41 -3
  90. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  91. package/dist/Helpers/PopoverHelpers.js +52 -17
  92. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  93. package/dist/Helpers/PopoverHooks.js +62 -0
  94. package/dist/Helpers/RequestCache.d.ts +35 -0
  95. package/dist/Helpers/RequestCache.js +113 -0
  96. package/dist/Helpers/RowsStore.d.ts +7 -2
  97. package/dist/Helpers/RowsStore.js +15 -3
  98. package/dist/Helpers/TableFilters.d.ts +25 -0
  99. package/dist/Helpers/TableFilters.js +162 -0
  100. package/dist/Helpers/TableHelpers.d.ts +7 -4
  101. package/dist/Helpers/TableHelpers.js +15 -6
  102. package/dist/Helpers/TableJoins.d.ts +70 -0
  103. package/dist/Helpers/TableJoins.js +250 -0
  104. package/dist/Helpers/TableStore.d.ts +16 -3
  105. package/dist/Helpers/TableStore.js +25 -5
  106. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  107. package/dist/Helpers/ThemeHelpers.js +46 -9
  108. package/dist/Helpers/TreeStore.d.ts +225 -11
  109. package/dist/Helpers/TreeStore.js +683 -90
  110. package/dist/Helpers/UserPreferences.d.ts +22 -0
  111. package/dist/Helpers/UserPreferences.js +116 -0
  112. package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
  113. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  114. package/dist/Interfaces/FormInterfaces.d.ts +37 -6
  115. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  116. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  117. package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
  118. package/dist/Interfaces/PreferenceInterfaces.js +25 -0
  119. package/dist/Interfaces/TableInterfaces.d.ts +145 -16
  120. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  121. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  122. package/dist/Services/ApiProxy.d.ts +36 -1
  123. package/dist/Services/ApiProxy.js +182 -52
  124. package/dist/Services/ApiProxyRules.d.ts +203 -0
  125. package/dist/Services/ApiProxyRules.js +426 -0
  126. package/dist/Services/ExcelApi.d.ts +3 -1
  127. package/dist/Services/ExcelApi.js +1 -1
  128. package/dist/Services/JoinSources.d.ts +48 -0
  129. package/dist/Services/JoinSources.js +74 -0
  130. package/dist/Services/ServerApi.d.ts +13 -2
  131. package/dist/Services/ServerApi.js +8 -3
  132. package/dist/Services/ThemeService.d.ts +18 -2
  133. package/dist/Services/ThemeService.js +80 -16
  134. package/dist/Shared.Index.d.ts +6 -0
  135. package/dist/Shared.Index.js +6 -0
  136. package/package.json +4 -2
  137. package/src/Styles/Nexus.Avatar.css +79 -0
  138. package/src/Styles/Nexus.Base.css +51 -4
  139. package/src/Styles/Nexus.Button.css +27 -0
  140. package/src/Styles/Nexus.Calendar.css +4 -6
  141. package/src/Styles/Nexus.Chat.css +5 -51
  142. package/src/Styles/Nexus.Choice.css +4 -6
  143. package/src/Styles/Nexus.Crud.css +16 -9
  144. package/src/Styles/Nexus.Dropdown.css +12 -61
  145. package/src/Styles/Nexus.Editor.css +4 -6
  146. package/src/Styles/Nexus.Excel.css +34 -15
  147. package/src/Styles/Nexus.Form.css +25 -67
  148. package/src/Styles/Nexus.Index.css +1 -0
  149. package/src/Styles/Nexus.Input.css +794 -637
  150. package/src/Styles/Nexus.Markdown.css +0 -28
  151. package/src/Styles/Nexus.Menu.css +8 -30
  152. package/src/Styles/Nexus.Popup.css +22 -29
  153. package/src/Styles/Nexus.RichText.css +2 -32
  154. package/src/Styles/Nexus.Rows.css +51 -10
  155. package/src/Styles/Nexus.Slider.css +3 -5
  156. package/src/Styles/Nexus.Tab.Buttons.css +20 -7
  157. package/src/Styles/Nexus.Table.css +1387 -1296
  158. package/src/Styles/Nexus.Theme.Picker.css +342 -232
  159. package/src/Styles/Nexus.Time.css +9 -12
  160. package/src/Styles/Nexus.Toast.css +6 -8
  161. package/src/Styles/Nexus.Tokens.css +17 -0
  162. package/src/Styles/Nexus.Tree.css +76 -11
package/README.md CHANGED
@@ -1,1000 +1,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 choice before the page paints, so there is no flash of the default theme.
57
- Options: `defaultTheme`, `defaultMode`, `themes`, `nonce`. It sets attributes on `<html>`, hence `suppressHydrationWarning`.
58
- - `ThemeSwitcher` is a palette button with a popover; `ThemePicker` is the same picker placed in a page.
59
- - `useTheme()` returns `{ theme, mode, resolvedMode, setTheme, setMode }` for custom controls.
60
- - The choice is saved in `localStorage` (`nx-theme`, `nx-mode`) and shared by open tabs.
61
-
62
- Without JavaScript, set the attributes yourself: `<html data-nx-theme="nord" data-nx-mode="dark">`.
63
- Without `data-nx-mode`, the system setting applies. `data-nx-theme` also works on any element, which then
64
- draws in that theme (the picker's previews use this).
65
-
66
- ### How it works
67
-
68
- - `Nexus.Themes.css` gives each theme its colors once, as `light-dark(light, dark)`; the page's `color-scheme` picks the side.
69
- - `Nexus.Tokens.css` holds sizes, spacing, and type, and derives hover colors, soft backgrounds, and the focus ring from the palette.
70
- - Components use only `--nx-*` variables, so every component follows the theme with no extra code.
71
- - `npm run check:themes` checks every theme in both modes against WCAG contrast. Run it after changing a color.
72
-
73
- ### Custom themes
74
-
75
- A theme is one CSS block. List it for the script and the picker:
76
-
77
- ```css
78
- [data-nx-theme="brand"] {
79
- --nx-primary: light-dark(#0f766e, #2dd4bf); /* the accent as text and lines */
80
- --nx-primary-solid: light-dark(#0f766e, #0d7a70); /* the accent as a fill behind white text: buttons, checks */
81
- --nx-primary-text: light-dark(#ffffff, #ffffff);
82
- /* …any other --nx-* color: bg, surface, surface-2, surface-hover, border, border-strong,
83
- text, text-muted, text-subtle, danger, danger-solid, danger-text, success, warning, info */
84
- }
85
- ```
86
-
87
- ```tsx
88
- const themes = [...NEXUS_THEMES, { id: "brand", name: "Brand", description: "Our colors" }];
89
- <ThemeScript themes={themes} defaultTheme="brand" />
90
- <ThemeSwitcher themes={themes} />
91
- ```
92
-
93
- All styles sit in `@layer nexus.*`, so an app's own CSS overrides any token or component style without `!important`.
94
-
95
- ## Components
96
-
97
- | Component | Import | Notes |
98
- |---|---|---|
99
- | `Button` | `nexus-shared` | Variants `primary`, `secondary`, `ghost`, `danger`; sizes `sm`, `md`, `lg`; `icon`, `iconEnd`, `loading`, `block`; `hoverTone` |
100
- | `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 |
101
- | `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 |
102
- | `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"` |
103
- | `Toaster`, `toast` | `nexus-shared/client` | Toast messages from anywhere: `toast.success(title, message)`, `toast.promise`, `toast.loading`, actions, positions; one `<Toaster />` in the layout |
104
- | `Popup` | `nexus-shared/client` | A modal window on the native dialog, with the buttons of a desktop window: minimize (a bar at the bottom right, content kept), maximize, close; moved by its title bar inside the window; title, description, scrolling body, footer, sizes `xs` to `full`, close reasons |
105
- | `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 |
106
- | `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` |
107
- | `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 |
108
- | `messageBox` | `nexus-shared/client` | `alert`, `confirm`, `prompt`, and `show` with any buttons, each returning a promise |
109
- | `TextBox` | `nexus-shared/client` | Text input: label designs `standard`, `outlined`, `inbox`; sizes; icons; `prefix`, `suffix`; `clearable`; show password; `showCount`; validation |
110
- | `TextArea` | `nexus-shared/client` | Multi-line text with the same field features; `autoResize` between `rows` and `maxRows` |
111
- | `CheckBox`, `Switch` | `nexus-shared/client` | One checkbox or on/off switch: `label`, `description`, `required` (must be checked), `indeterminate`, `readOnly`, `labelPosition` |
112
- | `CheckBoxGroup` | `nexus-shared/client` | Many choices from `options`: `min`, `max` (disables the rest at the limit), `selectAll`, `columns`, `design="cards"` |
113
- | `RadioGroup` | `nexus-shared/client` | One choice from `options`: arrow keys, `orientation`, `columns`, `design="cards"` |
114
- | `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); saved values the list no longer offers (`selectedOptions`, `resolveOptions`, inactive items) show and cannot be picked again |
115
- | `Slider`, `RangeSlider` | `nexus-shared/client` | Pick a number or a range: `min`, `max`, `step`, number format with `prefix`/`suffix`, `marks`, `tooltip`, `minDistance`, `onValueCommit` |
116
- | `NumberBox` | `nexus-shared/client` | Numbers only: `min`, `max`, `decimals`, `fixedDecimals`, `grouping` by `locale`, `prefix`, `suffix`, `stepper`, `clamp`; the same field shell |
117
- | `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 |
118
- | `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 |
119
- | `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 |
120
- | `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` |
121
- | `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` |
122
- | `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 |
123
- | `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` placeholders |
124
- | `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 |
125
- | `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 |
126
- | `FormButtons` | `nexus-shared/client` | A form's status line and buttons on their own, such as in a popup's footer |
127
- | `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 |
128
- | `FormStore` | `nexus-shared` | The store and the form's handle: `getValue`, `getChanges`, `set`, `load` (with `keepChanged`), `reset`, `validate`, `setErrors`, `focus`, `submit` |
129
- | `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 (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` |
130
- | `TableStore` | `nexus-shared` | The table's state and handle, outside React: `reload`, `upsert`, `patch`, `remove`, `select`, `getSelected`, `setSearch`, `setSort`, `setPage` |
131
- | `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 |
132
- | `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 |
133
- | `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 |
134
- | `configureCrud`, `getCrudSettings` | `nexus-shared` | What every CRUD in the app follows: how many records a user may pin (100 by default) |
135
- | `queryRows`, `formatCellText`, `sortRows`, `pageButtons`, `CrudCache`, `crudRowActions` | `nexus-shared` | Pure helpers behind the table and the CRUD: search, sort, and page rows as the table does, a value as its cell writes it, the page buttons, the cache, and which actions a list offers |
136
- | `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 |
137
- | `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 |
138
- | `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 |
139
- | `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 |
140
- | `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` |
141
- | `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 |
142
- | `createApiClient` | `nexus-shared` | The engine behind `api` and `serverApi`, for an API of another shape |
143
- | `Field` | `nexus-shared` | The frame of every input (label, box, icons, hint or error), for building custom inputs |
144
- | `validateText`, `TEXT_PATTERNS` | `nexus-shared` | Pure text validation and common patterns with their messages |
145
- | `validateCheck`, `validateSelection` | `nexus-shared` | Pure checks for a checkbox and for chosen values (`required`, `min`, `max`) |
146
- | `validateNumber`, `formatNumber`, `parseNumber` | `nexus-shared` | Pure number validation, and number text in any locale (`roundDecimal`, `stepNumber` too) |
147
- | `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 |
148
- | `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 |
149
- | `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 |
150
- | `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` |
151
- | `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` |
152
- | `ThemeScript` | `nexus-shared` | Applies the saved theme before paint; goes in `<head>` |
153
- | `ThemeSwitcher` | `nexus-shared/client` | Palette button with the theme picker in a popover |
154
- | `ThemePicker` | `nexus-shared/client` | Mode control and theme cards with live previews |
155
-
156
- ### Tab buttons
157
-
158
- ```tsx
159
- import { TabButtons } from "nexus-shared/client";
160
-
161
- <TabButtons
162
- aria-label="View"
163
- value={view}
164
- onChange={setView}
165
- options={[
166
- { value: "grid", label: "Grid", icon: <InlineIcon icon={iconLayoutGrid} /> },
167
- { value: "list", label: "List", icon: <InlineIcon icon={iconList} /> },
168
- ]}
169
- />
170
- ```
171
-
172
- - An indicator slides to the selected option. In light mode the track is a soft tint with no frame and the indicator
173
- is lifted by a shadow; in dark mode the indicator is lighter than the track, with a visible edge. The selected icon
174
- takes the primary color, so the choice is clear in every theme.
175
- - `size` (`sm`, `md`, `lg`), `tone="primary"` for a solid primary indicator, `block` to fill the width, `disabled` per option.
176
- - `labels="selected"`: icons only, and the selected option grows to show its label. Every option needs an icon;
177
- hidden labels are still read by screen readers and shown as tooltips.
178
- - `design="list"`: the options stacked as full-width rows, for settings pages and side panels. Each row takes an
179
- `icon`, a `description` on a second line, and a `badge` (a count) at the end. The selected row is tinted, marked
180
- by a bar at its start, and its label turns bold; `tone="primary"` tints it with the primary color. ↑ and ↓ move
181
- the selection. The nav design is the old project's `nav-tabs`, this one its `list-tabs`.
182
- - `multiple`: several at once. `value` is an array and `onChange` hands back the chosen values in option order.
183
- Each chosen option is raised as the one choice's indicator is, the options wrap onto more rows when they do not
184
- fit (never narrower than tall, so single letters line up), and on a touch screen each grows to a medium control's
185
- height. They are checkboxes: Tab moves between them and Space toggles one.
186
- - Native radio buttons: arrow keys move the selection, and `name` makes it part of a form. Needs `aria-label` or `aria-labelledby`.
187
- `form` sets the form the choice belongs to, as in HTML; an id that matches no form keeps a switch that is not a
188
- value (the Markdown editor's views) out of the form around it.
189
- - For choosing a value. For switching page panels, use tabs with panels (planned).
190
-
191
- ### Split button
192
-
193
- ```tsx
194
- import { SplitButton } from "nexus-shared/client";
195
-
196
- <SplitButton storageKey="pull-request" onAction={value => create(value)} options={[
197
- { value: "create", label: "Create pull request", description: "Open a pull request that is ready for review" },
198
- { value: "draft", label: "Create draft pull request", description: "Cannot be merged until marked ready for review" },
199
- ]} />
200
- ```
201
-
202
- - The main button runs the chosen action; the arrow opens the alternatives as a radio menu. Picking one makes it the
203
- main button, and with `storageKey` the browser remembers it (`localStorage`, `nx-split:<key>`), so the next visit
204
- starts with it. `runOnSelect` also runs the pick at once, for Save / Save and close.
205
- - `variant`, `size`, `loading`, `disabled`, `block` as `Button`; `type="submit"` with `name` submits a form and sends
206
- the chosen value; `value` and `onValueChange` for a controlled choice; `align` and `menuLabel` for the menu.
207
-
208
- ### Menus
209
-
210
- ```tsx
211
- import { ContextMenu, Menu } from "nexus-shared/client";
212
-
213
- <Menu items={[
214
- { value: "edit", label: "Edit", icon: <InlineIcon icon={iconPencil} />, shortcut: "Ctrl+E" },
215
- { separator: true },
216
- { value: "delete", label: "Delete", icon: <InlineIcon icon={iconTrash} />, tone: "danger" },
217
- ]} onSelect={value => run(value)}>
218
- <Button iconEnd={<InlineIcon icon={iconChevronDown} />}>Actions</Button>
219
- </Menu>
220
-
221
- <ContextMenu items={rowItems} onSelect={value => run(value, row)}>
222
- <table>…</table>
223
- </ContextMenu>
224
- ```
225
-
226
- - **Native popovers.** The list is a `[popover]` element: it sits above everything, closes with Esc or a click
227
- outside, and needs no library. It opens under its button (above it when there is no room), lined up with the
228
- button's `align="start"` or `"end"` edge, and follows the page as it scrolls.
229
- - **Items are params:** `{ value, label, description?, icon?, shortcut?, tone?: "danger", disabled?, href?, target?, checked?, title? }`,
230
- plus `{ separator: true }` and `{ heading }`. An item with `href` is a link. The trigger is any element that
231
- renders a button and passes its props on, such as `Button`.
232
- - `kind="radio"` or `"checkbox"` adds a check column and `aria-checked`; `closeOnSelect={false}` keeps a checkbox
233
- menu open while options are toggled.
234
- - **Keys:** arrows move between items (wrapping, skipping disabled ones), Home and End jump, a letter jumps to the
235
- next item starting with it, Enter or Space chooses, Esc closes and returns focus to the button, Tab closes and
236
- moves on. Focus follows a moving pointer, so the keys carry on from the hovered item.
237
- - `ContextMenu` opens at the pointer on right-click, or under the focused element on Shift+F10. Its wrapper takes no
238
- space (`display: contents`), so wrap a table once and note the clicked row in the row's own `onContextMenu`.
239
- - `placeUnderAnchor`, `placeAtPoint`, and `followAnchor` (`nexus-shared/client`) place custom popovers the same way.
240
-
241
- ### Toast messages
242
-
243
- ```tsx
244
- import { Toaster, toast } from "nexus-shared/client";
245
-
246
- // Once, in the root layout
247
- <Toaster position="top-right" />
248
-
249
- // Anywhere: components, services, fetch helpers
250
- toast.success("Booking confirmed", "Room 204, 3 nights");
251
- toast.error("Could not save", { message: error.message, action: { label: "Retry", onClick: save } });
252
- toast.promise(saveBooking(), { loading: "Saving…", success: "Saved", error: e => `Not saved: ${e.message}` });
253
- ```
254
-
255
- - `toast(title, options?)`, `toast.info`, `.success`, `.warning`, `.error`, `.loading` (spinner, no timer), and
256
- `.promise`. Options: `message`, `duration` (default 5000 ms, 0 keeps it; pauses while hovered or focused), `action`,
257
- `closable`, `icon`, `position`, `onClose`, and `id` to update a toast in place. `toast.dismiss(id?)` closes one or all.
258
- - Each toast is a native popover of its own, so it sits above open dialogs; the newest is nearest the edge, and at
259
- most `max` (default 5) show per position. Warnings and errors use `role="alert"`, the rest `role="status"`.
260
-
261
- ### Popup
262
-
263
- ```tsx
264
- import { Popup, definePopupRules, openPopup } from "nexus-shared/client";
265
-
266
- <Popup open={open} onClose={() => setOpen(false)} title="Edit booking" description="NX-2048 · Room 204"
267
- footer={<><Button onClick={() => setOpen(false)}>Cancel</Button><Button type="submit" form="booking" variant="primary">Save</Button></>}>
268
- <form id="booking" onSubmit={save}>…</form>
269
- </Popup>
270
-
271
- // From code, with a handle; needs <PopupHost /> in the layout. Never two popups with one key: opening the key again
272
- // replaces its popup (a fresh one), or with reopen: "restore" brings it back as it was.
273
- const popup = openPopup({ key: "room-edit-204", reopen: "restore", title: "Room 204", content: <RoomDetails id={204} />, footer: p => <Button onClick={() => p.close()}>Close</Button> });
274
- popup.minimize(); popup.restore(); popup.maximize();
275
- await popup.closed;
276
-
277
- // Rules by key, once, in a client module; options given to openPopup win over them
278
- definePopupRules({
279
- "room-new": { reopen: "replace" }, // Add: a fresh form every time (the default)
280
- "room-edit-*": { reopen: "restore" }, // Edit: back as it was, with what was typed
281
- "night-report": { minimizable: false, maximizable: false, movable: false },
282
- });
283
-
284
- // The page in charge of the window state
285
- <Popup open={open} windowState={state} onWindowStateChange={setState} … />
286
- ```
287
-
288
- - Built on the native `<dialog>` shown modally: above everything, the page behind dimmed and locked (without a layout
289
- shift), Tab kept inside, focus returned on close. `onClose(reason)` says whether the close button, Esc, the
290
- backdrop, or code asked; the page decides by setting `open`. `closable`, `closeOnEscape`, `closeOnBackdrop` turn
291
- those off for a popup that must be answered.
292
- - `size`: `xs` 360, `sm` 440, `md` 560, `lg` 720, `xl` 960, `full`. The body scrolls while the title and footer
293
- stay; `flush` removes the body padding for tables. `initialFocus` picks the first focused element (default: the
294
- first control in the body or footer). The content mounts only while open, so forms start fresh.
295
- - Popups open other popups and message boxes above themselves; Esc closes the top one.
296
- - A **title bar** like a desktop window's: 32px high (40px on touch screens), the icon, the title, and the description
297
- on one line (cut with …), and the window buttons filling its top right corner, 46 x 32 each: **minimize**,
298
- **maximize** (restore down when maximized), **close** (red on hover). `variant="dialog"` gives the larger heading
299
- instead (the icon in a circle, the description under the title), which message boxes use. `minimizable` (default: when `closable`, so a popup that must be answered cannot be put aside),
300
- `maximizable` and `movable` (default true); message boxes have only close.
301
- - **Minimized**, the dialog closes so the page can be used, and a bar waits at the bottom right with the popup's icon and
302
- title; its content stays mounted, so typed text, scroll, and the focused field are there when the bar brings it back
303
- (maximized again if it was). Several stack behind the newest with a count that opens the whole list (Esc or a press
304
- elsewhere closes it). An open popup's backdrop covers the dock. `<PopupHost />` renders the dock; without a host
305
- there is no minimize button. Bottom-right toasts stack above it (`--nx-dock-space`).
306
- - **Maximized**, it fills the window edge to edge; a double-click on the title bar does the same, and restore down puts
307
- it back where it was.
308
- - **Moved** by its title bar (not its buttons), kept inside the window, pulled back in when the window shrinks; Esc
309
- during a drag puts it back; dragging a maximized popup restores it under the pointer, as desktop windows do. Each
310
- opening starts centered in `defaultWindowState`.
311
- - **Opened again** while it is still open or minimized, a popup never doubles up. `openPopup` with a `key` whose popup
312
- is there closes that popup (`onClose` and `closed` say `replaced`) and opens a fresh one: `reopen: "replace"`, the
313
- default, right for an Add form, so Add, minimize, Add again cannot pile up popups. `reopen: "restore"` brings the
314
- old one back as it was and returns its handle, right for editing one record. A page popup opens again through the
315
- button that opened it, with the same `reopen` choice. Popups without a key are separate every time.
316
- - **Asking before a close**: `openPopup({ beforeClose })` runs when the user closes the popup (its close button, Esc,
317
- the backdrop, or its bar on the dock); return `false`, or a promise of it, to keep it open, such as after
318
- `confirmDiscard(form)` asked "Discard your changes?". Closing from code does not ask.
319
- - **Rules by key**: `definePopupRules({ "booking-new": {…}, "booking-edit-*": {…} })` sets `reopen`, `minimizable`,
320
- `maximizable`, `movable`, `closable`, `closeOnEscape`, `closeOnBackdrop`, `size`, and `defaultWindowState` for every
321
- `openPopup` with that key. A name ending in `*` matches keys that start with the rest (`*` alone, every key); all
322
- matching rules apply, an exact key over a pattern and a longer pattern over a shorter one; options given to
323
- `openPopup` win. It returns a function that removes the rules; `popupRuleFor(key)` shows what a key gets.
324
- - With motion, minimize flies to the dock and maximize grows from the old box; with reduced motion, both happen in the
325
- same frame.
326
-
327
- ### Drag helpers
328
-
329
- ```tsx
330
- import { clampToBounds, makeDraggable, moveWithin, useDraggable, viewportBounds } from "nexus-shared/client";
331
-
332
- useDraggable(cardRef, { bounds: "parent" }); // the card moves, kept inside its positioned parent
333
- useDraggable(barRef, { target: () => cardRef.current, bounds: "parent", axis: "x", keepVisible: 40 });
334
- const stop = makeDraggable(titleBar, { target: panel, bounds: document.querySelector(".board") }); // without React
335
- moveWithin(card, { x: 10, y: 0 }, { bounds: "parent" }); // the keyboard way
336
- clampToBounds({ left: -20, top: 40, width: 300, height: 200 }, viewportBounds()); // { x: 0, y: 40 }
337
- ```
338
-
339
- - The popup's title bar is built on these; any component can use them. `bounds`: `viewport` (default), `parent`,
340
- an element (inside its borders), a box, a function, or `null`. `margin`, `keepVisible` (how much must stay
341
- inside when it may partly leave), `axis`, `threshold` (default 3 px, so clicks stay clicks), `cancel` (controls in
342
- the handle that do not drag).
343
- - The position is an offset from the element's place in the layout, written to CSS `translate` by default, or to CSS
344
- variables (`cssVariablePosition`), or anywhere through `position: { get, set }`, so a move never shifts the layout.
345
- - `onStart` may cancel or change the target first; `onEnd` says whether Esc cancelled. A drag selects no text, and
346
- the click that ends it does not reach what lies under the pointer. The handle needs `touch-action: none`
347
- (`makeDraggable` and `useDraggable` set it).
348
-
349
- ### Message box
350
-
351
- ```tsx
352
- import { messageBox } from "nexus-shared/client";
353
-
354
- if (await messageBox.confirm({ title: "Delete booking NX-2048?", message: "This cannot be undone.", tone: "danger", confirmLabel: "Delete" })) …
355
- await messageBox.alert({ title: "Saved", tone: "success" });
356
- const name = await messageBox.prompt({ title: "Rename the room", label: "Name", defaultValue: room.name });
357
- const choice = await messageBox.show({ title: "Unsaved changes", tone: "warning", buttons: [{ value: "discard", label: "Don't save" }, { value: "save", label: "Save" }] });
358
- ```
359
-
360
- - Small popups through `<PopupHost />`, the tone in the icon: `info` (alert default), `question` (confirm default),
361
- `success`, `warning`, `danger`, or `neutral` for none. A danger box makes the last button red and focuses the first
362
- one, so Enter cannot delete by accident.
363
- - `confirm` resolves `false` and `show` resolves `null` when the box is dismissed with Esc, the close button, or a
364
- click outside; `prompt` resolves `null` when cancelled and refuses an empty answer while `required` (the default).
365
-
366
- ### Button hover tones
367
-
368
- `hoverTone` keeps a `secondary` or `ghost` button neutral until it is hovered or focused from the keyboard, then
369
- colors its border, a light background, and its text. Tones: `primary`, `danger`, `success`, `warning`, `info`.
370
- Good for rows of actions, where full-color buttons would be too loud:
371
-
372
- ```tsx
373
- <Button hoverTone="danger" icon={<InlineIcon icon={iconTrash} />}>Delete</Button>
374
- <Button variant="ghost" size="sm" hoverTone="primary" aria-label="Edit" icon={<InlineIcon icon={iconPencil} />} />
375
- ```
376
-
377
- Every tone meets WCAG AA contrast on its light background in all themes.
378
-
379
- ### Text box
380
-
381
- ```tsx
382
- import { TEXT_PATTERNS } from "nexus-shared";
383
- import { TextBox, TextArea } from "nexus-shared/client";
384
-
385
- <form onSubmit={save}>
386
- <TextBox name="email" type="email" label="Email" required pattern={TEXT_PATTERNS.email} icon={<InlineIcon icon={iconMail} />} />
387
- <TextBox name="password" type="password" label="Password" required minLength={8} />
388
- <TextArea name="notes" label="Notes" maxLength={500} showCount autoResize />
389
- <Button type="submit" variant="primary">Save</Button>
390
- </form>
391
- ```
392
-
393
- - **Uncontrolled by default**, so typing re-renders nothing. `onValueChange(value)` reports each change; `value` makes it controlled.
394
- - **Validation:** `required`, `minLength`, `maxLength`, `pattern` (a `TEXT_PATTERNS` entry carries its own message), and
395
- `validate(value)` for custom rules. Messages show after a field is left with text in it (`validateOn`: `blur`, `change`,
396
- `submit`). Inside a `<form>`, an invalid field blocks submit, every message shows, and the first invalid field takes focus.
397
- `error` shows a message from outside, such as the server.
398
- - **Read-only says why.** Every input with `readOnly` shows a lock, and a short note for 2.5 seconds when someone tries
399
- 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
400
- selecting and copying text work as usual. Esc, leaving the field, or the timeout hide the note; `readOnlyMessage`
401
- changes its text.
402
-
403
- | Input | Lock | Tries that show the note | Default note |
404
- |---|---|---|---|
405
- | `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. |
406
- | `NumberBox` | In place of the − and + buttons | The same, and ↑ ↓ Page Up Page Down | Read-only: this number cannot be changed. |
407
- | `DatePicker`, `DateRangePicker` | In place of the calendar button | The same as a text box, and ↓ | Read-only: this date (these dates) cannot be changed. |
408
- | `TimePicker` | In place of the clock button | The same as a text box, and ↓ | Read-only: this time cannot be changed. |
409
- | `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. |
410
- | `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. |
411
- | `CheckBox`, `Switch` | After the label | A click or Space | Read-only: this option (this setting) cannot be changed. |
412
- | `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. |
413
- | `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. |
414
-
415
- Nothing renders until an attempt, and an attempt re-renders only the note, so read-only forms of any size cost
416
- nothing extra. `useReadOnlyNotice` gives a custom control the same lock and note.
417
- - **Props are params:** plain, named like HTML attributes, and serializable, so forms and CRUD pages can describe
418
- fields as objects. Design notes for inputs, forms, and CRUD: [src/Components/Inputs/README.md](src/Components/Inputs/README.md).
419
-
420
- ### Number box
421
-
422
- ```tsx
423
- import { NumberBox } from "nexus-shared/client";
424
-
425
- <NumberBox name="nights" label="Nights" stepper="split" required min={1} max={30} defaultValue={1} />
426
- <NumberBox name="rate" label="Rate per night" prefix="Rs" required min={500} decimals={2} fixedDecimals grouping locale="en-IN" />
427
- <NumberBox name="discount" label="Discount" suffix="%" stepper min={0} max={50} step={5} />
428
- ```
429
-
430
- - **Only a number gets in.** Typing accepts digits, one decimal separator, and a minus sign when `min` is below 0 (or
431
- not set). `decimals` stops typing after that many decimals; pasted text such as "Rs 1,250.50" keeps its number, and
432
- text with no single clear number is ignored rather than guessed at.
433
- - **Formats by locale:** `grouping` shows 12,50,000.00 (`en-IN`) or 1.250.000,00 (`de-DE`) while the box is not
434
- being edited; separators come off on focus. The default locale is `en-US`, so server and browser render the same text.
435
- Inside a `<form>`, a grouped box sends the plain number through a hidden input with its `name`.
436
- - **Steps:** ↑ and ↓ (Shift or Page Up/Down for ten), and `stepper` buttons that repeat while held. Values move along
437
- the grid of `step` from `min` and stay within the limits.
438
- - **Values:** `onValueChange(value)` gets a `number`, or `null` when empty; `validate(value)` too. `clamp` moves an
439
- out-of-range value to the nearest limit on blur instead of showing a message.
440
- - `npm run test:numbers` checks the number helpers (formatting, parsing, paste, rounding, stepping, messages).
441
-
442
- ### Checkbox and radio
443
-
444
- ```tsx
445
- import { CheckBox, CheckBoxGroup, RadioGroup, Switch } from "nexus-shared/client";
446
-
447
- <RadioGroup name="roomType" label="Room type" required design="cards" columns={3} options={roomTypes} />
448
- <CheckBoxGroup name="amenityIds" label="Amenities" columns={3} selectAll options={amenities.map(a => ({ value: a.id, label: a.name }))} />
449
- <Switch name="breakfast" label="Breakfast included" labelPosition="start" />
450
- <CheckBox name="terms" label="I accept the booking terms" required />
451
- ```
452
-
453
- - **Native inputs, drawn with CSS.** Each option is one `<input>`, so forms, arrow keys (radios), and screen readers
454
- work as usual. Box edges use the subtle text color, which meets 3:1 contrast on panels in every theme.
455
- - **Options are params:** `{ value, label, description?, icon?, disabled? }`. Values can be strings or numbers and
456
- come back from `onValueChange` with their type.
457
- - **Limits:** `required`, `min`, `max`, `validate`. At `max` a checkbox group disables the other options and shows a
458
- count. Messages follow the text box: after leaving a changed group, or on submit, where the first invalid option takes focus.
459
- - **Fast:** uncontrolled by default, so clicking re-renders nothing; 10,000 checkboxes mount in 0.5 to 0.7 s
460
- in the development build.
461
-
462
- ### Slider
463
-
464
- ```tsx
465
- import { RangeSlider, Slider } from "nexus-shared/client";
466
-
467
- <Slider name="discount" label="Discount" max={50} suffix="%" defaultValue={10} />
468
- <RangeSlider name="price" label="Price per night" min={1000} max={30000} step={500} minDistance={1000}
469
- prefix="Rs " grouping locale="en-IN" onValueCommit={([from, to]) => loadRooms(from, to)} />
470
- ```
471
-
472
- - **Native range inputs** under a drawn track, so arrow keys, Page Up/Down, Home/End, touch, and screen readers work.
473
- Each thumb announces its formatted value (`aria-valuetext`).
474
- - **Values read like a number box:** `prefix`, `suffix`, `decimals`, `grouping`, `locale`, or a `format` function.
475
- - **`onValueChange`** follows every step; **`onValueCommit`** fires once when a thumb is released or a key step ends,
476
- the moment to call an API from a filter.
477
- - **Range:** two thumbs that never cross and keep `minDistance` apart; a click on the track moves the nearer thumb.
478
- A form receives two values under the name.
479
- - **Marks** label points under the track and move the thumb when clicked; **`tooltip`** shows the value above the thumb.
480
- - Dragging writes the fill and the text straight to the DOM, so nothing re-renders.
481
-
482
- ### Date picker and date range picker
483
-
484
- ```tsx
485
- import { todayIsoDate } from "nexus-shared";
486
- import { Calendar, DatePicker, DateRangePicker, setDatePreferences } from "nexus-shared/client";
487
-
488
- <DatePicker name="checkIn" label="Check-in" required min={todayIsoDate()} onValueChange={iso => …} />
489
- <DatePicker name="fiscalYearStart" label="Fiscal year start" calendar="bs" format="YYYY/MM/DD" />
490
- <DateRangePicker startName="checkIn" endName="checkOut" label="Stay" required min={todayIsoDate()} minDistance={1} maxDistance={30} />
491
- <Calendar mode="range" months={2} range={range} onRangeChange={setRange} min={todayIsoDate()} />
492
-
493
- // A settings page
494
- setDatePreferences({ calendar: "bs", format: "D MMMM YYYY", script: "devanagari" });
495
- ```
496
-
497
- - **The value is a day, not a time.** An ISO date `"2026-09-17"` in the Gregorian calendar, whatever calendar is
498
- shown: what a form sends (through a hidden input with the field's `name`), what an API stores (`DateOnly`), and
499
- what means the same day in every time zone. `toIsoDate` reads a `Date` in local time and an ISO date-time with a
500
- zone as the local day of that instant; `isoDateToDate(iso)` gives UTC midnight, `isoDateToDate(iso, "local")` local midnight.
501
- - **English or Nepali from the user's settings.** `useDatePreferences` reads `nx-date-calendar` (`ad` or `bs`),
502
- `nx-date-format`, and `nx-date-script` (`latin` or `devanagari`) from local storage, with the English calendar in
503
- `YYYY-MM-DD` as the default; `setDatePreferences` changes every date component on the page, and other tabs follow.
504
- A box can fix its own `calendar`, `format`, or `script`.
505
- - **Formats** use the tokens `YYYY` `YY` `MMMM` `MMM` `MM` `M` `DD` `D` `dddd` `ddd`. Typing follows the format's
506
- order and is lenient: `17/9/2026`, `Sep 17, 2026`, `1 asoj 2083`, `२०८३-०६-०१`; a four-digit number is the year wherever it stands.
507
- - **Bikram Sambat** covers BS 1975 to 2100 (AD 1918 to 2044), as the majority of five published tables
508
- (`NepaliCalendar.ts`), converted through day numbers with no `Date` objects. `npm run test:dates` checks known
509
- dates and round-trips every day.
510
- - **DateRangePicker** holds a start and an end in one box, written as "2026-09-17 – 2026-09-24" in the user's
511
- format. Pick the start and then the end in a calendar of two months (one when the window is narrow), with the
512
- days between lit as the pointer moves, or type both dates around a dash, an arrow, or "to". The values are two
513
- ISO dates: `onValueChange` gives `[start, end]` (`null` for an end not chosen yet), and a form sends `startName`
514
- and `endName` as two fields, or both under `name`. Rules: `required`, `min`, `max`, `minDistance` and
515
- `maxDistance` (days from start to end, the nights of a stay), and `isDateDisabled`, which also refuses a range
516
- that includes such a day (a booked night). `validateDateRange` is the pure check behind it.
517
- - **Read-only** boxes have no calendar: a lock sits where the calendar button was, and a click, a typed key, or ↓
518
- shows a note under the box, as in every read-only input (see [Text box](#text-box)).
519
- - **Calendar** is the pickers' panel and a component of its own: month and year menus, arrow keys (Page Up and Page
520
- Down for months, with Shift for years), `min`, `max`, `isDateDisabled`, `weekStart`, `weekendDays` (Saturday is
521
- red in the Nepali calendar), `mode="range"` with a live preview, `months={2}` side by side (a page passes 1 on
522
- a phone; the calendar does not measure), `month` and `onMonthChange` to load a month's events, and `renderDay`
523
- to draw markers in a cell.
524
-
525
- ### Time picker
526
-
527
- ```tsx
528
- import { TimePicker, setDatePreferences } from "nexus-shared/client";
529
-
530
- <TimePicker name="arrival" label="Arrival" required min="14:00" max="22:00" step={1800} view="list" onValueChange={iso => …} />
531
- <TimePicker name="shiftStart" label="Shift start" min="22:00" max="06:00" format="HH:mm" />
532
- <TimePicker name="spa" label="Spa slot" view="list" step={1800} isTimeDisabled={iso => booked.has(iso)} />
533
-
534
- // A settings page
535
- setDatePreferences({ timeFormat: "HH:mm" });
536
- ```
537
-
538
- - **The value is a time of day, not an instant.** An ISO time on a 24-hour clock, `"14:30"` (`"14:30:15"` when
539
- `step` has seconds), with no date and no zone: what a form sends through a hidden input with the field's `name`,
540
- and what a .NET `TimeOnly` stores. `toIsoTime` reads `"9:05"`, `"14:30:00.000"`, a `Date` (its local time), and an
541
- ISO date-time (with a zone, the local time of that instant; without one, its time part).
542
- - **12 or 24 hours from the user's settings.** `nx-time-format` in local storage, next to the date settings, holds
543
- a format of tokens `HH` `H` `hh` `h` `mm` `m` `ss` `s` `A` `a`; the default is `hh:mm A` (02:30 PM).
544
- `useDatePreferences` returns it as `timeFormat`, and `setDatePreferences({ timeFormat })` changes every time picker
545
- on the page. Nepali dates in Devanagari bring Nepali digits and पूर्वाह्न/अपराह्न. A box can fix its own `format` or `script`.
546
- - **Typing** works on either clock: `2:30 pm`, `2.30p`, `230pm`, `14:30`, `1430`, `9`, `१४:३०`. Without AM or PM the
547
- hours are read on a 24-hour clock. Leaving the box writes the time in the format.
548
- - **Columns** (the default) show hours, minutes, seconds when the step needs them, and AM/PM; the chosen items line
549
- up on the middle row, a pick in any column changes the time at once, and ↑ ↓ ← → Home End Page Up/Down move through
550
- them. **`view="list"`** shows one slot per `step` (every 30 minutes for steps under 5 minutes); a click or Enter
551
- picks and closes.
552
- - **Rules:** `required`, `min` and `max` (with `max` earlier than `min` the range runs past midnight, 22:00 to 06:00),
553
- `step` in seconds counted from midnight as in HTML, `isTimeDisabled` for booked slots, and `validate`. Items outside
554
- the rules are dimmed in the panel; a typed time outside them gets a message such as "Arrival must be in steps of 30
555
- minutes, like 02:00 PM or 02:30 PM." `validateTime` is the pure check behind it. `npm run test:times` checks the helpers.
556
- - **Read-only** boxes have no panel: a lock sits where the clock button was, as in every read-only input.
557
-
558
- ### Date and time picker
559
-
560
- ```tsx
561
- import { DateTimePicker } from "nexus-shared/client";
562
-
563
- <DateTimePicker name="pickupAt" label="Airport pickup" required min={new Date()} step={900} minTime="05:00" maxTime="23:00" />
564
- <DateTimePicker name="checkInAt" label="Check-in" timeZone="Asia/Kathmandu" defaultTime="14:00" />
565
- <DateTimePicker name="spaAt" label="Spa slot" view="list" step={1800} isTimeDisabled={(time, date) => booked.has(`${date} ${time}`)} />
566
- ```
567
-
568
- - **The value is a moment, in UTC.** `"2026-09-17T08:45:00.000Z"`, the shape `toISOString` writes, is what the hidden
569
- input sends and `onValueChange` gives, whatever the user sees; the same moment is 02:30 PM in Kathmandu and 04:45 AM
570
- in New York, so no time zone can move a saved value. Store it as UTC: a .NET `DateTimeOffset`, or a `DateTime` of
571
- kind `Utc`. For a day with no time use `DatePicker`; for a time of day with no date, `TimePicker`.
572
- - **Values in** (`toUtcDateTime`): an ISO date-time with `Z` or an offset is that moment; one **without a zone is read
573
- as UTC**, the way a UTC `DateTime` loaded from a database often arrives without its `Z`; a date alone is the first
574
- moment of that day where the user is; a `Date` or milliseconds are that moment. .NET's seven digits of fractions are read.
575
- - **Shown in the user's zone and settings.** The browser's time zone, and the date (`nx-date-calendar`,
576
- `nx-date-format`, `nx-date-script`) and time (`nx-time-format`) settings of the other pickers: "2026-09-17 02:30 PM",
577
- or "२०८३-०६-०१ ०२:३० अपराह्न". The panel names the zone ("Kathmandu · UTC+05:45"). `timeZone` fixes a zone, such as a
578
- 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
579
- after hydration rather than another zone's time for a moment; the hidden input holds the value from the start.
580
- - **No data loss.** A value changes only when the user changes what it shows: seconds and milliseconds the box does not
581
- show stay as they came (a stamp from the server is sent back exactly), and retyping or re-picking what it shows keeps
582
- the moment. Where clocks go back, one hour happens twice: a box holds whichever it was given, an edit inside that hour
583
- keeps its offset, and a new pick is the first of the two. Where clocks go forward, a skipped time moves on (02:30
584
- becomes 03:30), as `Date` and Temporal do.
585
- - **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`,
586
- `Sep 17, 2026 at 2:30 PM`, `2026-09-17T14:30`, `२०८३-०६-०१ १४:३०`. A date alone takes `defaultTime` (default
587
- `minTime`, or midnight). Leaving the box writes it in the settings.
588
- - **The panel** is the calendar and the time columns (or `view="list"` slots) side by side, as tall as each other, with
589
- 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
590
- hours, and Tab runs through the calendar, the columns, and the buttons. On a phone the time goes under the calendar,
591
- and a panel taller than the room scrolls, moving to the time once a day is picked.
592
- - **Rules:** `required`; `min` and `max` as moments (days outside are dimmed, and times outside on the first and last
593
- day); `minTime` and `maxTime` as opening hours on every day (past midnight when `maxTime` is earlier); `step` in
594
- seconds from each midnight; `isDateDisabled(date)` and `isTimeDisabled(time, date)` with ISO dates and times where the
595
- user is; `validate`. Messages read like the box: "Airport pickup must be between 05:00 AM and 11:00 PM."
596
- `validateDateTime(value, rules, fieldName, options)` is the pure check. `npm run test:datetimes` checks the helpers
597
- against `Date` in eight zones, including half-hour daylight saving and a skipped day.
598
- - **Read-only** boxes have no panel: a lock sits where the button was, as in every read-only input.
599
-
600
- ### Dropdown
601
-
602
- ```tsx
603
- import { Dropdown } from "nexus-shared/client";
604
-
605
- <Dropdown name="roomType" label="Room type" required options={roomTypes} />
606
- <Dropdown name="amenities" label="Amenities" multiple max={6} options={amenities} /> // { value, label, icon: "wifi" }
607
-
608
- // Rows like a data table
609
- <Dropdown name="assignee" label="Assign to" options={staff} valueKey="id" labelKey="fullName"
610
- columns={[{ key: "photo", type: "avatar" }, { key: "username", header: "Username" }, { key: "fullName", header: "Full name" }]} />
611
- <Dropdown name="country" label="Country" options={countries} valueKey="code" labelKey="name"
612
- columns={[{ key: "flag", type: "image" }, { key: "code", header: "Code", type: "code" }, { key: "name", header: "Country" }]} />
613
-
614
- // A server list, editing a saved value
615
- <Dropdown name="guestId" label="Guest" valueKey="id" labelKey="fullName" defaultValue={booking.guestId} selectedOptions={[booking.guest]}
616
- loadOptions={({ search, skip, pageSize, signal }) => api.guests({ search, skip, take: pageSize }, signal)} /> // { items, total }
617
- ```
618
-
619
- - **Options are any objects.** `{ value, label, description, icon, disabled }` by default (a `ChoiceOption`), or API rows
620
- read through `valueKey`, `labelKey`, `descriptionKey`, and `iconKey`. Values keep their type; a number and its text
621
- 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.
622
- - **Columns** turn rows into cells that line up down the list (one grid with subgrid rows), with headings that stay on top.
623
- Types: `text`, `muted`, `code`, `avatar` (a photo URL, or initials of the label on a soft tone), `image` (4:3: flags,
624
- logos), and `icon` (a Tabler name); `width`, `align`, and `render` for the rest. The first picture leads the value in
625
- the box and in chips.
626
- - **Search** matches every typed word in any order, with or without Latin accents, in the label, description, value, and
627
- text columns (`searchKeys` to change). It is on with `loadOptions` or more than 8 options; without it, letters jump to
628
- an option as in a native select. A local list renders 100 rows and adds more while it scrolls: 10,000 options open in
629
- about 30 ms.
630
- - **Server pages:** `loadOptions({ search, page, pageSize, skip, signal })` returns `{ items, total?, hasMore? }`. The first
631
- page loads when the list opens (placeholder rows, a spinner in the box), the next near the end ("Loading more…"), a new
632
- search 250 ms after typing stops, aborting the request before it. A failure shows Retry. `loadKey` drops the loaded
633
- pages, for a list that depends on another field. `loading` shows the same loaders when the page fetches `options` itself.
634
- - **Saved values the list can no longer offer** always show, and are never offered again once removed:
635
- - on a server page not loaded: `selectedOptions` gives their items (a record usually carries the name), or
636
- `resolveOptions(values)` looks them up (a placeholder shows until it answers);
637
- - inactive items (`inactive: true` or `isActive: false`, or `isOptionInactive`): never in the list, shown only as a value;
638
- - values missing from `options`: tagged **Unavailable**, or **Not found** when nothing is known but the value.
639
-
640
- In the list they sit on top under **Selected** with their tag; in the box they carry the tag, or show as dashed chips.
641
- A value only on another server page has no tag: a search finds it as a normal option.
642
- - **Rules:** `required`, `min` and `max` for several (at `max` the other options are disabled), and `validate`, through
643
- `validateSelection`. A pick follows the same message timing as typing. `npm run test:dropdown` checks the helpers.
644
- - **Keys:** ↓ ↑ Page Up/Down Home End move past rows that cannot be picked, Enter picks, Space picks without search,
645
- Backspace in an empty box removes the last value, Esc closes (then clears the search), Tab closes and moves on.
646
- - **Read-only** boxes have no list: a lock sits where the list button was, chips lose their remove buttons, and a click or
647
- a key shows the note, as in every read-only input.
648
-
649
- ### Markdown editor
650
-
651
- ```tsx
652
- import { MarkdownView } from "nexus-shared";
653
- import { MarkdownEditor } from "nexus-shared/client";
654
-
655
- <MarkdownEditor name="description" label="Room description" defaultValue={room.description} defaultMode="split"
656
- height={320} heightKey="room-description" maxLength={5000} required />
657
-
658
- // Wherever the text is shown, also in a server component
659
- <MarkdownView value={room.description} />
660
- ```
661
-
662
- - **Write, split, and preview.** `defaultMode` (or `mode` with `onModeChange`) picks the view, and `modes` the views
663
- 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
664
- them. The preview renders as a React transition, so typing stays within a frame in a 2,000-line document.
665
- - **Toolbar and shortcuts:** heading, bold (Ctrl+B), italic (Ctrl+I), strikethrough (Ctrl+Shift+X), quote
666
- (Ctrl+Shift+.), code (Ctrl+E), code block, link (Ctrl+K), image, bulleted, numbered, and task lists (Ctrl+Shift+8,
667
- 7, L), table, and horizontal line. Marks wrap the selection or the word at the caret and come off when applied
668
- again. Edits go through the browser's own editing, so Ctrl+Z undoes them. `toolbar` picks and orders the buttons.
669
- - **While typing:** Enter continues a list (the next number, an empty task box) and ends it on an empty item; a URL
670
- pasted over selected text links it; a task ticked in the preview is ticked in the text.
671
- - **Height handle:** the status bar under the editor drags the height between `minHeight` and `maxHeight`, showing
672
- the height as it changes; its grip takes ↑ ↓ (Shift for more), Page Up/Down, Home, and End; a double-click goes back
673
- to `height`. `heightKey` remembers the user's height in local storage. Dragging writes straight to the DOM.
674
- - **Full screen** (`fullscreen`, on by default) lifts the editor into the top layer over a dimmed page, without
675
- moving it in the DOM, so undo history and the form stay. Tab stays inside, and Esc closes it.
676
- - **A form field like `TextArea`:** `name`, `required`, `minLength`, `maxLength`, `pattern`, `validate`, the status bar's
677
- word and character count, and read-only (a lock in the bar and a note under it). An invalid submit while the preview
678
- shows brings the text back and focuses it. The view switch belongs to no form (`TabButtons` `form`), so it is never sent.
679
- - **Safe to show anyone's text.** `parseMarkdown` (GitHub-flavored: tables, tasks, strikethrough, autolinks) never
680
- renders raw HTML, and `safeUrl` keeps only http, https, mailto, tel, and relative addresses for links, and raster
681
- data URLs for images. Links to other sites get `rel="nofollow noopener noreferrer"`.
682
- - **Helpers:** `markdownToPlainText` for excerpts and search, and the editing commands `applyMarkdownTool`,
683
- `continueMarkdownList`, `toggleMarkdownTask`, and `linkPastedUrl` for a custom editor. `npm run test:markdown`
684
- checks the parser and every command.
685
-
686
- ### Forms
687
-
688
- ```tsx
689
- import { TEXT_PATTERNS, type InputParams } from "nexus-shared";
690
- import { ApiForm, SubmitForm } from "nexus-shared/client";
691
-
692
- const ROOM_INPUTS: InputParams[] = [
693
- { type: "text", name: "number", label: "Room number", required: true, pattern: TEXT_PATTERNS.digits, span: 4 },
694
- { type: "number", name: "floor", label: "Floor", required: true, min: 0, span: 4 },
695
- { type: "dropdown", name: "type", label: "Type", required: true, options: ROOM_TYPES, span: 4 },
696
- { type: "tabular", name: "beds", label: "Beds", itemLabel: "Bed", inputs: BED_INPUTS }, // a list is an input too
697
- ];
698
-
699
- <ApiForm
700
- inputs={ROOM_INPUTS}
701
- loadValue={signal => api.get(ROOMS.details, { params: { id }, signal, toast: false }).then(unwrapApiResult)}
702
- api={ROOMS.update} // or in place: { module: NexusModule.Reservation, path: "Room/Update", method: "PUT" }
703
- draftKey={`hotel-3:user-8:room:${id}`}
704
- onSuccess={room => table.updateRow(room)}
705
- onCancel={close}
706
- />
707
- ```
708
-
709
- - **Params in, values out.** Every input is an entry of `inputs`, so a page, a CRUD component, or a server can describe a
710
- form with plain objects. Values come in as each input reports them (a date-time from an API becomes a date, "12.5"
711
- becomes 12.5), and fields the record came with (an `id`) stay in the values.
712
- - **Fast at any size.** The values live in a `FormStore` outside React. Typing renders only the input typed in; an input
713
- renders again only when a message it shows changes, or code sets its value. A keystroke costs about 5 µs in the store
714
- for a form of 200 inputs, and a list of 10,000 rows is not checked again on each keystroke (it checks itself).
715
- - **Checks on Save.** Each input's own rules (they also catch text that is not a date yet), the form's `validate` across
716
- inputs, and rows not on the page. Every message shows, the first problem takes focus (a table scrolls to its row), and
717
- the status line lists what needs attention, each name a link to its input. A rule message on an input shows once the
718
- input was left, and on Save.
719
- - **One save at a time.** Save waits with a spinner; another click, Enter, or Ctrl+S does nothing until it ends. A save
720
- makes the values the form's own ("Unsaved changes" clears; values typed meanwhile stay changes). `onSubmit` returns
721
- `{ ok: false, errors, message }` to say why not, and a thrown error shows its message.
722
- - **Server messages.** The server's own words are what the user reads. `ApiForm` shows **every** message a refusal
723
- carried - a Nexus answer's `errorMessages` (a list, or one message object, or one message and its codes at the top
724
- level) and ASP.NET Core problem details (`errors` by field) - each on the input its `inputName` names, matched without
725
- case and by the first part of a path (`Addresses[1].City`). Names that match no input of this form, messages that name
726
- none, and failures without fields show beside the buttons; nothing is swallowed and nothing is shown twice. A sentence
727
- of ours by HTTP status ("Someone else changed this record…") is the fallback for an answer that explained nothing.
728
- `messageCode` and `errorCode` ride along in `answer.errorMessages` for the log and for `onError` to react to, and are
729
- never shown as the sentence. An input's message clears when its value changes.
730
- - **Nothing lost.** `draftKey` keeps unsaved changes in IndexedDB (after a 600 ms pause, and at once when the page hides
731
- or the form leaves) and brings them back with a note and Discard; drafts changed on the server since are named. Cancel
732
- asks before it drops changes, and the page asks before it closes with some (`confirmLeave`, on without a draft).
733
- `form.load(values, { keepChanged: true })` refreshes a record without touching what the user changed.
734
- - `npm run test:forms` checks the store and the API helpers; `scripts/browser-checks/forms.cjs` in the template checks the
735
- three demo pages.
736
-
737
- ### API calls
738
-
739
- ```ts
740
- // api-modules.ts: the app's modules and where each one's root is (shared by the browser and the server)
741
- import { defineApiModules, NexusApiModules, NexusModule } from "nexus-shared";
742
-
743
- export const MODULES = defineApiModules(new NexusApiModules({ useProxy: process.env.NEXT_PUBLIC_API_PROXY !== "false" }));
744
- export const ROOMS = MODULES.controller(NexusModule.Reservation, "Room", "room");
745
-
746
- // In the browser
747
- import { api } from "nexus-shared/client";
748
- const rooms = await api.get<Room[]>(ROOMS.listing); // GET /api/app/reservation/Room/NormalListing
749
- await api.put(ROOMS.update, values, { subject: "Room 204" }); // toast: "Room 204 saved." or what failed and why
750
- await api.confirm(ROOMS.delete, { params: { id }, subject: "Room 204" }); // asks "Delete Room 204?" first
751
-
752
- // On the server (a server component, a route handler, a server action)
753
- import { serverApi } from "nexus-shared/server";
754
- const list = await serverApi.get<Room[]>(ROOMS.listing, { revalidate: 60, tags: ["rooms"] }); // straight to API_RESERVATION
755
-
756
- // app/api/app/[module]/[...path]/route.ts: the browser's way to every module
757
- export const { GET, HEAD, POST, PUT, PATCH, DELETE } = createApiProxy({ token: readAccessToken });
758
- ```
759
-
760
- - **Modules are the app's.** `ApiModules<TModule>` is an abstract class over the app's own module enum: implement
761
- `modules` (each module's value, its key in proxy URLs, its label for messages, where its root comes from) and
762
- `serverRoot(module)` (from the environment, a settings service, anything; may be async). Register it once with
763
- `defineApiModules`, and define endpoints through it (`MODULES.controller`, `MODULES.endpoint`), so a file that calls
764
- an endpoint has registered the modules on either side. `NexusApiModules` is the Nexus set (roots from `API_<MODULE>`,
765
- as in the previous project; `NexusModule.None` for the app's own `/api` routes) and runs when nothing is registered.
766
- The list is checked: keys and values must be unique, and keys fit in a URL.
767
- - **`NexusModule`'s numbers are the backend's `EModules` flags**, and a permission is a bitmask of them, so a value
768
- that disagrees with the backend by one bit grants or denies the wrong module in silence. They changed after
769
- 2.0.0: `Chat` took bit 6 and `Accounting` through `Portfolio` each moved up one. Code that uses the names needs
770
- nothing; anything holding a stored or hard-coded **number** does. [CHANGELOG.md](CHANGELOG.md) has the table.
771
- - **Module types from one place.** An app names its enum once, next to `defineApiModules`:
772
- `declare module "nexus-shared" { interface ApiRegister { module: HrModule } }`. Every endpoint, call, `ApiForm`,
773
- and CRUD then takes only those modules (`AppModule`), so `{ module: NexusModule.Reservation, … }` in an HR app is a
774
- type error. Without it, the modules are `NexusModule`.
775
- - **The proxy, on or off.** With `useProxy` (the default), the browser calls `/api/app/<module>/<path>` and the proxy
776
- route sends it on to the module's root with the user's token, which never reaches the browser; it strips the app's
777
- cookies and the backend's hop headers, refuses `..`, rewrites the backend's redirects, and answers failures the way
778
- the backend does. Off, the browser calls each module's `browserRoot` (public) and the proxy serves none.
779
- `proxies(module)` mixes the two, such as uploads straight to the Drive module and the rest through the proxy.
780
- - **One engine, two clients.** `api` and `serverApi` are two settings of `createApiClient`: it fills `{id}` in the path
781
- from `params` or the body, adds the query, headers, and token, waits up to `timeout` (60 s), tries a failed GET once
782
- more (a save never twice), and reads every answer as an `ApiResult` (`ok`, `status`, `result`, `message`,
783
- `errorMessages`, `errors`, `messageCode`, `errorCode`, `network`, `timeout`, `aborted`). A failed call is an answer,
784
- never a throw; an aborted one shows nothing. Next.js's own signals (`headers()` while prerendering, `redirect()`) pass
785
- through untouched.
786
- - **What the server said wins.** A refusal is shown in the backend's own words whenever it sent any:
787
- `{ isSuccess, code, errorMessages: [{ message, messageCode, errorCode, inputName }] }` becomes
788
- `result.errorMessages` in that order (one message object instead of a list, a single `errorMessage`, and an older
789
- answer's one message at the top level all read into the same list), `result.errors` holds the ones that name an input,
790
- and `result.message` is all of them joined for a toast. `messageCode` and `errorCode` are for the log and for a page to
791
- react to - never the sentence a user reads. The messages built from the HTTP status are the fallback for an answer that
792
- explained nothing.
793
- - **Answers of another shape.** Nexus answers and ASP.NET Core problem
794
- details are read out of the box. A backend that answers its own envelope registers one reader for the whole app:
795
-
796
- ```ts
797
- // Reads { success, message, data, error: { code, message, fields } } — the answer becomes ok, result, message, errors
798
- const read: ApiResponseReader = (body, status) => {
799
- const answer = body as BackendAnswer | null;
800
- if (answer?.success) return { ok: true, status, result: answer.data, message: answer.message };
801
- const fields = Object.entries(answer?.error?.fields ?? {}).map(([name, list]) => [name, list.join(" ")]);
802
- return { ok: false, status, message: answer?.error?.message, messageCode: answer?.error?.code, errors: Object.fromEntries(fields) };
803
- };
804
- configureApi({ read }); // the browser
805
- configureServerApi({ read }); // and the server, so both sides read one envelope
806
- ```
807
-
808
- It is the fallback of every call that does not bring a `read` of its own, which is what the components that call
809
- inside themselves need: a `Crud`'s lists and actions and an `ApiForm`'s save have no place to pass one. A call's own
810
- `read`, and an `ApiForm`'s `readResponse`, still win.
811
- - **Toasts in the browser.** Errors always, successes for POST, PUT, PATCH, and DELETE; the same failure twice at once
812
- shows once; `toast: false`, `true`, or `{ success, error, loading }` per call; `configureApi({ toast, headers, read,
813
- onSignedOut })` for the app. The server shows nothing and logs network failures, timeouts, and 5xx.
814
- - **One message builder.** `buildMessage(kind, { action, subject, count, status })` words every message, and
815
- `messages.success`, `.failure`, `.reason`, `.loading`, `.confirm`, `.label`, and `.forResult` are short ways to call
816
- it: "3 rooms deleted.", "Could not move Room 204 to the trash.", "You do not have permission to delete Room 204.". The
817
- texts and action words are one table: `configureMessages` rewords or translates all of them at once, and an action
818
- of the app's own is its words (`{ verb: "check in", done: "checked in", doing: "Checking in", object: "the guest" }`).
819
- - **A controller's lists.** `apiController` names the lists as the Nexus backend does: `listing` and `pagination` are the
820
- active records' (`NormalListing`, `NormalPagination`), and `lists.flag`, `lists.trash`, `pages.trash`, … the others
821
- (`FlagListing`, `TrashListing`, `TrashPagination`, …), for the CRUD component.
822
- - `npm run test:api` checks the builder, routes, modules (another app's enum, the proxy on and off), the engine, the
823
- server client, and the proxy; `scripts/browser-checks/api-calls.cjs` in the template checks `/demo/api`.
824
-
825
- ### Data table
826
-
827
- ```tsx
828
- import type { TableColumn } from "nexus-shared";
829
- import { DataTable, useTable } from "nexus-shared/client";
830
-
831
- const COLUMNS: TableColumn<Booking>[] = [
832
- { key: "no", label: "Booking", width: "7rem", movable: false, pinnable: false }, // stays first, and stays put
833
- { key: "guest.name", label: "Guest" }, // a path into the row
834
- { key: "amount", label: "Amount", format: "money", prefix: "Rs. ", group: "Amount details" }, // number, money, percent, date, …
835
- { key: "due", label: "Due", format: "money", prefix: "Rs. ", group: "Amount details" }, // one heading over both: the header is two rows
836
- { key: "status", label: "Status", options: [{ value: 1, label: "Confirmed", tone: "success" }] }, // a tag
837
- { key: "paid", label: "Paid", format: "boolean", resizable: false }, // keeps its width
838
- { key: "created", label: "Booked", format: "date-time", hidden: true }, // shown from the Columns menu
839
- ];
840
-
841
- const table = useTable<Booking>();
842
- <DataTable table={table} aria-label="Bookings" columns={COLUMNS} rows={bookings} selectable massActions={MASS} rowActions={row => ACTIONS(row)} stateKey="bookings" />
843
- <DataTable columns={COLUMNS} paging="server" load={(query, signal) => fetchPage(query, signal)} /> // { search, sort, page, pageSize }
844
- <DataTable columns={COLUMNS} rows={bookings} onImport={askForFile} moreActions={[{ id: "report", label: "Monthly report", onSelect: open }]} />
845
-
846
- table.upsert(saved); table.patch(7, { paid: true }, { flash: true }); table.remove([7, 8]); await table.reload();
847
- ```
848
-
849
- - **Columns are params**, one plain object each, as inputs are: `key` (a field or a path), `label`, `group`, `format`,
850
- `decimals`, `prefix`, `suffix`, `options` (labels for an enum's values; a `tone` draws a tag with a dot of its color),
851
- `value`, `render`, `text`, `width`, `minWidth`, `align`, `wrap`, `sortable`, `sortKey`, `searchable`, `hidden`,
852
- `hideable`, `movable`, `resizable`, `pinnable`, `pinned`. Numbers line up at the end in tabular figures; dates and
853
- times follow the user's settings; moments show in the user's zone once the page has hydrated; long text ends in … at
854
- the column's width.
855
- - **Columns in bands.** `group` is the heading above a column (`{ key: "amount", label: "Total", group: "Amount
856
- details" }`); the columns next to each other that name the same one share it, and the header reads in two rows, the
857
- bands on top and the columns' own headings under them. A column with no group keeps one heading as tall as both rows,
858
- and so do the numbers and checkbox column and the actions. A band is a heading and nothing more: it sorts nothing, has
859
- no menu and no edge to drag, and the columns stay one flat list, so hiding, ordering, widths, pins and the export are
860
- as they were. The bands are read off the order on screen: a column dragged out of its run takes its band's heading
861
- with it, and the run it left draws that heading again. With no group anywhere the header is one row.
862
- - **Rows three ways.** Given (`rows`) or loaded once (`load`), the table searches, sorts, and pages them itself: every word
863
- 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
864
- (`load` with `paging="server"`), each search (after 300 ms without typing, or Enter), sort, and page is one request; a
865
- newer query stops the one before, so a late answer never shows. A loader may answer without a promise (a cache), and
866
- the rows then paint with the table. `loadKey` starts over for another list behind the same table.
867
- - **What it shows while it waits.** Placeholders in the shape of the rows for the first rows; afterwards the rows stay,
868
- 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
869
- list has its own message and action; a search that finds nothing offers Clear search.
870
- - **The columns are the user's.** Drag a heading to another place, drag its right edge for its width (double-click the
871
- edge, or "Fit to content", to fit it, never under the column's `minWidth`), and use the caret on the heading to sort,
872
- pin it left or right, move it, or hide it; the Columns menu shows and hides them. A column opts out with
873
- `movable`, `resizable`, or `pinnable: false`, and `pinned` starts it on a side. The table itself can refuse all three
874
- (`reorderable`, `resizable`, `pinnable`). Once a width is set the columns are laid out from those widths, so one edge
875
- moves alone; "Reset columns" in the options menu puts everything back.
876
- - **One options menu (⋮)**: the columns shown as a CSV (`exportable`, named by `exportName` or the table's label), a
877
- print view of the same in a frame of its own (`printable`), Import (`onImport`), and any `moreActions` of the page.
878
- A client table writes every row the search left; a server table writes the page it has, and says so.
879
- - **Selection.** The first column counts the rows (`numbered`, on by default when `selectable`) and turns into the row's
880
- checkbox under the pointer; once anything is selected every row shows its checkbox and the heading shows the box that
881
- selects the page (partly checked when some are). Shift+click selects a range, up to `maxSelection` (500). While rows
882
- are selected, a bar with the count, one split button holding every `massAction`, and Clear covers the headings, so
883
- nothing moves: the menu makes the action picked the button, and the next click runs it, with a spinner while it does.
884
- - **Row actions**: `inline` ones as icon buttons, the rest in the row's menu; while one runs (a promise), the row is busy.
885
- `onRowOpen` for a double-click. A row added or saved is highlighted for a moment (steadily, with reduced motion).
886
- - **A pinned section.** `pinnedRows` puts rows in a band above the page with a button that folds them open and shut
887
- (`pinnedLabel`, `pinnedMax` beside the count, `pinnedLoading` while they come); with a `stateKey` the choice is
888
- remembered on the device. They are the same records as in the list below, and each wears a pin where the rows of the
889
- page show their number.
890
- - **Wide tables** scroll inside their frame, with the numbers and checkbox column on the left, the actions on the right,
891
- any pinned columns beside them, and the headings on top staying in place, and a soft shadow where columns scroll under
892
- them. Under the table: "Showing 41–50 of 234" (and how many the search left out) on the left, "Page 5 of 24" and the
893
- pager on the right; rows per page sits inside the search box. At 520px and less the page buttons go; at 560px the
894
- search takes its own line.
895
- - `npm run test:table` checks the helpers, the store, and the CRUD rules; `scripts/browser-checks/data-table.cjs` in the
896
- template checks `/demo/data-table`.
897
-
898
- ### CRUD
899
-
900
- ```tsx
901
- import { Crud } from "nexus-shared/client";
902
-
903
- export const GUESTS = MODULES.controller(NexusModule.Reservation, "Guest", "guest"); // every list and action
904
-
905
- <Crud<Guest>
906
- api={GUESTS}
907
- columns={GUEST_COLUMNS} // as a DataTable takes them
908
- inputs={GUEST_INPUTS} // or { create, edit }, or (action, row) => inputs
909
- paging="server" // default "client": each list loaded once
910
- form={{ load: "details", newValues: { country: "Nepal" } }}
911
- details={{ columns: GUEST_DETAILS }}
912
- onChange={({ action, rows, mode }) => audit(action, rows)}
913
- />
914
- ```
915
-
916
- - **Lists.** All (`NormalListing` or `NormalPagination`), Flagged, Trash, and Deleted, as icon tabs with the
917
- chosen one named. A list shows when it is in `modes`, has its endpoint, and the user may call it.
918
- - **Pinned records.** `PinListing` answers the records the user pinned: they sit in a section above the active list that
919
- folds open and shut, and in the list and its pages as well. One limit holds for every CRUD in the app
920
- (`configureCrud({ maxPins: 100 })`, 100 by default). Pinning a selection bigger than the room left pins as many as fit,
921
- in the order they were picked, and says so; lowering the limit never unpins anything, so a user over it can still
922
- unpin and can pin again once they are under it.
923
- - **Actions.** On a record in the lists of active records: View details, Edit (an icon on the row), Flag, Pin,
924
- 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
925
- `actions`, has its endpoint, and is allowed. A controller without a trash (no endpoint, or left out of `actions`) offers
926
- Delete where Move to trash would be; a trash the user may not use never becomes Delete.
927
- - **Questions and messages.** Move to trash and Delete ask first ("Move 3 guests to the trash?", "This cannot be
928
- undone."); Flag, Pin, and Recover are undone by a click and do not ask. Toasts come from the message
929
- builder ("Sita Sharma moved to the trash.", "3 guests unpinned.").
930
- - **Rows from the answers.** A record that moves to another list leaves the one in view (and the pinned section); flags,
931
- 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
932
- answer lists the records it changed, only those change.
933
- - **Add and Edit** open popups built on `ApiForm`, with the Save bar in the footer and the server's messages on their
934
- inputs. Keys keep them one each: pressing Add again brings back an Add form with changes (minimized or not), or opens a
935
- fresh one; Edit of a record already open brings that popup back as it was. Closing a form with changes (its close
936
- button, Esc, the dock) asks "Discard your changes?". Edit opens with the row, the `details` endpoint's record
937
- (`form.load: "details"`, for fields a list leaves out), or a loader of your own.
938
- - **Details** (View, or a double-click) shows every column, hidden ones too, and the full record once `details` answers,
939
- with Edit in its footer.
940
- - **Cache.** Lists are kept for 5 minutes while the page is open (`cache={{ lifetime }}`, or `false`): going back to a
941
- list paints it at once. After a change, the list in view keeps its rows and the others load again when shown; Reload
942
- and the handle's `reload()` go past the cache. The cache goes when the page does.
943
- - **Permissions** come from the app's rules: `definePermissions(new NexusPermissions())` once, then `setActions(accessActions)`
944
- after sign-in. While the rules load, the table shows placeholders; a user with no list sees a message. Rules for
945
- another backend extend `Permissions` (`can(endpoint)`), and its page and mass shapes extend `CrudBackend`.
946
- - `ref` gives `table`, `mode`, `setMode`, `reload`, `create(values)`, `edit(row)`, and `view(row)`.
947
- - `scripts/browser-checks/crud.cjs` in the template checks `/demo/crud`: every list and action, the popups, the roles, the
948
- cache, a phone, and contrast in every theme.
949
-
950
- ### Excel import and export
951
-
952
- A model's records in and out of a spreadsheet, against the backend's Excel controller bases
953
- (`NexusExcelController<TContract>` and `NexusExcelExportController<TContract>`): `Excel/Settings`, `Excel/Template`,
954
- `Excel/Verify`, `Excel/Import`, and the model's own export action.
955
-
956
- ```tsx
957
- export const CITIES = MODULES.controller(NexusModule.Configuration, "City", "city");
958
- export const CITY_SHEET = excelEndpoints(CITIES, { subject: "city" }); // the four, and Excel/Export
959
-
960
- openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload }); // the panel as a window
961
- <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={reload} /> // or in the page
962
-
963
- await downloadExcelTemplate(CITY_SHEET);
964
- await exportExcel(CITY_SHEET, { subject: "city", rows: table.matches, query: { search, sort: "cityName", direction: "asc", filters } });
965
- ```
966
-
967
- - **The template** is the backend's file, downloaded under the name its `Content-Disposition` gives it. Every list in it
968
- is a dropdown and the ids beside a choice fill themselves, so the person types no code and pastes no id.
969
- - **A verify writes nothing.** The file goes up as form data, and the answer is the rows, judged: what would be added,
970
- what is already a record, and what is waiting on a fix.
971
- - **The review is two collapsible editors** over those rows - **New rows** (which includes the rows to fix, since a row
972
- with a problem is not yet a record) and **Already there** - built from the sheet's columns with the rules the sheet
973
- told the person about, so a fix made here is checked the way the file was. What the server found wrong is listed under
974
- the rows, each entry naming the cell as a spreadsheet does (`B7 - City name`) and taking the caret to it.
975
- - **The import sends rows, never the file again**, or every fix made on screen would be lost; every check runs again on
976
- the way in. **A duplicate is never updated**: one left alone is not sent at all, and editing one sends it as a new
977
- record. An import writes what it can and names the rest; only one that wrote nothing at all is a failure.
978
- - **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
979
- back, so nothing copied out of a file or out of an answer can be posted to an endpoint as an id.
980
- - **The export is the server's file**, written from the search, the sort and the filters on screen - not a CSV of the
981
- rows this browser holds. The row limit is the backend's (`Excel/Settings`, 5,000 by default, kept for the page's life);
982
- over it the person is asked first, and the answer's own headers say what was written and what was left out.
983
- - **Generic, like the rest.** `ExcelBackend` is the class an app on another backend registers
984
- (`defineExcelBackend(new MyExcelBackend())`): the request shapes, how the answers read, the upload field, and the names
985
- of a download's headers. `NexusExcelBackend` is the default. `readExcelResponse` reads a refusal's `errorMessages`, so
986
- "no city could be written" reaches the person in the backend's own words.
987
- - **A column draws its own input**: text, note, whole number, decimal, amount, date, time, Yes/No, and a choice shown
988
- 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
989
- 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.
990
- - `scripts/test-excel.mts` checks the endpoints, the answers, the inputs and the round trip; the template's
991
- `scripts/browser-checks/excel.cjs` checks `/demo/excel`: every state, the two rules about the wire, both downloads, the
992
- warning before a capped export, a phone, and contrast in every theme.
993
-
994
- In a table, `excel={{ endpoints: CITY_SHEET, subject: "city" }}` is the whole of it: the options menu (⋮) then offers
995
- **Download template**, **Import from a spreadsheet** (the panel as a window, after which the table reads its rows
996
- again) and **Export to Excel**, which sends the table's own search and sort with the page's `filters()`. A `Crud`
997
- passes it through as `table={{ excel }}` and drops the lists it had kept once an import has written. It replaces
998
- `onImport`, which stays for a page that imports its own way.
999
-
1000
- Planned, following the previous Nexus project: a page status layout, and in the CRUD, audit logs in the details view.
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
+ - Components use only `--nx-*` variables, so every component follows the theme with no extra code.
92
+ - `npm run check:themes` checks every theme in both modes against WCAG contrast. Run it after changing a color.
93
+
94
+ ### Custom themes
95
+
96
+ A theme is one CSS block. List it for the script and the picker:
97
+
98
+ ```css
99
+ [data-nx-theme="brand"] {
100
+ --nx-primary: light-dark(#0f766e, #2dd4bf); /* the accent as text and lines */
101
+ --nx-primary-solid: light-dark(#0f766e, #0d7a70); /* the accent as a fill behind white text: buttons, checks */
102
+ --nx-primary-text: light-dark(#ffffff, #ffffff);
103
+ /* …any other --nx-* color: bg, surface, surface-2, surface-hover, border, border-strong,
104
+ text, text-muted, text-subtle, danger, danger-solid, danger-text, success, warning, info */
105
+ }
106
+ ```
107
+
108
+ ```tsx
109
+ const themes = [...NEXUS_THEMES, { id: "brand", name: "Brand", description: "Our colors" }];
110
+ <ThemeScript themes={themes} defaultTheme="brand" />
111
+ <ThemeSwitcher themes={themes} />
112
+ ```
113
+
114
+ All styles sit in `@layer nexus.*`, so an app's own CSS overrides any token or component style without `!important`.
115
+
116
+ ### Quick preferences
117
+
118
+ Under the theme, the `ThemeSwitcher` panel holds the person's own settings (`PreferencesPicker`, or
119
+ `<ThemeSwitcher preferences={false} />` for the theme alone). Each applies at once to everything on the page, is saved
120
+ on this device, and reaches other open tabs:
121
+
122
+ | Section | Setting | Saved as | Read by |
123
+ |---|---|---|---|
124
+ | Language and region | Interface language: English, नेपाली | `nx-language` | `useUserPreferences().language` |
125
+ | | Calendar: AD, BS | `nx-date-calendar` | every date component (`useDatePreferences`) |
126
+ | Dates and times | Date picker language: English, नेपाली | `nx-date-script` | every date component |
127
+ | | Clock: 12-hour, 24-hour | `nx-time-format` | every time component |
128
+ | | Display date format | `nx-date-format` | every date component |
129
+ | Numbers | Number scale (the core's `ENumberScales`): International 1,234,567, South Asian 12,34,567 | `nx-number-scale` | `useNumberLocale()` |
130
+ | | Thousands separator: 1,200, 1.200 (decimal comma), 1 200, 1200 | `nx-thousands` | `useNumberLocale()` |
131
+ | Tables | Default page size | `nx-page-size` | every `DataTable` and `Crud` |
132
+ | | A row click opens the record | `nx-row-click` | every `DataTable` and `Crud` |
133
+
134
+ - **Numbers:** `useNumberLocale()` turns the two number settings into a locale (`numberLocale(scale, separator)`):
135
+ `en-US` and `en-IN` as they are, any other pair a key such as `"en-IN|.|,"` that `formatNumber`, `parseNumber` and
136
+ `getNumberSymbols` read. A `NumberBox`, a `Slider` and a table's number cells without a `locale` of their own use it;
137
+ a number box rewrites what it shows when the setting changes, so a typed value is never read in the wrong marks.
138
+ - **Tables:** once chosen, the page size and the row click go over what a table names itself (`pageSize`,
139
+ `openOnClick`); a page size picked in a table with a `stateKey` still wins there, and a size the table does not offer
140
+ is not forced on it. Until chosen (`null`), each table keeps its own.
141
+ - **Interface language** is stored and read through `useUserPreferences()`; the package's own words are English for
142
+ now, and it does not set `lang` on `<html>` until they are translated (a screen reader would read English in a Nepali
143
+ voice).
144
+ - `setUserPreferences({ … })` and `readUserPreferences()` (`nexus-shared/client`) change and read them from code, as
145
+ `setDatePreferences` does the date settings.
146
+
147
+ ## Components
148
+
149
+ | Component | Import | Notes |
150
+ |---|---|---|
151
+ | `Button` | `nexus-shared` | Variants `primary`, `secondary`, `ghost`, `danger`; sizes `sm`, `md`, `lg`; `icon`, `iconEnd`, `loading`, `block`; `hoverTone` |
152
+ | `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 |
153
+ | `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 |
154
+ | `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"` |
155
+ | `Toaster`, `toast` | `nexus-shared/client` | Toast messages from anywhere: `toast.success(title, message)`, `toast.promise`, `toast.loading`, actions, positions; one `<Toaster />` in the layout |
156
+ | `Popup` | `nexus-shared/client` | A modal window on the native dialog, with the buttons of a desktop window: minimize (a bar at the bottom right, content kept), maximize, close; moved by its title bar inside the window; title, description, scrolling body, footer, sizes `xs` to `full`, close reasons |
157
+ | `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 |
158
+ | `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` |
159
+ | `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 |
160
+ | `messageBox` | `nexus-shared/client` | `alert`, `confirm`, `prompt`, and `show` with any buttons, each returning a promise |
161
+ | `TextBox` | `nexus-shared/client` | Text input: label designs `standard`, `outlined`, `inbox`; sizes; icons; `prefix`, `suffix`; `clearable`; show password; `showCount`; validation |
162
+ | `TextArea` | `nexus-shared/client` | Multi-line text with the same field features; `autoResize` between `rows` and `maxRows` |
163
+ | `CheckBox`, `Switch` | `nexus-shared/client` | One checkbox or on/off switch: `label`, `description`, `required` (must be checked), `indeterminate`, `readOnly`, `labelPosition` |
164
+ | `CheckBoxGroup` | `nexus-shared/client` | Many choices from `options`: `min`, `max` (disables the rest at the limit), `selectAll`, `columns`, `design="cards"` |
165
+ | `RadioGroup` | `nexus-shared/client` | One choice from `options`: arrow keys, `orientation`, `columns`, `design="cards"` |
166
+ | `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); saved values the list no longer offers (`selectedOptions`, `resolveOptions`, inactive items) show and cannot be picked again |
167
+ | `Slider`, `RangeSlider` | `nexus-shared/client` | Pick a number or a range: `min`, `max`, `step`, number format with `prefix`/`suffix`, `marks`, `tooltip`, `minDistance`, `onValueCommit` |
168
+ | `NumberBox` | `nexus-shared/client` | Numbers only: `min`, `max`, `decimals`, `fixedDecimals`, `grouping` by `locale`, `prefix`, `suffix`, `stepper`, `clamp`; the same field shell |
169
+ | `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 |
170
+ | `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 |
171
+ | `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 |
172
+ | `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` |
173
+ | `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` |
174
+ | `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 |
175
+ | `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) |
176
+ | `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 |
177
+ | `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 |
178
+ | `FormButtons` | `nexus-shared/client` | A form's status line and buttons on their own, such as in a popup's footer |
179
+ | `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 |
180
+ | `FormStore` | `nexus-shared` | The store and the form's handle: `getValue`, `getChanges`, `set`, `load` (with `keepChanged`), `reset`, `validate`, `setErrors`, `focus`, `submit` |
181
+ | `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` |
182
+ | `TableStore` | `nexus-shared` | The table's state and handle, outside React: `reload`, `upsert`, `patch`, `remove`, `select`, `getSelected`, `setSearch`, `setFilters`, `setSort`, `setPage` |
183
+ | `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 |
184
+ | `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 |
185
+ | `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 |
186
+ | `configureCrud`, `getCrudSettings` | `nexus-shared` | What every CRUD in the app follows: how many records a user may pin (100 by default) |
187
+ | `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 |
188
+ | `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 |
189
+ | `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 |
190
+ | `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 |
191
+ | `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 |
192
+ | `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` |
193
+ | `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 |
194
+ | `createApiClient` | `nexus-shared` | The engine behind `api` and `serverApi`, for an API of another shape |
195
+ | `Field` | `nexus-shared` | The frame of every input (label, box, icons, hint or error), for building custom inputs |
196
+ | `validateText`, `TEXT_PATTERNS` | `nexus-shared` | Pure text validation and common patterns with their messages |
197
+ | `validateCheck`, `validateSelection` | `nexus-shared` | Pure checks for a checkbox and for chosen values (`required`, `min`, `max`) |
198
+ | `validateNumber`, `formatNumber`, `parseNumber` | `nexus-shared` | Pure number validation, and number text in any locale (`roundDecimal`, `stepNumber` too) |
199
+ | `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 |
200
+ | `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 |
201
+ | `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 |
202
+ | `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` |
203
+ | `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` |
204
+ | `ThemeScript` | `nexus-shared` | Applies the saved theme, mode, density and animations (and the scale grid in development) before paint; goes in `<head>` |
205
+ | `ThemeSwitcher` | `nexus-shared/client` | Palette button with the theme picker in a popover: mode, theme, density, animations, and the scale grid in development |
206
+ | `ThemePicker` | `nexus-shared/client` | Mode control and theme cards with live previews |
207
+
208
+ ### Tab buttons
209
+
210
+ ```tsx
211
+ import { TabButtons } from "nexus-shared/client";
212
+
213
+ <TabButtons
214
+ aria-label="View"
215
+ value={view}
216
+ onChange={setView}
217
+ options={[
218
+ { value: "grid", label: "Grid", icon: <InlineIcon icon={iconLayoutGrid} /> },
219
+ { value: "list", label: "List", icon: <InlineIcon icon={iconList} /> },
220
+ ]}
221
+ />
222
+ ```
223
+
224
+ - An indicator slides to the selected option. In light mode the track is a soft tint with no frame and the indicator
225
+ is lifted by a shadow; in dark mode the indicator is lighter than the track, with a visible edge. The selected icon
226
+ takes the primary color, so the choice is clear in every theme.
227
+ - `size` (`sm`, `md`, `lg`), `tone="primary"` for a solid primary indicator, `block` to fill the width, `disabled` per option.
228
+ - `labels="selected"`: icons only, and the selected option grows to show its label. Every option needs an icon;
229
+ hidden labels are still read by screen readers and shown as tooltips.
230
+ - `design="list"`: the options stacked as full-width rows, for settings pages and side panels. Each row takes an
231
+ `icon`, a `description` on a second line, and a `badge` (a count) at the end. The selected row is tinted, marked
232
+ by a bar at its start, and its label turns bold; `tone="primary"` tints it with the primary color. ↑ and ↓ move
233
+ the selection. The nav design is the old project's `nav-tabs`, this one its `list-tabs`.
234
+ - `multiple`: several at once. `value` is an array and `onChange` hands back the chosen values in option order.
235
+ Each chosen option is raised as the one choice's indicator is, the options wrap onto more rows when they do not
236
+ fit (never narrower than tall, so single letters line up), and on a touch screen each grows to a medium control's
237
+ height. They are checkboxes: Tab moves between them and Space toggles one.
238
+ - Native radio buttons: arrow keys move the selection, and `name` makes it part of a form. Needs `aria-label` or `aria-labelledby`.
239
+ `form` sets the form the choice belongs to, as in HTML; an id that matches no form keeps a switch that is not a
240
+ value (the Markdown editor's views) out of the form around it.
241
+ - For choosing a value. For switching page panels, use tabs with panels (planned).
242
+
243
+ ### Split button
244
+
245
+ ```tsx
246
+ import { SplitButton } from "nexus-shared/client";
247
+
248
+ <SplitButton storageKey="pull-request" onAction={value => create(value)} options={[
249
+ { value: "create", label: "Create pull request", description: "Open a pull request that is ready for review" },
250
+ { value: "draft", label: "Create draft pull request", description: "Cannot be merged until marked ready for review" },
251
+ ]} />
252
+ ```
253
+
254
+ - The main button runs the chosen action; the arrow opens the alternatives as a radio menu. Picking one makes it the
255
+ main button, and with `storageKey` the browser remembers it (`localStorage`, `nx-split:<key>`), so the next visit
256
+ starts with it. `runOnSelect` also runs the pick at once, for Save / Save and close.
257
+ - `variant`, `size`, `loading`, `disabled`, `block` as `Button`; `type="submit"` with `name` submits a form and sends
258
+ the chosen value; `value` and `onValueChange` for a controlled choice; `align` and `menuLabel` for the menu.
259
+
260
+ ### Menus
261
+
262
+ ```tsx
263
+ import { ContextMenu, Menu } from "nexus-shared/client";
264
+
265
+ <Menu items={[
266
+ { value: "edit", label: "Edit", icon: <InlineIcon icon={iconPencil} />, shortcut: "Ctrl+E" },
267
+ { separator: true },
268
+ { value: "delete", label: "Delete", icon: <InlineIcon icon={iconTrash} />, tone: "danger" },
269
+ ]} onSelect={value => run(value)}>
270
+ <Button iconEnd={<InlineIcon icon={iconChevronDown} />}>Actions</Button>
271
+ </Menu>
272
+
273
+ <ContextMenu items={rowItems} onSelect={value => run(value, row)}>
274
+ <table>…</table>
275
+ </ContextMenu>
276
+ ```
277
+
278
+ - **Native popovers.** The list is a `[popover]` element: it sits above everything, closes with Esc or a click
279
+ outside, and needs no library. It opens under its button (above it when there is no room), lined up with the
280
+ button's `align="start"` or `"end"` edge, and follows the page as it scrolls.
281
+ - **Items are params:** `{ value, label, description?, icon?, shortcut?, tone?: "danger", disabled?, href?, target?, checked?, title? }`,
282
+ plus `{ separator: true }` and `{ heading }`. An item with `href` is a link. The trigger is any element that
283
+ renders a button and passes its props on, such as `Button`.
284
+ - `kind="radio"` or `"checkbox"` adds a check column and `aria-checked`; `closeOnSelect={false}` keeps a checkbox
285
+ menu open while options are toggled.
286
+ - **Keys:** arrows move between items (wrapping, skipping disabled ones), Home and End jump, a letter jumps to the
287
+ next item starting with it, Enter or Space chooses, Esc closes and returns focus to the button, Tab closes and
288
+ moves on. Focus follows a moving pointer, so the keys carry on from the hovered item.
289
+ - `ContextMenu` opens at the pointer on right-click, or under the focused element on Shift+F10. Its wrapper takes no
290
+ space (`display: contents`), so wrap a table once and note the clicked row in the row's own `onContextMenu`.
291
+ - 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
292
+ neither, and is never taller than that room: its items scroll inside it, the scroll bar against the panel's right
293
+ wall. It opens scrolled to the top, and a hovered item never scrolls the list.
294
+ - `placeUnderAnchor`, `placeAtPoint`, and `followAnchor` (`nexus-shared/client`) place custom popovers the same way, and
295
+ `showPanel` opens one with its scroller at the top. A panel (`.nx-popover`) that marks its scrolling part
296
+ `.nx-popover__scroll` is capped to the room it was placed in and gets the scroll bar on its wall; set its padding with
297
+ `--nx-popover-pad`. `usePopoverPanel` is a panel a menu item opens under a button, as the table's Filters and Columns
298
+ open.
299
+
300
+ ### Toast messages
301
+
302
+ ```tsx
303
+ import { Toaster, toast } from "nexus-shared/client";
304
+
305
+ // Once, in the root layout
306
+ <Toaster position="top-right" />
307
+
308
+ // Anywhere: components, services, fetch helpers
309
+ toast.success("Booking confirmed", "Room 204, 3 nights");
310
+ toast.error("Could not save", { message: error.message, action: { label: "Retry", onClick: save } });
311
+ toast.promise(saveBooking(), { loading: "Saving…", success: "Saved", error: e => `Not saved: ${e.message}` });
312
+ ```
313
+
314
+ - `toast(title, options?)`, `toast.info`, `.success`, `.warning`, `.error`, `.loading` (spinner, no timer), and
315
+ `.promise`. Options: `message`, `duration` (default 5000 ms, 0 keeps it; pauses while hovered or focused), `action`,
316
+ `closable`, `icon`, `position`, `onClose`, and `id` to update a toast in place. `toast.dismiss(id?)` closes one or all.
317
+ - Each toast is a native popover of its own, so it sits above open dialogs; the newest is nearest the edge, and at
318
+ most `max` (default 5) show per position. Warnings and errors use `role="alert"`, the rest `role="status"`.
319
+
320
+ ### Popup
321
+
322
+ ```tsx
323
+ import { Popup, definePopupRules, openPopup } from "nexus-shared/client";
324
+
325
+ <Popup open={open} onClose={() => setOpen(false)} title="Edit booking" description="NX-2048 · Room 204"
326
+ footer={<><Button onClick={() => setOpen(false)}>Cancel</Button><Button type="submit" form="booking" variant="primary">Save</Button></>}>
327
+ <form id="booking" onSubmit={save}>…</form>
328
+ </Popup>
329
+
330
+ // From code, with a handle; needs <PopupHost /> in the layout. Never two popups with one key: opening the key again
331
+ // replaces its popup (a fresh one), or with reopen: "restore" brings it back as it was.
332
+ const popup = openPopup({ key: "room-edit-204", reopen: "restore", title: "Room 204", content: <RoomDetails id={204} />, footer: p => <Button onClick={() => p.close()}>Close</Button> });
333
+ popup.minimize(); popup.restore(); popup.maximize();
334
+ await popup.closed;
335
+
336
+ // Rules by key, once, in a client module; options given to openPopup win over them
337
+ definePopupRules({
338
+ "room-new": { reopen: "replace" }, // Add: a fresh form every time (the default)
339
+ "room-edit-*": { reopen: "restore" }, // Edit: back as it was, with what was typed
340
+ "night-report": { minimizable: false, maximizable: false, movable: false },
341
+ });
342
+
343
+ // The page in charge of the window state
344
+ <Popup open={open} windowState={state} onWindowStateChange={setState} … />
345
+ ```
346
+
347
+ - Built on the native `<dialog>` shown modally: above everything, the page behind dimmed and locked (without a layout
348
+ shift), Tab kept inside, focus returned on close. `onClose(reason)` says whether the close button, Esc, the
349
+ backdrop, or code asked; the page decides by setting `open`. `closable`, `closeOnEscape`, `closeOnBackdrop` turn
350
+ those off for a popup that must be answered.
351
+ - `size`: `xs` 360, `sm` 440, `md` 560, `lg` 720, `xl` 960, `full`. The body scrolls while the title and footer
352
+ stay; `flush` removes the body padding for tables. `initialFocus` picks the first focused element (default: the
353
+ first control in the body or footer). The content mounts only while open, so forms start fresh.
354
+ - Popups open other popups and message boxes above themselves; Esc closes the top one.
355
+ - A **title bar** like a desktop window's: 32px high (40px on touch screens), the icon, the title, and the description
356
+ on one line (cut with …), and the window buttons filling its top right corner, 46 x 32 each: **minimize**,
357
+ **maximize** (restore down when maximized), **close** (red on hover). `variant="dialog"` gives the larger heading
358
+ instead (the icon in a circle, the description under the title), which message boxes use. `minimizable` (default: when `closable`, so a popup that must be answered cannot be put aside),
359
+ `maximizable` and `movable` (default true); message boxes have only close.
360
+ - **Minimized**, the dialog closes so the page can be used, and a bar waits at the bottom right with the popup's icon and
361
+ title; its content stays mounted, so typed text, scroll, and the focused field are there when the bar brings it back
362
+ (maximized again if it was). Several stack behind the newest with a count that opens the whole list (Esc or a press
363
+ elsewhere closes it). An open popup's backdrop covers the dock. `<PopupHost />` renders the dock; without a host
364
+ there is no minimize button. Bottom-right toasts stack above it (`--nx-dock-space`).
365
+ - **Maximized**, it fills the window edge to edge; a double-click on the title bar does the same, and restore down puts
366
+ it back where it was.
367
+ - **Moved** by its title bar (not its buttons), kept inside the window, pulled back in when the window shrinks; Esc
368
+ during a drag puts it back; dragging a maximized popup restores it under the pointer, as desktop windows do. Each
369
+ opening starts centered in `defaultWindowState`.
370
+ - **Opened again** while it is still open or minimized, a popup never doubles up. `openPopup` with a `key` whose popup
371
+ is there closes that popup (`onClose` and `closed` say `replaced`) and opens a fresh one: `reopen: "replace"`, the
372
+ default, right for an Add form, so Add, minimize, Add again cannot pile up popups. `reopen: "restore"` brings the
373
+ old one back as it was and returns its handle, right for editing one record. A page popup opens again through the
374
+ button that opened it, with the same `reopen` choice. Popups without a key are separate every time.
375
+ - **Asking before a close**: `openPopup({ beforeClose })` runs when the user closes the popup (its close button, Esc,
376
+ the backdrop, or its bar on the dock); return `false`, or a promise of it, to keep it open, such as after
377
+ `confirmDiscard(form)` asked "Discard your changes?". Closing from code does not ask.
378
+ - **Rules by key**: `definePopupRules({ "booking-new": {…}, "booking-edit-*": {…} })` sets `reopen`, `minimizable`,
379
+ `maximizable`, `movable`, `closable`, `closeOnEscape`, `closeOnBackdrop`, `size`, and `defaultWindowState` for every
380
+ `openPopup` with that key. A name ending in `*` matches keys that start with the rest (`*` alone, every key); all
381
+ matching rules apply, an exact key over a pattern and a longer pattern over a shorter one; options given to
382
+ `openPopup` win. It returns a function that removes the rules; `popupRuleFor(key)` shows what a key gets.
383
+ - With motion, minimize flies to the dock and maximize grows from the old box; with reduced motion, both happen in the
384
+ same frame.
385
+
386
+ ### Drag helpers
387
+
388
+ ```tsx
389
+ import { clampToBounds, makeDraggable, moveWithin, useDraggable, viewportBounds } from "nexus-shared/client";
390
+
391
+ useDraggable(cardRef, { bounds: "parent" }); // the card moves, kept inside its positioned parent
392
+ useDraggable(barRef, { target: () => cardRef.current, bounds: "parent", axis: "x", keepVisible: 40 });
393
+ const stop = makeDraggable(titleBar, { target: panel, bounds: document.querySelector(".board") }); // without React
394
+ moveWithin(card, { x: 10, y: 0 }, { bounds: "parent" }); // the keyboard way
395
+ clampToBounds({ left: -20, top: 40, width: 300, height: 200 }, viewportBounds()); // { x: 0, y: 40 }
396
+ ```
397
+
398
+ - The popup's title bar is built on these; any component can use them. `bounds`: `viewport` (default), `parent`,
399
+ an element (inside its borders), a box, a function, or `null`. `margin`, `keepVisible` (how much must stay
400
+ inside when it may partly leave), `axis`, `threshold` (default 3 px, so clicks stay clicks), `cancel` (controls in
401
+ the handle that do not drag).
402
+ - The position is an offset from the element's place in the layout, written to CSS `translate` by default, or to CSS
403
+ variables (`cssVariablePosition`), or anywhere through `position: { get, set }`, so a move never shifts the layout.
404
+ - `onStart` may cancel or change the target first; `onEnd` says whether Esc cancelled. A drag selects no text, and
405
+ the click that ends it does not reach what lies under the pointer. The handle needs `touch-action: none`
406
+ (`makeDraggable` and `useDraggable` set it).
407
+
408
+ ### Message box
409
+
410
+ ```tsx
411
+ import { messageBox } from "nexus-shared/client";
412
+
413
+ if (await messageBox.confirm({ title: "Delete booking NX-2048?", message: "This cannot be undone.", tone: "danger", confirmLabel: "Delete" })) …
414
+ await messageBox.alert({ title: "Saved", tone: "success" });
415
+ const name = await messageBox.prompt({ title: "Rename the room", label: "Name", defaultValue: room.name });
416
+ const choice = await messageBox.show({ title: "Unsaved changes", tone: "warning", buttons: [{ value: "discard", label: "Don't save" }, { value: "save", label: "Save" }] });
417
+ ```
418
+
419
+ - Small popups through `<PopupHost />`, the tone in the icon: `info` (alert default), `question` (confirm default),
420
+ `success`, `warning`, `danger`, or `neutral` for none. A danger box makes the last button red and focuses the first
421
+ one, so Enter cannot delete by accident.
422
+ - `confirm` resolves `false` and `show` resolves `null` when the box is dismissed with Esc, the close button, or a
423
+ click outside; `prompt` resolves `null` when cancelled and refuses an empty answer while `required` (the default).
424
+
425
+ ### Button hover tones
426
+
427
+ `hoverTone` keeps a `secondary` or `ghost` button neutral until it is hovered or focused from the keyboard, then
428
+ colors its border, a light background, and its text. Tones: `primary`, `danger`, `success`, `warning`, `info`.
429
+ Good for rows of actions, where full-color buttons would be too loud:
430
+
431
+ ```tsx
432
+ <Button hoverTone="danger" icon={<InlineIcon icon={iconTrash} />}>Delete</Button>
433
+ <Button variant="ghost" size="sm" hoverTone="primary" aria-label="Edit" icon={<InlineIcon icon={iconPencil} />} />
434
+ ```
435
+
436
+ Every tone meets WCAG AA contrast on its light background in all themes.
437
+
438
+ ### Text box
439
+
440
+ ```tsx
441
+ import { TEXT_PATTERNS } from "nexus-shared";
442
+ import { TextBox, TextArea } from "nexus-shared/client";
443
+
444
+ <form onSubmit={save}>
445
+ <TextBox name="email" type="email" label="Email" required pattern={TEXT_PATTERNS.email} icon={<InlineIcon icon={iconMail} />} />
446
+ <TextBox name="password" type="password" label="Password" required minLength={8} />
447
+ <TextArea name="notes" label="Notes" maxLength={500} showCount autoResize />
448
+ <Button type="submit" variant="primary">Save</Button>
449
+ </form>
450
+ ```
451
+
452
+ - **Uncontrolled by default**, so typing re-renders nothing. `onValueChange(value)` reports each change; `value` makes it controlled.
453
+ - **Validation:** `required`, `minLength`, `maxLength`, `pattern` (a `TEXT_PATTERNS` entry carries its own message), and
454
+ `validate(value)` for custom rules. Messages show after a field is left with text in it (`validateOn`: `blur`, `change`,
455
+ `submit`). Inside a `<form>`, an invalid field blocks submit, every message shows, and the first invalid field takes focus.
456
+ `error` shows a message from outside, such as the server.
457
+ - **Read-only says why.** Every input with `readOnly` shows a lock, and a short note for 2.5 seconds when someone tries
458
+ 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
459
+ selecting and copying text work as usual. Esc, leaving the field, or the timeout hide the note; `readOnlyMessage`
460
+ changes its text.
461
+
462
+ | Input | Lock | Tries that show the note | Default note |
463
+ |---|---|---|---|
464
+ | `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. |
465
+ | `NumberBox` | In place of the − and + buttons | The same, and ↑ ↓ Page Up Page Down | Read-only: this number cannot be changed. |
466
+ | `DatePicker`, `DateRangePicker` | In place of the calendar button | The same as a text box, and ↓ | Read-only: this date (these dates) cannot be changed. |
467
+ | `TimePicker` | In place of the clock button | The same as a text box, and ↓ | Read-only: this time cannot be changed. |
468
+ | `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. |
469
+ | `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. |
470
+ | `CheckBox`, `Switch` | After the label | A click or Space | Read-only: this option (this setting) cannot be changed. |
471
+ | `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. |
472
+ | `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. |
473
+
474
+ Nothing renders until an attempt, and an attempt re-renders only the note, so read-only forms of any size cost
475
+ nothing extra. `useReadOnlyNotice` gives a custom control the same lock and note.
476
+ - **Props are params:** plain, named like HTML attributes, and serializable, so forms and CRUD pages can describe
477
+ fields as objects. Design notes for inputs, forms, and CRUD: [src/Components/Inputs/README.md](src/Components/Inputs/README.md).
478
+
479
+ ### Number box
480
+
481
+ ```tsx
482
+ import { NumberBox } from "nexus-shared/client";
483
+
484
+ <NumberBox name="nights" label="Nights" stepper="split" required min={1} max={30} defaultValue={1} />
485
+ <NumberBox name="rate" label="Rate per night" prefix="Rs" required min={500} decimals={2} fixedDecimals grouping locale="en-IN" />
486
+ <NumberBox name="discount" label="Discount" suffix="%" stepper min={0} max={50} step={5} />
487
+ ```
488
+
489
+ - **Only a number gets in.** Typing accepts digits, one decimal separator, and a minus sign when `min` is below 0 (or
490
+ not set). `decimals` stops typing after that many decimals; pasted text such as "Rs 1,250.50" keeps its number, and
491
+ text with no single clear number is ignored rather than guessed at.
492
+ - **Formats by locale:** `grouping` shows 12,50,000.00 (`en-IN`) or 1.250.000,00 (`de-DE`) while the box is not
493
+ being edited; separators come off on focus. Without a `locale`, the box follows the person's Number scale and
494
+ Thousands separator (`useNumberLocale`, see Quick preferences), which is `en-US` until they choose, so the server
495
+ renders that and the browser takes the saved choice right after hydration.
496
+ Inside a `<form>`, a grouped box sends the plain number through a hidden input with its `name`.
497
+ - **Steps:** ↑ and ↓ (Shift or Page Up/Down for ten), and `stepper` buttons that repeat while held. Values move along
498
+ the grid of `step` from `min` and stay within the limits.
499
+ - **Values:** `onValueChange(value)` gets a `number`, or `null` when empty; `validate(value)` too. `clamp` moves an
500
+ out-of-range value to the nearest limit on blur instead of showing a message.
501
+ - `npm run test:numbers` checks the number helpers (formatting, parsing, paste, rounding, stepping, messages).
502
+
503
+ ### Checkbox and radio
504
+
505
+ ```tsx
506
+ import { CheckBox, CheckBoxGroup, RadioGroup, Switch } from "nexus-shared/client";
507
+
508
+ <RadioGroup name="roomType" label="Room type" required design="cards" columns={3} options={roomTypes} />
509
+ <CheckBoxGroup name="amenityIds" label="Amenities" columns={3} selectAll options={amenities.map(a => ({ value: a.id, label: a.name }))} />
510
+ <Switch name="breakfast" label="Breakfast included" labelPosition="start" />
511
+ <CheckBox name="terms" label="I accept the booking terms" required />
512
+ ```
513
+
514
+ - **Native inputs, drawn with CSS.** Each option is one `<input>`, so forms, arrow keys (radios), and screen readers
515
+ work as usual. Box edges use the subtle text color, which meets 3:1 contrast on panels in every theme.
516
+ - **Options are params:** `{ value, label, description?, icon?, disabled? }`. Values can be strings or numbers and
517
+ come back from `onValueChange` with their type.
518
+ - **Limits:** `required`, `min`, `max`, `validate`. At `max` a checkbox group disables the other options and shows a
519
+ count. Messages follow the text box: after leaving a changed group, or on submit, where the first invalid option takes focus.
520
+ - **Fast:** uncontrolled by default, so clicking re-renders nothing; 10,000 checkboxes mount in 0.5 to 0.7 s
521
+ in the development build.
522
+
523
+ ### Slider
524
+
525
+ ```tsx
526
+ import { RangeSlider, Slider } from "nexus-shared/client";
527
+
528
+ <Slider name="discount" label="Discount" max={50} suffix="%" defaultValue={10} />
529
+ <RangeSlider name="price" label="Price per night" min={1000} max={30000} step={500} minDistance={1000}
530
+ prefix="Rs " grouping locale="en-IN" onValueCommit={([from, to]) => loadRooms(from, to)} />
531
+ ```
532
+
533
+ - **Native range inputs** under a drawn track, so arrow keys, Page Up/Down, Home/End, touch, and screen readers work.
534
+ Each thumb announces its formatted value (`aria-valuetext`).
535
+ - **Values read like a number box:** `prefix`, `suffix`, `decimals`, `grouping`, `locale`, or a `format` function.
536
+ - **`onValueChange`** follows every step; **`onValueCommit`** fires once when a thumb is released or a key step ends,
537
+ the moment to call an API from a filter.
538
+ - **Range:** two thumbs that never cross and keep `minDistance` apart; a click on the track moves the nearer thumb.
539
+ A form receives two values under the name.
540
+ - **Marks** label points under the track and move the thumb when clicked; **`tooltip`** shows the value above the thumb.
541
+ - Dragging writes the fill and the text straight to the DOM, so nothing re-renders.
542
+
543
+ ### Date picker and date range picker
544
+
545
+ ```tsx
546
+ import { todayIsoDate } from "nexus-shared";
547
+ import { Calendar, DatePicker, DateRangePicker, setDatePreferences } from "nexus-shared/client";
548
+
549
+ <DatePicker name="checkIn" label="Check-in" required min={todayIsoDate()} onValueChange={iso => …} />
550
+ <DatePicker name="fiscalYearStart" label="Fiscal year start" calendar="bs" format="YYYY/MM/DD" />
551
+ <DateRangePicker startName="checkIn" endName="checkOut" label="Stay" required min={todayIsoDate()} minDistance={1} maxDistance={30} />
552
+ <Calendar mode="range" months={2} range={range} onRangeChange={setRange} min={todayIsoDate()} />
553
+
554
+ // A settings page
555
+ setDatePreferences({ calendar: "bs", format: "D MMMM YYYY", script: "devanagari" });
556
+ ```
557
+
558
+ - **The value is a day, not a time.** An ISO date `"2026-09-17"` in the Gregorian calendar, whatever calendar is
559
+ shown: what a form sends (through a hidden input with the field's `name`), what an API stores (`DateOnly`), and
560
+ what means the same day in every time zone. `toIsoDate` reads a `Date` in local time and an ISO date-time with a
561
+ zone as the local day of that instant; `isoDateToDate(iso)` gives UTC midnight, `isoDateToDate(iso, "local")` local midnight.
562
+ - **English or Nepali from the user's settings.** `useDatePreferences` reads `nx-date-calendar` (`ad` or `bs`),
563
+ `nx-date-format`, and `nx-date-script` (`latin` or `devanagari`) from local storage, with the English calendar in
564
+ `YYYY-MM-DD` as the default; `setDatePreferences` changes every date component on the page, and other tabs follow.
565
+ A box can fix its own `calendar`, `format`, or `script`.
566
+ - **Formats** use the tokens `YYYY` `YY` `MMMM` `MMM` `MM` `M` `DD` `D` `dddd` `ddd`. Typing follows the format's
567
+ order and is lenient: `17/9/2026`, `Sep 17, 2026`, `1 asoj 2083`, `२०८३-०६-०१`; a four-digit number is the year wherever it stands.
568
+ - **Bikram Sambat** covers BS 1975 to 2100 (AD 1918 to 2044), as the majority of five published tables
569
+ (`NepaliCalendar.ts`), converted through day numbers with no `Date` objects. `npm run test:dates` checks known
570
+ dates and round-trips every day.
571
+ - **DateRangePicker** holds a start and an end in one box, written as "2026-09-17 – 2026-09-24" in the user's
572
+ format. Pick the start and then the end in a calendar of two months (one when the window is narrow), with the
573
+ days between lit as the pointer moves, or type both dates around a dash, an arrow, or "to". The values are two
574
+ ISO dates: `onValueChange` gives `[start, end]` (`null` for an end not chosen yet), and a form sends `startName`
575
+ and `endName` as two fields, or both under `name`. Rules: `required`, `min`, `max`, `minDistance` and
576
+ `maxDistance` (days from start to end, the nights of a stay), and `isDateDisabled`, which also refuses a range
577
+ that includes such a day (a booked night). `validateDateRange` is the pure check behind it.
578
+ - **Read-only** boxes have no calendar: a lock sits where the calendar button was, and a click, a typed key, or ↓
579
+ shows a note under the box, as in every read-only input (see [Text box](#text-box)).
580
+ - **Calendar** is the pickers' panel and a component of its own: month and year menus, arrow keys (Page Up and Page
581
+ Down for months, with Shift for years), `min`, `max`, `isDateDisabled`, `weekStart`, `weekendDays` (Saturday is
582
+ red in the Nepali calendar), `mode="range"` with a live preview, `months={2}` side by side (a page passes 1 on
583
+ a phone; the calendar does not measure), `month` and `onMonthChange` to load a month's events, and `renderDay`
584
+ to draw markers in a cell.
585
+
586
+ ### Time picker
587
+
588
+ ```tsx
589
+ import { TimePicker, setDatePreferences } from "nexus-shared/client";
590
+
591
+ <TimePicker name="arrival" label="Arrival" required min="14:00" max="22:00" step={1800} view="list" onValueChange={iso => …} />
592
+ <TimePicker name="shiftStart" label="Shift start" min="22:00" max="06:00" format="HH:mm" />
593
+ <TimePicker name="spa" label="Spa slot" view="list" step={1800} isTimeDisabled={iso => booked.has(iso)} />
594
+
595
+ // A settings page
596
+ setDatePreferences({ timeFormat: "HH:mm" });
597
+ ```
598
+
599
+ - **The value is a time of day, not an instant.** An ISO time on a 24-hour clock, `"14:30"` (`"14:30:15"` when
600
+ `step` has seconds), with no date and no zone: what a form sends through a hidden input with the field's `name`,
601
+ and what a .NET `TimeOnly` stores. `toIsoTime` reads `"9:05"`, `"14:30:00.000"`, a `Date` (its local time), and an
602
+ ISO date-time (with a zone, the local time of that instant; without one, its time part).
603
+ - **12 or 24 hours from the user's settings.** `nx-time-format` in local storage, next to the date settings, holds
604
+ a format of tokens `HH` `H` `hh` `h` `mm` `m` `ss` `s` `A` `a`; the default is `hh:mm A` (02:30 PM).
605
+ `useDatePreferences` returns it as `timeFormat`, and `setDatePreferences({ timeFormat })` changes every time picker
606
+ on the page. Nepali dates in Devanagari bring Nepali digits and पूर्वाह्न/अपराह्न. A box can fix its own `format` or `script`.
607
+ - **Typing** works on either clock: `2:30 pm`, `2.30p`, `230pm`, `14:30`, `1430`, `9`, `१४:३०`. Without AM or PM the
608
+ hours are read on a 24-hour clock. Leaving the box writes the time in the format.
609
+ - **Columns** (the default) show hours, minutes, seconds when the step needs them, and AM/PM; the chosen items line
610
+ up on the middle row, a pick in any column changes the time at once, and ↑ ↓ ← → Home End Page Up/Down move through
611
+ them. **`view="list"`** shows one slot per `step` (every 30 minutes for steps under 5 minutes); a click or Enter
612
+ picks and closes.
613
+ - **Rules:** `required`, `min` and `max` (with `max` earlier than `min` the range runs past midnight, 22:00 to 06:00),
614
+ `step` in seconds counted from midnight as in HTML, `isTimeDisabled` for booked slots, and `validate`. Items outside
615
+ the rules are dimmed in the panel; a typed time outside them gets a message such as "Arrival must be in steps of 30
616
+ minutes, like 02:00 PM or 02:30 PM." `validateTime` is the pure check behind it. `npm run test:times` checks the helpers.
617
+ - **Read-only** boxes have no panel: a lock sits where the clock button was, as in every read-only input.
618
+
619
+ ### Date and time picker
620
+
621
+ ```tsx
622
+ import { DateTimePicker } from "nexus-shared/client";
623
+
624
+ <DateTimePicker name="pickupAt" label="Airport pickup" required min={new Date()} step={900} minTime="05:00" maxTime="23:00" />
625
+ <DateTimePicker name="checkInAt" label="Check-in" timeZone="Asia/Kathmandu" defaultTime="14:00" />
626
+ <DateTimePicker name="spaAt" label="Spa slot" view="list" step={1800} isTimeDisabled={(time, date) => booked.has(`${date} ${time}`)} />
627
+ ```
628
+
629
+ - **The value is a moment, in UTC.** `"2026-09-17T08:45:00.000Z"`, the shape `toISOString` writes, is what the hidden
630
+ input sends and `onValueChange` gives, whatever the user sees; the same moment is 02:30 PM in Kathmandu and 04:45 AM
631
+ in New York, so no time zone can move a saved value. Store it as UTC: a .NET `DateTimeOffset`, or a `DateTime` of
632
+ kind `Utc`. For a day with no time use `DatePicker`; for a time of day with no date, `TimePicker`.
633
+ - **Values in** (`toUtcDateTime`): an ISO date-time with `Z` or an offset is that moment; one **without a zone is read
634
+ as UTC**, the way a UTC `DateTime` loaded from a database often arrives without its `Z`; a date alone is the first
635
+ moment of that day where the user is; a `Date` or milliseconds are that moment. .NET's seven digits of fractions are read.
636
+ - **Shown in the user's zone and settings.** The browser's time zone, and the date (`nx-date-calendar`,
637
+ `nx-date-format`, `nx-date-script`) and time (`nx-time-format`) settings of the other pickers: "2026-09-17 02:30 PM",
638
+ or "२०८३-०६-०१ ०२:३० अपराह्न". The panel names the zone ("Kathmandu · UTC+05:45"). `timeZone` fixes a zone, such as a
639
+ 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
640
+ after hydration rather than another zone's time for a moment; the hidden input holds the value from the start.
641
+ - **No data loss.** A value changes only when the user changes what it shows: seconds and milliseconds the box does not
642
+ show stay as they came (a stamp from the server is sent back exactly), and retyping or re-picking what it shows keeps
643
+ the moment. Where clocks go back, one hour happens twice: a box holds whichever it was given, an edit inside that hour
644
+ keeps its offset, and a new pick is the first of the two. Where clocks go forward, a skipped time moves on (02:30
645
+ becomes 03:30), as `Date` and Temporal do.
646
+ - **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`,
647
+ `Sep 17, 2026 at 2:30 PM`, `2026-09-17T14:30`, `२०८३-०६-०१ १४:३०`. A date alone takes `defaultTime` (default
648
+ `minTime`, or midnight). Leaving the box writes it in the settings.
649
+ - **The panel** is the calendar and the time columns (or `view="list"` slots) side by side, as tall as each other, with
650
+ 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
651
+ hours, and Tab runs through the calendar, the columns, and the buttons. On a phone the time goes under the calendar,
652
+ and a panel taller than the room scrolls, moving to the time once a day is picked.
653
+ - **Rules:** `required`; `min` and `max` as moments (days outside are dimmed, and times outside on the first and last
654
+ day); `minTime` and `maxTime` as opening hours on every day (past midnight when `maxTime` is earlier); `step` in
655
+ seconds from each midnight; `isDateDisabled(date)` and `isTimeDisabled(time, date)` with ISO dates and times where the
656
+ user is; `validate`. Messages read like the box: "Airport pickup must be between 05:00 AM and 11:00 PM."
657
+ `validateDateTime(value, rules, fieldName, options)` is the pure check. `npm run test:datetimes` checks the helpers
658
+ against `Date` in eight zones, including half-hour daylight saving and a skipped day.
659
+ - **Read-only** boxes have no panel: a lock sits where the button was, as in every read-only input.
660
+
661
+ ### Dropdown
662
+
663
+ ```tsx
664
+ import { Dropdown } from "nexus-shared/client";
665
+
666
+ <Dropdown name="roomType" label="Room type" required options={roomTypes} />
667
+ <Dropdown name="amenities" label="Amenities" multiple max={6} options={amenities} /> // { value, label, icon: "wifi" }
668
+
669
+ // Rows like a data table
670
+ <Dropdown name="assignee" label="Assign to" options={staff} valueKey="id" labelKey="fullName"
671
+ columns={[{ key: "photo", type: "avatar" }, { key: "username", header: "Username" }, { key: "fullName", header: "Full name" }]} />
672
+ <Dropdown name="country" label="Country" options={countries} valueKey="code" labelKey="name"
673
+ columns={[{ key: "flag", type: "image" }, { key: "code", header: "Code", type: "code" }, { key: "name", header: "Country" }]} />
674
+
675
+ // A server list, editing a saved value
676
+ <Dropdown name="guestId" label="Guest" valueKey="id" labelKey="fullName" defaultValue={booking.guestId} selectedOptions={[booking.guest]}
677
+ loadOptions={({ search, skip, pageSize, signal }) => api.guests({ search, skip, take: pageSize }, signal)} /> // { items, total }
678
+ ```
679
+
680
+ - **Options are any objects.** `{ value, label, description, icon, disabled }` by default (a `ChoiceOption`), or API rows
681
+ read through `valueKey`, `labelKey`, `descriptionKey`, and `iconKey`. Values keep their type; a number and its text
682
+ 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.
683
+ - **Columns** turn rows into cells that line up down the list (one grid with subgrid rows), with headings that stay on top.
684
+ Types: `text`, `muted`, `code`, `avatar` (a photo URL, or initials of the label on a soft tone), `image` (4:3: flags,
685
+ logos), and `icon` (a Tabler name); `width`, `align`, and `render` for the rest. The first picture leads the value in
686
+ the box and in chips.
687
+ - **Search** matches every typed word in any order, with or without Latin accents, in the label, description, value, and
688
+ text columns (`searchKeys` to change). It is on with `loadOptions` or more than 8 options; without it, letters jump to
689
+ an option as in a native select. A local list renders 100 rows and adds more while it scrolls: 10,000 options open in
690
+ about 30 ms.
691
+ - **Server pages:** `loadOptions({ search, page, pageSize, skip, signal })` returns `{ items, total?, hasMore? }`. The first
692
+ page loads when the list opens (placeholder rows, a spinner in the box), the next near the end ("Loading more…"), a new
693
+ search 250 ms after typing stops, aborting the request before it. A failure shows Retry. `loadKey` drops the loaded
694
+ pages, for a list that depends on another field. `loading` shows the same loaders when the page fetches `options` itself.
695
+ - **Saved values the list can no longer offer** always show, and are never offered again once removed:
696
+ - on a server page not loaded: `selectedOptions` gives their items (a record usually carries the name), or
697
+ `resolveOptions(values)` looks them up (a placeholder shows until it answers);
698
+ - inactive items (`inactive: true` or `isActive: false`, or `isOptionInactive`): never in the list, shown only as a value;
699
+ - values missing from `options`: tagged **Unavailable**, or **Not found** when nothing is known but the value.
700
+
701
+ In the list they sit on top under **Selected** with their tag; in the box they carry the tag, or show as dashed chips.
702
+ A value only on another server page has no tag: a search finds it as a normal option.
703
+ - **Rules:** `required`, `min` and `max` for several (at `max` the other options are disabled), and `validate`, through
704
+ `validateSelection`. A pick follows the same message timing as typing. `npm run test:dropdown` checks the helpers.
705
+ - **Keys:** ↓ ↑ Page Up/Down Home End move past rows that cannot be picked, Enter picks, Space picks without search,
706
+ Backspace in an empty box removes the last value, Esc closes (then clears the search), Tab closes and moves on.
707
+ - **Read-only** boxes have no list: a lock sits where the list button was, chips lose their remove buttons, and a click or
708
+ a key shows the note, as in every read-only input.
709
+
710
+ ### Markdown editor
711
+
712
+ ```tsx
713
+ import { MarkdownView } from "nexus-shared";
714
+ import { MarkdownEditor } from "nexus-shared/client";
715
+
716
+ <MarkdownEditor name="description" label="Room description" defaultValue={room.description} defaultMode="split"
717
+ height={320} heightKey="room-description" maxLength={5000} required />
718
+
719
+ // Wherever the text is shown, also in a server component
720
+ <MarkdownView value={room.description} />
721
+ ```
722
+
723
+ - **Write, split, and preview.** `defaultMode` (or `mode` with `onModeChange`) picks the view, and `modes` the views
724
+ 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
725
+ them. The preview renders as a React transition, so typing stays within a frame in a 2,000-line document.
726
+ - **Toolbar and shortcuts:** heading, bold (Ctrl+B), italic (Ctrl+I), strikethrough (Ctrl+Shift+X), quote
727
+ (Ctrl+Shift+.), code (Ctrl+E), code block, link (Ctrl+K), image, bulleted, numbered, and task lists (Ctrl+Shift+8,
728
+ 7, L), table, and horizontal line. Marks wrap the selection or the word at the caret and come off when applied
729
+ again. Edits go through the browser's own editing, so Ctrl+Z undoes them. `toolbar` picks and orders the buttons.
730
+ - **While typing:** Enter continues a list (the next number, an empty task box) and ends it on an empty item; a URL
731
+ pasted over selected text links it; a task ticked in the preview is ticked in the text.
732
+ - **Height handle:** the status bar under the editor drags the height between `minHeight` and `maxHeight`, showing
733
+ the height as it changes; its grip takes ↑ ↓ (Shift for more), Page Up/Down, Home, and End; a double-click goes back
734
+ to `height`. `heightKey` remembers the user's height in local storage. Dragging writes straight to the DOM.
735
+ - **Full screen** (`fullscreen`, on by default) lifts the editor into the top layer over a dimmed page, without
736
+ moving it in the DOM, so undo history and the form stay. Tab stays inside, and Esc closes it.
737
+ - **A form field like `TextArea`:** `name`, `required`, `minLength`, `maxLength`, `pattern`, `validate`, the status bar's
738
+ word and character count, and read-only (a lock in the bar and a note under it). An invalid submit while the preview
739
+ shows brings the text back and focuses it. The view switch belongs to no form (`TabButtons` `form`), so it is never sent.
740
+ - **Safe to show anyone's text.** `parseMarkdown` (GitHub-flavored: tables, tasks, strikethrough, autolinks) never
741
+ renders raw HTML, and `safeUrl` keeps only http, https, mailto, tel, and relative addresses for links, and raster
742
+ data URLs for images. Links to other sites get `rel="nofollow noopener noreferrer"`.
743
+ - **Helpers:** `markdownToPlainText` for excerpts and search, and the editing commands `applyMarkdownTool`,
744
+ `continueMarkdownList`, `toggleMarkdownTask`, and `linkPastedUrl` for a custom editor. `npm run test:markdown`
745
+ checks the parser and every command.
746
+
747
+ ### Forms
748
+
749
+ ```tsx
750
+ import { TEXT_PATTERNS, type InputParams } from "nexus-shared";
751
+ import { ApiForm, SubmitForm } from "nexus-shared/client";
752
+
753
+ const ROOM_INPUTS: InputParams[] = [
754
+ { type: "text", name: "number", label: "Room number", required: true, pattern: TEXT_PATTERNS.digits, span: 4 },
755
+ { type: "number", name: "floor", label: "Floor", required: true, min: 0, span: 4 },
756
+ { type: "dropdown", name: "type", label: "Type", required: true, options: ROOM_TYPES, span: 4 },
757
+ { type: "tabular", name: "beds", label: "Beds", itemLabel: "Bed", inputs: BED_INPUTS }, // a list is an input too
758
+ ];
759
+
760
+ <ApiForm
761
+ inputs={ROOM_INPUTS}
762
+ loadValue={signal => api.get(ROOMS.details, { params: { id }, signal, toast: false }).then(unwrapApiResult)}
763
+ api={ROOMS.update} // or in place: { module: NexusModule.Reservation, path: "Room/Update", method: "PUT" }
764
+ draftKey={`hotel-3:user-8:room:${id}`}
765
+ onSuccess={room => table.updateRow(room)}
766
+ onCancel={close}
767
+ />
768
+ ```
769
+
770
+ - **Params in, values out.** Every input is an entry of `inputs`, so a page, a CRUD component, or a server can describe a
771
+ form with plain objects. Values come in as each input reports them (a date-time from an API becomes a date, "12.5"
772
+ becomes 12.5), and fields the record came with (an `id`) stay in the values.
773
+ - **Fast at any size.** The values live in a `FormStore` outside React. Typing renders only the input typed in; an input
774
+ renders again only when a message it shows changes, or code sets its value. A keystroke costs about 5 µs in the store
775
+ for a form of 200 inputs, and a list of 10,000 rows is not checked again on each keystroke (it checks itself).
776
+ - **Checks on Save.** Each input's own rules (they also catch text that is not a date yet), the form's `validate` across
777
+ inputs, and rows not on the page. Every message shows, the first problem takes focus (a table scrolls to its row), and
778
+ the status line lists what needs attention, each name a link to its input. A rule message on an input shows once the
779
+ input was left, and on Save.
780
+ - **One save at a time.** Save waits with a spinner; another click, Enter, or Ctrl+S does nothing until it ends. A save
781
+ makes the values the form's own ("Unsaved changes" clears; values typed meanwhile stay changes). `onSubmit` returns
782
+ `{ ok: false, errors, message }` to say why not, and a thrown error shows its message.
783
+ - **Server messages.** The server's own words are what the user reads. `ApiForm` shows **every** message a refusal
784
+ carried - a Nexus answer's `errorMessages` (a list, or one message object, or one message and its codes at the top
785
+ level) and ASP.NET Core problem details (`errors` by field) - each on the input its `inputName` names, matched without
786
+ case and by the first part of a path (`Addresses[1].City`). Names that match no input of this form, messages that name
787
+ none, and failures without fields show beside the buttons; nothing is swallowed and nothing is shown twice. A sentence
788
+ of ours by HTTP status ("Someone else changed this record…") is the fallback for an answer that explained nothing.
789
+ `messageCode` and `errorCode` ride along in `answer.errorMessages` for the log and for `onError` to react to, and are
790
+ never shown as the sentence. An input's message clears when its value changes.
791
+ - **Nothing lost.** `draftKey` keeps unsaved changes in IndexedDB (after a 600 ms pause, and at once when the page hides
792
+ or the form leaves) and brings them back with a note and Discard; drafts changed on the server since are named. Cancel
793
+ asks before it drops changes, and the page asks before it closes with some (`confirmLeave`, on without a draft).
794
+ `form.load(values, { keepChanged: true })` refreshes a record without touching what the user changed.
795
+ - `npm run test:forms` checks the store and the API helpers; `scripts/browser-checks/forms.cjs` in the template checks the
796
+ three demo pages.
797
+
798
+ ### API calls
799
+
800
+ ```ts
801
+ // api-modules.ts: the app's modules and where each one's root is (shared by the browser and the server)
802
+ import { defineApiModules, NexusApiModules, NexusModule } from "nexus-shared";
803
+
804
+ export const MODULES = defineApiModules(new NexusApiModules({ useProxy: process.env.NEXT_PUBLIC_API_PROXY !== "false" }));
805
+ export const ROOMS = MODULES.controller(NexusModule.Reservation, "Room", "room");
806
+
807
+ // In the browser
808
+ import { api } from "nexus-shared/client";
809
+ const rooms = await api.get<Room[]>(ROOMS.listing); // GET /api/app/reservation/Room/NormalListing
810
+ await api.put(ROOMS.update, values, { subject: "Room 204" }); // toast: "Room 204 saved." or what failed and why
811
+ await api.confirm(ROOMS.delete, { params: { id }, subject: "Room 204" }); // asks "Delete Room 204?" first
812
+
813
+ // On the server (a server component, a route handler, a server action)
814
+ import { serverApi } from "nexus-shared/server";
815
+ const list = await serverApi.get<Room[]>(ROOMS.listing, { revalidate: 60, tags: ["rooms"] }); // straight to API_RESERVATION
816
+
817
+ // app/api/app/[module]/[...path]/route.ts: the browser's way to every module
818
+ export const { GET, HEAD, POST, PUT, PATCH, DELETE } = createApiProxy({ token: readAccessToken });
819
+ ```
820
+
821
+ - **Modules are the app's.** `ApiModules<TModule>` is an abstract class over the app's own module enum: implement
822
+ `modules` (each module's value, its key in proxy URLs, its label for messages, where its root comes from) and
823
+ `serverRoot(module)` (from the environment, a settings service, anything; may be async). Register it once with
824
+ `defineApiModules`, and define endpoints through it (`MODULES.controller`, `MODULES.endpoint`), so a file that calls
825
+ an endpoint has registered the modules on either side. `NexusApiModules` is the Nexus set (roots from `API_<MODULE>`,
826
+ as in the previous project; `NexusModule.None` for the app's own `/api` routes) and runs when nothing is registered.
827
+ The list is checked: keys and values must be unique, and keys fit in a URL.
828
+ - **`NexusModule`'s numbers are the backend's `EModules` flags**, and a permission is a bitmask of them, so a value
829
+ that disagrees with the backend by one bit grants or denies the wrong module in silence. They changed after
830
+ 2.0.0: `Chat` took bit 6 and `Accounting` through `Portfolio` each moved up one. Code that uses the names needs
831
+ nothing; anything holding a stored or hard-coded **number** does. [CHANGELOG.md](CHANGELOG.md) has the table.
832
+ - **Module types from one place.** An app names its enum once, next to `defineApiModules`:
833
+ `declare module "nexus-shared" { interface ApiRegister { module: HrModule } }`. Every endpoint, call, `ApiForm`,
834
+ and CRUD then takes only those modules (`AppModule`), so `{ module: NexusModule.Reservation, … }` in an HR app is a
835
+ type error. Without it, the modules are `NexusModule`.
836
+ - **The proxy, on or off.** With `useProxy` (the default), the browser calls `/api/app/<module>/<path>` and the proxy
837
+ route sends it on to the module's root with the user's token, which never reaches the browser. Off, the browser
838
+ calls each module's `browserRoot` (public) and the proxy serves none. `proxies(module)` mixes the two, such as
839
+ uploads straight to the Drive module and the rest through the proxy.
840
+ - **The proxy hides the backend.** To the browser the app is the backend, and nothing in an answer names the real one:
841
+ - **No redirect reaches the browser.** One that stays inside the module (the root's origin, or its host moved to
842
+ https by the backend's HTTPS redirection, under the root's path in any casing) is followed on the server as
843
+ `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
844
+ `maxRedirects` (3). Any other answers 502 `BACKEND_REDIRECT_REFUSED`: following it would carry the token elsewhere.
845
+ - **Response headers by an allowlist**, `DEFAULT_PROXY_RESPONSE_HEADERS` (Content-Type, Content-Disposition,
846
+ Content-Language, Content-Range, Accept-Ranges, Cache-Control, Expires, ETag, Last-Modified, Vary, Retry-After),
847
+ the Nexus backend's `X-Nexus-*`, and the names in `responseHeaders`. Server, X-Powered-By, X-AspNet-*,
848
+ X-SourceFiles, Via, Alt-Svc, Strict-Transport-Security, Report-To, NEL, CORS, Location, Link and WWW-Authenticate
849
+ never pass, even named; Set-Cookie only with `passCookies`. Every answer gets `X-Content-Type-Options: nosniff`,
850
+ and `Cache-Control: private, no-store` unless the backend said how to cache it.
851
+ - **Error pages replaced.** An error answer that is not JSON (an HTML or text page, a stack trace), JSON that is a
852
+ stack trace or repeats the user's token, or one over 1 MB, becomes the failure envelope with the backend's status
853
+ and plain words (`BACKEND_ERROR_PAGE`). JSON refusals (the Nexus envelope, problem details) and empty ones pass as
854
+ they came, so the backend's own words still reach the form.
855
+ - **The request.** The browser's own `X-Forwarded-*`, `Forwarded`, `X-Real-IP` and the like never go on, nor its
856
+ cookies, a method or path override (`X-HTTP-Method-Override`, `X-Original-URL`) or Next.js's own headers. The
857
+ proxy writes X-Forwarded-For (the last address the app received, which Next.js or the app's reverse proxy wrote;
858
+ `clientAddress` reads it elsewhere; an IPv4 or IPv6 address or nothing, never other text) and X-Forwarded-Host
859
+ and -Proto (the app's configured origin, else the request's). A module whose root is a path on the app (a pretend
860
+ backend) is called at this server's own host and port, over http: the request URL's, which Next.js builds from
861
+ where it listens. Never at the configured origin, so an app whose `origin` reads the Host header cannot let a client
862
+ choose another port on the machine; `serverApi`, which has no request, uses that `origin`, so build it from
863
+ configuration, or take the Host only when it names this server's own port. A body over `maxBodySize` (100 MB) is
864
+ refused with 413 before the backend sees it, and a page of another site cannot call through the proxy
865
+ (`Sec-Fetch-Site: cross-site`, other than a link followed) unless `allowCrossSite`.
866
+ - **A server call can say who the client is too.** `lastForwardedAddress` and `readIpAddress` (`nexus-shared/server`)
867
+ read the client's address as the proxy does, from anything with a `get` (Next.js's `headers()` included). The
868
+ template's `api-setup.ts` gives Identity calls, and only them, `X-Forwarded-For` and the browser's `User-Agent`
869
+ through `configureServerApi({ headers })`: sign-in leaves from the server, and Identity's rate limit counts per
870
+ address. Only Identity's, because reading the request makes a page render per request.
871
+ - **Refusals** are the Nexus envelope, `{ isSuccess: false, code, message, messageCode, errorCode, errorMessages }`
872
+ (`failure` builds another backend's; `PROXY_REFUSALS` lists the codes), and the real cause goes to the server's
873
+ log (`onProblem`, default `console.error`), never with the token or a query string.
874
+ - **Two things the proxy cannot do for an app.** Node.js does not trust a development certificate (ASP.NET Core's)
875
+ unless it starts with `NODE_USE_SYSTEM_CA=1`, so a module whose http root redirects to such an https port answers
876
+ 502 until then, and the log says so. And Next.js keeps an X-Forwarded-For a client sent when nothing stands in
877
+ front of the app, so only behind a reverse proxy of its own is the client's address certain.
878
+ - **One engine, two clients.** `api` and `serverApi` are two settings of `createApiClient`: it fills `{id}` in the path
879
+ from `params` or the body, adds the query, headers, and token, waits up to `timeout` (60 s), tries a failed GET once
880
+ more (a save never twice), and reads every answer as an `ApiResult` (`ok`, `status`, `result`, `message`,
881
+ `errorMessages`, `errors`, `messageCode`, `errorCode`, `network`, `timeout`, `aborted`). A failed call is an answer,
882
+ never a throw; an aborted one shows nothing. Next.js's own signals (`headers()` while prerendering, `redirect()`) pass
883
+ through untouched.
884
+ - **What the server said wins.** A refusal is shown in the backend's own words whenever it sent any:
885
+ `{ isSuccess, code, errorMessages: [{ message, messageCode, errorCode, inputName }] }` becomes
886
+ `result.errorMessages` in that order (one message object instead of a list, a single `errorMessage`, and an older
887
+ answer's one message at the top level all read into the same list), `result.errors` holds the ones that name an input,
888
+ and `result.message` is all of them joined for a toast. `messageCode` and `errorCode` are for the log and for a page to
889
+ react to - never the sentence a user reads. The messages built from the HTTP status are the fallback for an answer that
890
+ explained nothing.
891
+ - **Answers of another shape.** Nexus answers and ASP.NET Core problem
892
+ details are read out of the box. A backend that answers its own envelope registers one reader for the whole app:
893
+
894
+ ```ts
895
+ // Reads { success, message, data, error: { code, message, fields } } — the answer becomes ok, result, message, errors
896
+ const read: ApiResponseReader = (body, status) => {
897
+ const answer = body as BackendAnswer | null;
898
+ if (answer?.success) return { ok: true, status, result: answer.data, message: answer.message };
899
+ const fields = Object.entries(answer?.error?.fields ?? {}).map(([name, list]) => [name, list.join(" ")]);
900
+ return { ok: false, status, message: answer?.error?.message, messageCode: answer?.error?.code, errors: Object.fromEntries(fields) };
901
+ };
902
+ configureApi({ read }); // the browser
903
+ configureServerApi({ read }); // and the server, so both sides read one envelope
904
+ ```
905
+
906
+ It is the fallback of every call that does not bring a `read` of its own, which is what the components that call
907
+ inside themselves need: a `Crud`'s lists and actions and an `ApiForm`'s save have no place to pass one. A call's own
908
+ `read`, and an `ApiForm`'s `readResponse`, still win.
909
+ - **Toasts in the browser.** Errors always, successes for POST, PUT, PATCH, and DELETE; the same failure twice at once
910
+ shows once; `toast: false`, `true`, or `{ success, error, loading }` per call; `configureApi({ toast, headers, read,
911
+ onSignedOut })` for the app. The server shows nothing and logs network failures, timeouts, and 5xx.
912
+ - **One message builder.** `buildMessage(kind, { action, subject, count, status })` words every message, and
913
+ `messages.success`, `.failure`, `.reason`, `.loading`, `.confirm`, `.label`, and `.forResult` are short ways to call
914
+ it: "3 rooms deleted.", "Could not move Room 204 to the trash.", "You do not have permission to delete Room 204.". The
915
+ texts and action words are one table: `configureMessages` rewords or translates all of them at once, and an action
916
+ of the app's own is its words (`{ verb: "check in", done: "checked in", doing: "Checking in", object: "the guest" }`).
917
+ - **A controller's lists.** `apiController` names the lists as the Nexus backend does: `listing` and `pagination` are the
918
+ active records' (`NormalListing`, `NormalPagination`), and `lists.flag`, `lists.trash`, `pages.trash`, … the others
919
+ (`FlagListing`, `TrashListing`, `TrashPagination`, …), for the CRUD component.
920
+ - `npm run test:api` checks the builder, routes, modules (another app's enum, the proxy on and off), the engine, the
921
+ server client, and the proxy's routing; `npm run test:proxy` checks how the proxy hides the backend (redirects,
922
+ headers, error pages, forwarded headers, paths, the body limit, other sites, the log) against a fake backend;
923
+ `scripts/browser-checks/api-calls.cjs` in the template checks `/demo/api`.
924
+
925
+ ### Data table
926
+
927
+ ```tsx
928
+ import type { TableColumn } from "nexus-shared";
929
+ import { DataTable, useTable } from "nexus-shared/client";
930
+
931
+ const COLUMNS: TableColumn<Booking>[] = [
932
+ { key: "no", label: "Booking", width: "7rem", movable: false, pinnable: false }, // stays first, and stays put
933
+ { key: "guest.name", label: "Guest" }, // a path into the row
934
+ { key: "amount", label: "Amount", format: "money", prefix: "Rs. ", group: "Amount details" }, // number, money, percent, date, …
935
+ { key: "due", label: "Due", format: "money", prefix: "Rs. ", group: "Amount details" }, // one heading over both: the header is two rows
936
+ { key: "status", label: "Status", options: [{ value: 1, label: "Confirmed", tone: "success" }] }, // a tag
937
+ { key: "paid", label: "Paid", format: "boolean", resizable: false }, // keeps its width
938
+ { key: "created", label: "Booked", format: "date-time", hidden: true }, // shown from the Columns menu
939
+ ];
940
+
941
+ const table = useTable<Booking>();
942
+ <DataTable table={table} aria-label="Bookings" columns={COLUMNS} rows={bookings} selectable massActions={MASS} rowActions={row => ACTIONS(row)} stateKey="bookings" />
943
+ <DataTable columns={COLUMNS} paging="server" load={(query, signal) => fetchPage(query, signal)} /> // { search, sort, page, pageSize, filters }
944
+ <DataTable columns={COLUMNS} rows={bookings} onImport={askForFile} moreActions={[{ id: "report", label: "Monthly report", onSelect: open }]} />
945
+
946
+ table.upsert(saved); table.patch(7, { paid: true }, { flash: true }); table.remove([7, 8]); await table.reload();
947
+ ```
948
+
949
+ - **Columns are params**, one plain object each, as inputs are: `key` (a field or a path), `label`, `group`, `format`,
950
+ `decimals`, `prefix`, `suffix`, `options` (labels for an enum's values; a `tone` draws a tag with a dot of its color),
951
+ `value`, `render`, `text`, `width`, `minWidth`, `align`, `wrap`, `sortable`, `sortKey`, `searchable`, `hidden`,
952
+ `hideable`, `movable`, `resizable`, `pinnable`, `pinned`. Numbers line up at the end in tabular figures; dates and
953
+ times follow the user's settings; moments show in the user's zone once the page has hydrated; long text ends in … at
954
+ the column's width.
955
+ - **Columns in bands.** `group` is the heading above a column (`{ key: "amount", label: "Total", group: "Amount
956
+ details" }`); the columns next to each other that name the same one share it, and the header reads in two rows, the
957
+ bands on top and the columns' own headings under them. A column with no group keeps one heading as tall as both rows,
958
+ and so do the numbers and checkbox column and the actions. A band is a heading and nothing more: it sorts nothing, has
959
+ no menu and no edge to drag, and the columns stay one flat list, so hiding, ordering, widths, pins and the export are
960
+ as they were. The bands are read off the order on screen: a column dragged out of its run takes its band's heading
961
+ with it, and the run it left draws that heading again. With no group anywhere the header is one row.
962
+ - **Rows three ways.** Given (`rows`) or loaded once (`load`), the table searches, sorts, and pages them itself: every word
963
+ 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
964
+ (`load` with `paging="server"`), each search (after 300 ms without typing, or Enter), sort, and page is one request; a
965
+ newer query stops the one before, so a late answer never shows. A loader may answer without a promise (a cache), and
966
+ the rows then paint with the table. `loadKey` starts over for another list behind the same table.
967
+ - **What it shows while it waits.** Placeholders in the shape of the rows for the first rows; afterwards the rows stay,
968
+ 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
969
+ list has its own message and action; a search or filters that find nothing offer Clear search or Clear filters.
970
+ - **The columns are the user's.** Drag a heading to another place, drag its right edge for its width (double-click the
971
+ edge, or "Fit to content", to fit it, never under the column's `minWidth`), and use the caret on the heading to sort,
972
+ pin it left or right, move it, or hide it; the Columns menu shows and hides them. A column opts out with
973
+ `movable`, `resizable`, or `pinnable: false`, and `pinned` starts it on a side. The table itself can refuse all three
974
+ (`reorderable`, `resizable`, `pinnable`). Once a width is set the columns are laid out from those widths, so one edge
975
+ moves alone; "Reset columns" in the options menu puts everything back.
976
+ - **The bar** is one line on a wide table: the Add section (`add`, then the page's `actions`) and the lists
977
+ (`toolbar`) on the left; Reload, the options menu (⋮) and the search, rows per page inside it, on the right. A
978
+ narrower table moves whole parts, never a lone button, by its own width (a container query, so a table in a narrow
979
+ panel does the same): under 670px the search takes the top line (with nothing on the left, it leads the buttons'
980
+ line instead), under 375px Reload moves into ⋮, and under 325px, beside lists, the Add section takes the last line,
981
+ its buttons sharing it. On the admin shell, whose menu leaves the screen at 900px, those are windows of 695, 400 and
982
+ 350px. A bar wider than those widths expect (a long Add label, the page's own buttons, cozy density) takes the next
983
+ step as soon as a line would break (`data-squeeze`). A `Crud`'s lists name every tab from 900px and only the chosen
984
+ one below.
985
+ - **One options menu (⋮)**: **Filters** (`filterInputs`, input params as a form takes them, each named after the
986
+ column it filters: a small form under ⋮, applied as it changes; a server gets them flat by name, see `filterRequest`),
987
+ the columns shown as a CSV (`exportable`, named by `exportName` or the table's label), a
988
+ print view of the same in a frame of its own (`printable`), Import (`onImport`), any `moreActions` of the page, and
989
+ Reload, last, on a table too narrow for its button. A client table writes every row the search left; a server table
990
+ writes the page it has, and says so.
991
+ - **Selection.** The first column counts the rows (`numbered`, on by default when `selectable`) and turns into the row's
992
+ checkbox under the pointer; once anything is selected every row shows its checkbox and the heading shows the box that
993
+ selects the page (partly checked when some are). Shift+click selects a range, up to `maxSelection` (500). While rows
994
+ are selected, a bar with the count, one split button holding every `massAction`, and Clear covers the headings, so
995
+ nothing moves: the menu makes the action picked the button, and the next click runs it, with a spinner while it does.
996
+ - **Row actions**: `inline` ones as icon buttons, the rest in the row's menu; while one runs (a promise), the row is busy.
997
+ `onRowOpen` for a double-click. A row added or saved is highlighted for a moment (steadily, with reduced motion).
998
+ - **A pinned section.** `pinnedRows` puts rows in a band above the page with a button that folds them open and shut
999
+ (`pinnedLabel`, `pinnedMax` beside the count, `pinnedLoading` while they come); with a `stateKey` the choice is
1000
+ remembered on the device. They are the same records as in the list below, and each wears a pin where the rows of the
1001
+ page show their number.
1002
+ - **Wide tables** scroll inside their frame, with the numbers and checkbox column on the left, the actions on the right,
1003
+ any pinned columns beside them, and the headings on top staying in place, and a soft shadow where columns scroll under
1004
+ them. Under the table: "Showing 41–50 of 234" (and how many the search and filters left out) on the left, "Page 5 of
1005
+ 24" and the pager on the right; rows per page sits inside the search box. At 520px and less the page buttons go.
1006
+ - `npm run test:table` checks the helpers, the store, and the CRUD rules; `scripts/browser-checks/data-table.cjs` in the
1007
+ template checks `/demo/data-table`.
1008
+
1009
+ ### CRUD
1010
+
1011
+ ```tsx
1012
+ import { Crud } from "nexus-shared/client";
1013
+
1014
+ export const GUESTS = MODULES.controller(NexusModule.Reservation, "Guest", "guest"); // every list and action
1015
+
1016
+ <Crud<Guest>
1017
+ api={GUESTS}
1018
+ columns={GUEST_COLUMNS} // as a DataTable takes them
1019
+ inputs={GUEST_INPUTS} // or { create, edit }, or (action, row) => inputs
1020
+ paging="server" // default "client": each list loaded once
1021
+ form={{ load: "details", newValues: { country: "Nepal" } }}
1022
+ details={{ columns: GUEST_DETAILS }}
1023
+ onChange={({ action, rows, mode }) => audit(action, rows)}
1024
+ />
1025
+ ```
1026
+
1027
+ - **Lists.** All (`NormalListing` or `NormalPagination`), Flagged, Trash, and Deleted, as tabs after Add: every one
1028
+ named on a table 900px wide or more, icons with the chosen one named below. A list shows when it is in `modes`, has
1029
+ its endpoint, and the user may call it.
1030
+ - **Pinned records.** `PinListing` answers the records the user pinned: they sit in a section above the active list that
1031
+ folds open and shut, and in the list and its pages as well. One limit holds for every CRUD in the app
1032
+ (`configureCrud({ maxPins: 100 })`, 100 by default). Pinning a selection bigger than the room left pins as many as fit,
1033
+ in the order they were picked, and says so; lowering the limit never unpins anything, so a user over it can still
1034
+ unpin and can pin again once they are under it.
1035
+ - **Actions.** On a record in the lists of active records: View details, Edit (an icon on the row), Flag, Pin,
1036
+ 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
1037
+ `actions`, has its endpoint, and is allowed. A controller without a trash (no endpoint, or left out of `actions`) offers
1038
+ Delete where Move to trash would be; a trash the user may not use never becomes Delete.
1039
+ - **Questions and messages.** Move to trash and Delete ask first ("Move 3 guests to the trash?", "This cannot be
1040
+ undone."); Flag, Pin, and Recover are undone by a click and do not ask. Toasts come from the message
1041
+ builder ("Sita Sharma moved to the trash.", "3 guests unpinned.").
1042
+ - **Rows from the answers.** A record that moves to another list leaves the one in view (and the pinned section); flags,
1043
+ 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
1044
+ answer lists the records it changed, only those change.
1045
+ - **Add and Edit** open popups built on `ApiForm`, with the Save bar in the footer and the server's messages on their
1046
+ inputs. Keys keep them one each: pressing Add again brings back an Add form with changes (minimized or not), or opens a
1047
+ fresh one; Edit of a record already open brings that popup back as it was. Closing a form with changes (its close
1048
+ button, Esc, the dock) asks "Discard your changes?". Edit opens with the row, the `details` endpoint's record
1049
+ (`form.load: "details"`, for fields a list leaves out), or a loader of your own.
1050
+ - **Details** (View, or a double-click) shows every column, hidden ones too, and the full record once `details` answers,
1051
+ with Edit in its footer.
1052
+ - **Cache.** Lists are kept for 5 minutes while the page is open (`cache={{ lifetime }}`, or `false`): going back to a
1053
+ list paints it at once. After a change, the list in view keeps its rows and the others load again when shown; Reload
1054
+ and the handle's `reload()` go past the cache. The cache goes when the page does.
1055
+ - **Permissions** come from the app's rules: `definePermissions(new NexusPermissions())` once, then `setActions(accessActions)`
1056
+ after sign-in. While the rules load, the table shows placeholders; a user with no list sees a message. Rules for
1057
+ another backend extend `Permissions` (`can(endpoint)`), and its page and mass shapes extend `CrudBackend`.
1058
+ - `ref` gives `table`, `mode`, `setMode`, `reload`, `create(values)`, `edit(row)`, and `view(row)`.
1059
+ - `scripts/browser-checks/crud.cjs` in the template checks `/demo/crud`: every list and action, the popups, the roles, the
1060
+ cache, a phone, and contrast in every theme.
1061
+
1062
+ ### Excel import and export
1063
+
1064
+ A model's records in and out of a spreadsheet, against the backend's Excel controller bases
1065
+ (`NexusExcelController<TContract>` and `NexusExcelExportController<TContract>`): `Excel/Settings`, `Excel/Template`,
1066
+ `Excel/Verify`, `Excel/Import`, and the model's own export action.
1067
+
1068
+ ```tsx
1069
+ export const CITIES = MODULES.controller(NexusModule.Configuration, "City", "city");
1070
+ export const CITY_SHEET = excelEndpoints(CITIES, { subject: "city" }); // the four, and Excel/Export
1071
+
1072
+ openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload }); // the panel as a window
1073
+ <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={reload} /> // or in the page
1074
+ openExcelImport({ endpoints: REGION_SHEET, subject: "region", mode: "bulk", onImported: reload }); // rows typed in
1075
+
1076
+ await downloadExcelTemplate(CITY_SHEET);
1077
+ await exportExcel(CITY_SHEET, { subject: "city", rows: table.matches, query: { search, sort: "cityName", direction: "asc", filters } });
1078
+ ```
1079
+
1080
+ - **The template** is the backend's file, downloaded under the name its `Content-Disposition` gives it. Every list in it
1081
+ is a dropdown and the ids beside a choice fill themselves, so the person types no code and pastes no id.
1082
+ - **A verify writes nothing.** The file goes up as form data, and the answer is the rows, judged: what would be added,
1083
+ what is already a record, and what is waiting on a fix.
1084
+ - **The review is two editors** over those rows - **New rows** (which includes the rows to fix, since a row with a
1085
+ problem is not yet a record) and **Already there**, which folds away - built from the sheet's columns with the rules
1086
+ the sheet told the person about, so a fix made here is checked the way the file was. What the server found wrong is
1087
+ listed under the rows, each entry naming the cell as a spreadsheet does (`B7 - City name`) and taking the caret to it.
1088
+ Rows can be added to the new rows (Add, Insert, Duplicate); one added and left empty is not sent, and an added row is
1089
+ numbered after every row of the file, so no two rows share a number. A sheet with a required list takes no added
1090
+ rows: a list's key is picked in the spreadsheet, and a row added here could never have one.
1091
+ - **A bulk insert is an import with no file** (`mode: "bulk"`): the new rows' editor opens with one empty row, the
1092
+ person types the rows, and they go to the same `Excel/Import` with the same checks - a problem then names its row
1093
+ (`Row 3 - Region name`). A sheet with a required list cannot be typed in, and the panel says so.
1094
+ - **Nothing typed is lost unasked**: closing the window or Start over asks first, and the entry picked again while its
1095
+ window is minimized brings that window back. An import that wrote nothing keeps the rows, and lists under them what
1096
+ the backend refused and why.
1097
+ - **The import sends rows, never the file again**, or every fix made on screen would be lost; every check runs again on
1098
+ the way in. **A duplicate is never updated**: one left alone is not sent at all, and editing one sends it as a new
1099
+ record. An import writes what it can and names the rest; only one that wrote nothing at all is a failure.
1100
+ - **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
1101
+ back, so nothing copied out of a file or out of an answer can be posted to an endpoint as an id.
1102
+ - **The export is the server's file**, written from the search, the sort and the filters on screen - not a CSV of the
1103
+ rows this browser holds. The row limit is the backend's (`Excel/Settings`, 5,000 by default, kept for the page's life);
1104
+ over it the person is asked first, and the answer's own headers say what was written and what was left out.
1105
+ - **Generic, like the rest.** `ExcelBackend` is the class an app on another backend registers
1106
+ (`defineExcelBackend(new MyExcelBackend())`): the request shapes, how the answers read, the upload field, and the names
1107
+ of a download's headers. `NexusExcelBackend` is the default. `readExcelResponse` reads a refusal's `errorMessages`, so
1108
+ "no city could be written" reaches the person in the backend's own words.
1109
+ - **A column draws its own input**: text, note, whole number, decimal, amount, date, time, Yes/No, and a choice shown
1110
+ 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
1111
+ 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.
1112
+ - `scripts/test-excel.mts` checks the endpoints, the answers, the inputs and the round trip; the template's
1113
+ `scripts/browser-checks/excel.cjs` checks `/demo/excel`: every state, the two rules about the wire, both downloads, the
1114
+ warning before a capped export, a phone, and contrast in every theme.
1115
+
1116
+ In a table, `excel={{ endpoints: CITY_SHEET, subject: "city" }}` is the whole of it: the options menu (⋮) then offers
1117
+ **Download template**, **Import from a spreadsheet** (the panel as a window, after which the table reads its rows
1118
+ again), **Bulk insert** (the same with no file, beside an Add only; `bulk: false` leaves it out) and **Export to
1119
+ Excel**, which sends the table's own search, filters and sort with the page's `filters()`. A `Crud` passes it through
1120
+ as `table={{ excel }}` and drops the lists it had kept once an import has written. It replaces `onImport`, which stays
1121
+ for a page that imports its own way.
1122
+
1123
+ Planned, following the previous Nexus project: a page status layout, and in the CRUD, audit logs in the details view.