nexus-shared 3.1.0 → 4.0.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 (234) hide show
  1. package/CHANGELOG.md +2268 -446
  2. package/LICENSE +21 -21
  3. package/README.md +1705 -1084
  4. package/dist/Client.Index.d.ts +17 -0
  5. package/dist/Client.Index.js +17 -0
  6. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  7. package/dist/Components/Documents/DocumentFrame.js +83 -0
  8. package/dist/Components/Documents/Menu.d.ts +4 -0
  9. package/dist/Components/Documents/Menu.js +40 -24
  10. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  11. package/dist/Components/Documents/PrintDocument.js +62 -0
  12. package/dist/Components/Documents/Tabs.d.ts +59 -0
  13. package/dist/Components/Documents/Tabs.js +70 -0
  14. package/dist/Components/Forms/ApiForm.js +19 -2
  15. package/dist/Components/Forms/Crud.d.ts +2 -2
  16. package/dist/Components/Forms/Crud.js +871 -377
  17. package/dist/Components/Forms/CrudActions.d.ts +136 -0
  18. package/dist/Components/Forms/CrudActions.js +184 -0
  19. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  20. package/dist/Components/Forms/CrudHistory.js +140 -0
  21. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  22. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  23. package/dist/Components/Forms/CrudParts.d.ts +30 -2
  24. package/dist/Components/Forms/CrudParts.js +56 -5
  25. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  26. package/dist/Components/Forms/CrudRowForms.js +114 -0
  27. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  28. package/dist/Components/Forms/CrudScopes.js +76 -0
  29. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  30. package/dist/Components/Forms/CrudTree.js +363 -0
  31. package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
  32. package/dist/Components/Forms/CrudWorkspace.js +169 -0
  33. package/dist/Components/Forms/ExcelImport.js +3 -3
  34. package/dist/Components/Forms/Form.d.ts +4 -4
  35. package/dist/Components/Forms/Form.js +125 -16
  36. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  37. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  38. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  39. package/dist/Components/Forms/RecordFlows.js +282 -0
  40. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  41. package/dist/Components/Forms/SubmitForm.js +195 -10
  42. package/dist/Components/Inputs/CheckBox.js +1 -1
  43. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  44. package/dist/Components/Inputs/ChoiceField.js +28 -2
  45. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  46. package/dist/Components/Inputs/CodeBox.js +316 -0
  47. package/dist/Components/Inputs/DatePicker.js +4 -1
  48. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  49. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  50. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  51. package/dist/Components/Inputs/Dropdown.js +8 -5
  52. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  53. package/dist/Components/Inputs/DropdownParts.js +16 -4
  54. package/dist/Components/Inputs/Field.d.ts +7 -1
  55. package/dist/Components/Inputs/Field.js +2 -2
  56. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  57. package/dist/Components/Inputs/GroupForm.js +10 -11
  58. package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
  59. package/dist/Components/Inputs/InputRenderer.js +39 -31
  60. package/dist/Components/Inputs/NumberBox.js +4 -2
  61. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  62. package/dist/Components/Inputs/PictureInput.js +70 -0
  63. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  64. package/dist/Components/Inputs/RadioGroup.js +8 -3
  65. package/dist/Components/Inputs/RowsInput.d.ts +1 -1
  66. package/dist/Components/Inputs/RowsInput.js +18 -3
  67. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  68. package/dist/Components/Inputs/TabularForm.js +29 -29
  69. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  70. package/dist/Components/Inputs/TextArea.js +14 -2
  71. package/dist/Components/Inputs/TextBox.js +4 -1
  72. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  73. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  74. package/dist/Components/Inputs/TimePicker.js +4 -1
  75. package/dist/Components/Inputs/TreeView.d.ts +107 -6
  76. package/dist/Components/Inputs/TreeView.js +513 -30
  77. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  78. package/dist/Components/Layouts/MessageBox.js +56 -7
  79. package/dist/Components/Layouts/Popup.d.ts +13 -6
  80. package/dist/Components/Layouts/Popup.js +37 -9
  81. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  82. package/dist/Components/Layouts/PopupDock.js +2 -1
  83. package/dist/Components/Layouts/PopupHost.js +5 -4
  84. package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
  85. package/dist/Components/Layouts/ThemePicker.js +16 -4
  86. package/dist/Components/Viewers/DataTable.js +205 -61
  87. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  88. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  89. package/dist/Components/Viewers/DataTableParts.d.ts +46 -12
  90. package/dist/Components/Viewers/DataTableParts.js +62 -21
  91. package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
  92. package/dist/Components/Viewers/OwnerFiles.js +589 -0
  93. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  94. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  95. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  96. package/dist/Components/Viewers/RecordCards.js +509 -0
  97. package/dist/Helpers/ApiModules.d.ts +3 -34
  98. package/dist/Helpers/ApiModules.js +3 -3
  99. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  100. package/dist/Helpers/ApiRoutes.js +31 -27
  101. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  102. package/dist/Helpers/ArrayHelpers.js +2 -0
  103. package/dist/Helpers/BusyHooks.d.ts +6 -0
  104. package/dist/Helpers/BusyHooks.js +39 -0
  105. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  106. package/dist/Helpers/CodeHelpers.js +332 -0
  107. package/dist/Helpers/CrudBackend.d.ts +114 -6
  108. package/dist/Helpers/CrudBackend.js +214 -11
  109. package/dist/Helpers/CrudColumns.d.ts +98 -0
  110. package/dist/Helpers/CrudColumns.js +160 -0
  111. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  112. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  113. package/dist/Helpers/CrudHelpers.d.ts +130 -6
  114. package/dist/Helpers/CrudHelpers.js +304 -12
  115. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  116. package/dist/Helpers/CrudNarrowPick.js +157 -0
  117. package/dist/Helpers/CrudRowForms.d.ts +15 -0
  118. package/dist/Helpers/CrudRowForms.js +43 -0
  119. package/dist/Helpers/CrudScopes.d.ts +32 -0
  120. package/dist/Helpers/CrudScopes.js +79 -0
  121. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  122. package/dist/Helpers/DropdownHelpers.js +6 -2
  123. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  124. package/dist/Helpers/ExcelHelpers.js +12 -11
  125. package/dist/Helpers/FileAdapter.d.ts +78 -0
  126. package/dist/Helpers/FileAdapter.js +102 -0
  127. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  128. package/dist/Helpers/FilePrimaries.js +138 -0
  129. package/dist/Helpers/FormConfirm.d.ts +32 -0
  130. package/dist/Helpers/FormConfirm.js +40 -0
  131. package/dist/Helpers/FormStore.d.ts +90 -5
  132. package/dist/Helpers/FormStore.js +318 -22
  133. package/dist/Helpers/InputConditions.d.ts +50 -0
  134. package/dist/Helpers/InputConditions.js +168 -0
  135. package/dist/Helpers/InputMemory.d.ts +106 -0
  136. package/dist/Helpers/InputMemory.js +385 -0
  137. package/dist/Helpers/InputNarrowing.d.ts +18 -0
  138. package/dist/Helpers/InputNarrowing.js +67 -0
  139. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  140. package/dist/Helpers/InputParamsHelpers.js +162 -71
  141. package/dist/Helpers/MessageBuilder.js +2 -0
  142. package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
  143. package/dist/Helpers/NexusDriveAdapter.js +207 -0
  144. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  145. package/dist/Helpers/NexusHelpers.js +1 -0
  146. package/dist/Helpers/NumberHelpers.d.ts +95 -0
  147. package/dist/Helpers/NumberHelpers.js +126 -0
  148. package/dist/Helpers/Permissions.js +2 -1
  149. package/dist/Helpers/QrCode.d.ts +26 -0
  150. package/dist/Helpers/QrCode.js +463 -0
  151. package/dist/Helpers/RecordCards.d.ts +205 -0
  152. package/dist/Helpers/RecordCards.js +378 -0
  153. package/dist/Helpers/RequestCache.d.ts +47 -0
  154. package/dist/Helpers/RequestCache.js +132 -0
  155. package/dist/Helpers/RowsStore.d.ts +37 -2
  156. package/dist/Helpers/RowsStore.js +128 -10
  157. package/dist/Helpers/ServerPreview.d.ts +69 -0
  158. package/dist/Helpers/ServerPreview.js +99 -0
  159. package/dist/Helpers/TableColumns.js +4 -3
  160. package/dist/Helpers/TableExport.d.ts +7 -1
  161. package/dist/Helpers/TableExport.js +21 -15
  162. package/dist/Helpers/TableFilters.d.ts +1 -1
  163. package/dist/Helpers/TableFilters.js +24 -23
  164. package/dist/Helpers/TableHelpers.d.ts +24 -3
  165. package/dist/Helpers/TableHelpers.js +58 -12
  166. package/dist/Helpers/TableJoins.d.ts +83 -0
  167. package/dist/Helpers/TableJoins.js +284 -0
  168. package/dist/Helpers/TableStore.d.ts +26 -1
  169. package/dist/Helpers/TableStore.js +73 -7
  170. package/dist/Helpers/TreeStore.d.ts +306 -11
  171. package/dist/Helpers/TreeStore.js +1048 -90
  172. package/dist/Helpers/UserPreferences.d.ts +8 -0
  173. package/dist/Helpers/UserPreferences.js +17 -0
  174. package/dist/Interfaces/CrudInterfaces.d.ts +512 -14
  175. package/dist/Interfaces/FileInterfaces.d.ts +162 -0
  176. package/dist/Interfaces/FileInterfaces.js +4 -0
  177. package/dist/Interfaces/FormInterfaces.d.ts +393 -29
  178. package/dist/Interfaces/FormInterfaces.js +79 -1
  179. package/dist/Interfaces/InputInterfaces.d.ts +39 -3
  180. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  181. package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
  182. package/dist/Interfaces/PreferenceInterfaces.js +2 -5
  183. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  184. package/dist/Interfaces/RecordInterfaces.js +1 -0
  185. package/dist/Interfaces/TableInterfaces.d.ts +160 -5
  186. package/dist/Services/ApiProxy.d.ts +36 -1
  187. package/dist/Services/ApiProxy.js +182 -52
  188. package/dist/Services/ApiProxyRules.d.ts +203 -0
  189. package/dist/Services/ApiProxyRules.js +426 -0
  190. package/dist/Services/ExcelApi.js +5 -0
  191. package/dist/Services/FileApi.d.ts +13 -0
  192. package/dist/Services/FileApi.js +29 -0
  193. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  194. package/dist/Services/ImageEditorLoader.js +51 -0
  195. package/dist/Services/JoinSources.d.ts +80 -0
  196. package/dist/Services/JoinSources.js +150 -0
  197. package/dist/Services/ModuleImport.d.ts +7 -0
  198. package/dist/Services/ModuleImport.js +22 -0
  199. package/dist/Services/ServerApi.d.ts +13 -2
  200. package/dist/Services/ServerApi.js +8 -3
  201. package/dist/Shared.Index.d.ts +20 -0
  202. package/dist/Shared.Index.js +20 -0
  203. package/package.json +92 -85
  204. package/src/Styles/Nexus.Avatar.css +80 -79
  205. package/src/Styles/Nexus.Base.css +117 -117
  206. package/src/Styles/Nexus.Button.css +186 -180
  207. package/src/Styles/Nexus.Calendar.css +273 -273
  208. package/src/Styles/Nexus.Chat.css +1443 -1443
  209. package/src/Styles/Nexus.Choice.css +359 -343
  210. package/src/Styles/Nexus.Crud.css +542 -168
  211. package/src/Styles/Nexus.DateTime.css +81 -81
  212. package/src/Styles/Nexus.Dropdown.css +733 -725
  213. package/src/Styles/Nexus.Editor.css +295 -295
  214. package/src/Styles/Nexus.Excel.css +389 -389
  215. package/src/Styles/Nexus.Files.css +410 -0
  216. package/src/Styles/Nexus.Form.css +282 -279
  217. package/src/Styles/Nexus.Index.css +41 -37
  218. package/src/Styles/Nexus.Input.css +238 -4
  219. package/src/Styles/Nexus.Markdown.css +237 -237
  220. package/src/Styles/Nexus.Menu.css +246 -237
  221. package/src/Styles/Nexus.Popup.css +800 -724
  222. package/src/Styles/Nexus.Print.css +288 -0
  223. package/src/Styles/Nexus.QrCode.css +12 -0
  224. package/src/Styles/Nexus.Record.css +430 -0
  225. package/src/Styles/Nexus.Rows.css +637 -637
  226. package/src/Styles/Nexus.Slider.css +276 -276
  227. package/src/Styles/Nexus.Tab.Buttons.css +497 -432
  228. package/src/Styles/Nexus.Table.css +98 -35
  229. package/src/Styles/Nexus.Theme.Picker.css +59 -3
  230. package/src/Styles/Nexus.Themes.css +200 -194
  231. package/src/Styles/Nexus.Time.css +144 -144
  232. package/src/Styles/Nexus.Toast.css +263 -263
  233. package/src/Styles/Nexus.Tokens.css +94 -89
  234. package/src/Styles/Nexus.Tree.css +530 -431
@@ -63,6 +63,24 @@ export interface ChoiceItemProps<T extends OptionValue> {
63
63
  }
64
64
  /** One option of a group: a native input and its label, icon, and description, all inside one clickable label. */
65
65
  export declare function ChoiceItem<T extends OptionValue>({ type, groupId, name, option, index, size, checked, defaultChecked, invalid, readOnly, onInvalid }: ChoiceItemProps<T>): import("react").JSX.Element;
66
+ /**
67
+ * One option of a group drawn as tabs (`design="tabs"`): the native input out of sight inside a tab of the `TabButtons`
68
+ * track. Its description is its tooltip, and the input's description for a screen reader: `descriptionId` names the
69
+ * group's note for this option (`ChoiceTabNotes`), which a hidden note still gives.
70
+ */
71
+ export declare function ChoiceTab<T extends OptionValue>({ type, groupId, name, option, index, checked, defaultChecked, invalid, readOnly, onInvalid }: Omit<ChoiceItemProps<T>, "size">): import("react").JSX.Element;
72
+ /**
73
+ * The descriptions of a group drawn as tabs, under the track: the chosen option's shows (a phone has no tooltip), the
74
+ * rest stay hidden but still describe their inputs. `chosen` is the option chosen when the group is drawn; after that
75
+ * `showChosenNote` follows the inputs, as the group's `sync` rule.
76
+ */
77
+ export declare function ChoiceTabNotes<T extends OptionValue>({ groupId, options, chosen }: {
78
+ groupId: string;
79
+ options: readonly ChoiceOption<T>[];
80
+ chosen: number;
81
+ }): (import("react").JSX.Element | null)[];
82
+ /** Shows the note of the option checked now and hides the others (a `useChoiceField` `sync` rule). */
83
+ export declare function showChosenNote(inputs: HTMLInputElement[]): void;
66
84
  /** The legend of a choice group, with the required mark and, when read-only, a lock. */
67
85
  export declare function ChoiceLegend({ label, required, lock }: {
68
86
  label: ReactNode;
@@ -108,7 +126,9 @@ type GroupStyle = CSSProperties & {
108
126
  "--nx-choices-columns"?: number;
109
127
  };
110
128
  /** Class names and inline style of a group's fieldset and options list. */
111
- export declare function groupLayout(props: ChoiceGroupProps<OptionValue>): {
129
+ export declare function groupLayout(props: Omit<ChoiceGroupProps<OptionValue>, "design"> & {
130
+ design?: ChoiceDesign | "tabs";
131
+ }): {
112
132
  className: string;
113
133
  optionsStyle: GroupStyle | undefined;
114
134
  };
@@ -123,7 +123,7 @@ export function preventChange(event) {
123
123
  * reaches the input too), or pressed with Space or an arrow key, which browsers turn into a click.
124
124
  */
125
125
  export function optionAnchor(target) {
126
- return target instanceof HTMLInputElement ? target.closest(".nx-choice") : null;
126
+ return target instanceof HTMLInputElement ? target.closest(".nx-choice, .nx-tabs__option") : null;
127
127
  }
128
128
  /** Hides a field's read-only note when focus leaves the field, not when it moves between its options. */
129
129
  export function leavesField(event) {
@@ -146,6 +146,32 @@ export function ChoiceItem({ type, groupId, name, option, index, size, checked,
146
146
  onChange: checked === undefined ? undefined : noop }), hasContent(option.icon) && (_jsx("span", { className: "nx-choice__icon", "aria-hidden": "true", children: option.icon })), _jsxs("span", { className: "nx-choice__text", children: [_jsx("span", { id: labelId, className: "nx-choice__label", children: option.label }), descriptionId && (_jsx("span", { id: descriptionId, className: "nx-choice__description", children: option.description }))] })] }));
147
147
  }
148
148
  function noop() { }
149
+ /**
150
+ * One option of a group drawn as tabs (`design="tabs"`): the native input out of sight inside a tab of the `TabButtons`
151
+ * track. Its description is its tooltip, and the input's description for a screen reader: `descriptionId` names the
152
+ * group's note for this option (`ChoiceTabNotes`), which a hidden note still gives.
153
+ */
154
+ export function ChoiceTab({ type, groupId, name, option, index, checked, defaultChecked, invalid, readOnly, onInvalid }) {
155
+ const descriptionId = hasContent(option.description) ? `${groupId}-${index}-description` : undefined;
156
+ return (_jsxs("label", { className: "nx-tabs__option", title: typeof option.description === "string" ? option.description : undefined, children: [_jsx("input", { id: `${groupId}-${index}`, type: type, name: name, value: String(option.value), [OPTION_ATTRIBUTE]: index, className: "nx-visually-hidden", checked: checked, defaultChecked: checked === undefined ? defaultChecked : undefined, disabled: option.disabled, "aria-invalid": invalid || undefined, "aria-describedby": descriptionId, onClick: readOnly ? preventChange : undefined, onInvalid: onInvalid, onChange: checked === undefined ? undefined : noop }), option.icon, _jsx("span", { className: "nx-tabs__label", children: _jsx("span", { className: "nx-tabs__text", children: option.label }) })] }));
157
+ }
158
+ /**
159
+ * The descriptions of a group drawn as tabs, under the track: the chosen option's shows (a phone has no tooltip), the
160
+ * rest stay hidden but still describe their inputs. `chosen` is the option chosen when the group is drawn; after that
161
+ * `showChosenNote` follows the inputs, as the group's `sync` rule.
162
+ */
163
+ export function ChoiceTabNotes({ groupId, options, chosen }) {
164
+ return options.map((option, index) => hasContent(option.description) ? (_jsx("p", { id: `${groupId}-${index}-description`, className: "nx-choices__note", "data-nx-note": index, hidden: index !== chosen, children: option.description }, String(option.value))) : null);
165
+ }
166
+ /** Shows the note of the option checked now and hides the others (a `useChoiceField` `sync` rule). */
167
+ export function showChosenNote(inputs) {
168
+ const group = inputs[0]?.closest(".nx-choices");
169
+ if (!group)
170
+ return;
171
+ const chosen = inputs.find(input => input.checked)?.getAttribute(OPTION_ATTRIBUTE) ?? null;
172
+ for (const note of Array.from(group.querySelectorAll("[data-nx-note]")))
173
+ note.hidden = note.dataset.nxNote !== chosen;
174
+ }
149
175
  /** The legend of a choice group, with the required mark and, when read-only, a lock. */
150
176
  export function ChoiceLegend({ label, required, lock }) {
151
177
  if (!hasContent(label))
@@ -156,7 +182,7 @@ export function ChoiceLegend({ label, required, lock }) {
156
182
  export function groupLayout(props) {
157
183
  const { size = "md", design = "default", orientation = "vertical", columns } = props;
158
184
  return {
159
- className: cx("nx-choices", `nx-choices--${size}`, design === "cards" && "nx-choices--cards", orientation === "horizontal" && "nx-choices--horizontal", props.className),
185
+ className: cx("nx-choices", `nx-choices--${size}`, design === "cards" && "nx-choices--cards", design === "tabs" && "nx-choices--tabs", orientation === "horizontal" && "nx-choices--horizontal", props.className),
160
186
  optionsStyle: (columns && columns > 1 ? { "--nx-choices-columns": columns } : undefined),
161
187
  };
162
188
  }
@@ -0,0 +1,69 @@
1
+ import { type ChangeEvent, type ComponentPropsWithRef, type ReactNode } from "react";
2
+ import type { CodeRules, InputDesign, ValidateOn } from "../../Interfaces/InputInterfaces.ts";
3
+ import type { Size } from "../../Interfaces/TypeInterfaces.ts";
4
+ type NativeInputProps = Omit<ComponentPropsWithRef<"input">, "type" | "size" | "value" | "defaultValue" | "pattern" | "minLength" | "maxLength" | "prefix" | "placeholder" | "children" | keyof CodeRules>;
5
+ export interface CodeBoxProps extends NativeInputProps, CodeRules {
6
+ label?: ReactNode;
7
+ /** Help text under the boxes. */
8
+ hint?: ReactNode;
9
+ /** An error from outside, such as the server's "That code is wrong". Shown before the field's own message. */
10
+ error?: string | null;
11
+ /** Accepted for forms that set one on every input; the label of a code always sits above its boxes. */
12
+ design?: InputDesign;
13
+ /** Default `md`. */
14
+ size?: Size;
15
+ /** The boxes share the field's whole width, as on a sign-in card. Default: each box keeps its size. */
16
+ block?: boolean;
17
+ /** Shown between groups, such as "-" for an SSN. Default: a wider gap only. */
18
+ separator?: string;
19
+ /** A dot in each filled box instead of its character, for a PIN or an SSN. The browser treats it as a password. */
20
+ masked?: boolean;
21
+ /** Faint text in the empty boxes: one character for every box ("0"), or one per box ("MMDDYYYY"). */
22
+ slotPlaceholder?: string;
23
+ /**
24
+ * The code: only the characters it takes, without separators ("123456789" for an SSN). A saved code loses only its
25
+ * separators and case (`loadCode`); one the boxes cannot hold - a character the code does not take, or more characters
26
+ * than boxes - is kept as it is and shown invalid at once, with its message, never cut.
27
+ */
28
+ value?: string;
29
+ defaultValue?: string;
30
+ /** What a read-only field says when someone tries to change it. Default "Read-only: this code cannot be changed." */
31
+ readOnlyMessage?: string;
32
+ /** Name used in messages, e.g. "The code needs all 6 digits." Default: the label, when it is text. */
33
+ fieldName?: string;
34
+ /** When the field shows its own message. Default `blur`; submitting a form always shows it. */
35
+ validateOn?: ValidateOn;
36
+ /** The code after each change. */
37
+ onValueChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
38
+ /**
39
+ * Once every box is filled, with the code, so a one-time code can go without a button press. Once per code, and again
40
+ * once the boxes were short, however they got so: typing, a form's reset, or the page emptying a controlled `value`.
41
+ */
42
+ onComplete?: (value: string) => void;
43
+ /** Class of the outer field. `inputClassName` goes on the input. */
44
+ className?: string;
45
+ inputClassName?: string;
46
+ }
47
+ /**
48
+ * A code typed one character to a box: a one-time code, a PIN, an SSN, a licence key, a hex colour. Digits, letters,
49
+ * both, hex, or any one-character pattern; boxes in groups with a separator; dots for a secret.
50
+ *
51
+ * It is one real input under the boxes, so a phone offers the code from its message (`autoComplete="one-time-code"`),
52
+ * a pasted "123-45-6789" fills every box, and a screen reader reads one field. The boxes are drawn from that input
53
+ * straight in the DOM, so typing re-renders nothing. Uncontrolled by default, like the text box; `value` also works.
54
+ *
55
+ * Typing follows one model, however the text comes - a key, a phone's keyboard, an input method, a paste, autofill
56
+ * (`typeCode`): a whole code replaces everything; a character goes over a selected box, or into the box with the ring -
57
+ * inserted while the code has an empty box, so a gap Backspace or Delete left takes back what was there, and over that
58
+ * box once the code is full. A click or an arrow key selects a filled box; a full code's caret selects its last box. An
59
+ * input method's word goes in once, when it commits.
60
+ *
61
+ * A saved code loses only its separators and case (`loadCode`); one the boxes cannot hold is kept and shown invalid at
62
+ * once, never cut. A screen reader hears the label, then what the boxes show: "9 digits, in groups of 3, 2 and 4", or
63
+ * "8 digits: DD/MM/YYYY" with a hint in each box (`describeCode`). A read-only code rings every box while it has focus.
64
+ *
65
+ * @example <CodeBox label="Verification code" autoComplete="one-time-code" required onComplete={code => verify(code)} />
66
+ * @example <CodeBox label="SSN" groups={[3, 2, 4]} separator="-" masked />
67
+ */
68
+ export declare function CodeBox(props: CodeBoxProps): import("react").JSX.Element;
69
+ export {};
@@ -0,0 +1,316 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Fragment, useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, } from "react";
4
+ import { cx } from "../../Helpers/ClassHelpers.js";
5
+ import { codeAfterInput, codeFits, codeGroups, codeLength, describeCode, loadCode, moveCode, typeCode, validateCode, } from "../../Helpers/CodeHelpers.js";
6
+ import { Field } from "./Field.js";
7
+ import { useInputField } from "./InputField.js";
8
+ import { useReadOnlyNotice } from "./ReadOnlyNotice.js";
9
+ /**
10
+ * A code typed one character to a box: a one-time code, a PIN, an SSN, a licence key, a hex colour. Digits, letters,
11
+ * both, hex, or any one-character pattern; boxes in groups with a separator; dots for a secret.
12
+ *
13
+ * It is one real input under the boxes, so a phone offers the code from its message (`autoComplete="one-time-code"`),
14
+ * a pasted "123-45-6789" fills every box, and a screen reader reads one field. The boxes are drawn from that input
15
+ * straight in the DOM, so typing re-renders nothing. Uncontrolled by default, like the text box; `value` also works.
16
+ *
17
+ * Typing follows one model, however the text comes - a key, a phone's keyboard, an input method, a paste, autofill
18
+ * (`typeCode`): a whole code replaces everything; a character goes over a selected box, or into the box with the ring -
19
+ * inserted while the code has an empty box, so a gap Backspace or Delete left takes back what was there, and over that
20
+ * box once the code is full. A click or an arrow key selects a filled box; a full code's caret selects its last box. An
21
+ * input method's word goes in once, when it commits.
22
+ *
23
+ * A saved code loses only its separators and case (`loadCode`); one the boxes cannot hold is kept and shown invalid at
24
+ * once, never cut. A screen reader hears the label, then what the boxes show: "9 digits, in groups of 3, 2 and 4", or
25
+ * "8 digits: DD/MM/YYYY" with a hint in each box (`describeCode`). A read-only code rings every box while it has focus.
26
+ *
27
+ * @example <CodeBox label="Verification code" autoComplete="one-time-code" required onComplete={code => verify(code)} />
28
+ * @example <CodeBox label="SSN" groups={[3, 2, 4]} separator="-" masked />
29
+ */
30
+ export function CodeBox(props) {
31
+ const { label, hint, error, design: _design, size = "md", block = false, separator, masked = false, slotPlaceholder, length, groups, characters = "digits", allowed, letterCase = "upper", required, validate, value, defaultValue, readOnlyMessage = "Read-only: this code cannot be changed.", fieldName, validateOn, onValueChange, onComplete, className, inputClassName, style, disabled, readOnly, ref, autoComplete = "off", inputMode, onChange, onBlur, onInvalid, onKeyDown, onClick, onPaste, onFocus, onSelect, ...inputProps } = props;
32
+ const rules = { length, groups, characters, allowed, letterCase, required, validate };
33
+ const shape = { ...rules, separator, slotPlaceholder };
34
+ const sizes = codeGroups(rules);
35
+ const total = codeLength(rules);
36
+ const editable = !disabled && !readOnly;
37
+ // What the boxes say to the eye - how many, of what, in which groups, the hint in each - said to a screen reader.
38
+ const shapeId = useId();
39
+ const rowRef = useRef(null);
40
+ // The last code `onComplete` was told, so moving the caret or retyping the same code does not tell it again. It is
41
+ // forgotten whenever the boxes are short of a whole code, however they got so: typing, a form's reset, the page.
42
+ const completedRef = useRef(null);
43
+ // The input as its last change left it, for a change no `beforeinput` announced (autofill).
44
+ const lastRef = useRef(null);
45
+ // The input just before the browser changes it, from `beforeinput`.
46
+ const beforeRef = useRef(null);
47
+ // While an input method composes: the input as it was before. Its word is applied once, when it commits.
48
+ const composingRef = useRef(null);
49
+ // Set while this box announces its own change, which the browser-change listener leaves alone.
50
+ const announcingRef = useRef(false);
51
+ // A controlled box's text while an input method composes, so a render then does not put the code back over the word.
52
+ const [composed, setComposed] = useState(null);
53
+ const field = useInputField({
54
+ ...props,
55
+ "aria-describedby": [shapeId, props["aria-describedby"]].filter(Boolean).join(" "),
56
+ onChange: event => {
57
+ const code = event.currentTarget.value;
58
+ onChange?.(event);
59
+ onValueChange?.(code, event);
60
+ if ([...code].length < total)
61
+ completedRef.current = null;
62
+ else if (completedRef.current !== code) {
63
+ completedRef.current = code;
64
+ onComplete?.(code);
65
+ }
66
+ },
67
+ }, {
68
+ check: (element, name) => validateCode(element.value, rules, name),
69
+ checkKeys: [value, required, total, sizes.join(), characters, allowed, letterCase, validate],
70
+ onReset: element => {
71
+ forgetIfShort(element.value);
72
+ lastRef.current = readEdit(element);
73
+ paint();
74
+ },
75
+ });
76
+ const readOnlyNotice = useReadOnlyNotice(Boolean(readOnly) && !disabled, field.elementRef, readOnlyMessage);
77
+ function forgetIfShort(code) {
78
+ if ([...code].length < total)
79
+ completedRef.current = null;
80
+ }
81
+ /**
82
+ * Draws the boxes from the input: each one's character (or a dot), and the caret, the selection, and focus. While an
83
+ * input method composes, they show its word as the code will hold it.
84
+ */
85
+ function paint() {
86
+ const input = field.elementRef.current;
87
+ const row = rowRef.current;
88
+ if (!input || !row)
89
+ return;
90
+ const composing = composingRef.current;
91
+ const shown = composing ? codeAfterInput(composing, readEdit(input), rules) : readEdit(input);
92
+ const characters = [...shown.value];
93
+ const hasFocus = !disabled && input.ownerDocument.activeElement === input;
94
+ const focused = editable && hasFocus;
95
+ // A read-only code has no caret: focus marks every box, which shows the accent border alone, as a read-only box does.
96
+ const lookedAt = hasFocus && !editable;
97
+ const { start, end } = shown;
98
+ row.querySelectorAll(".nx-code__slot").forEach((slot, index, slots) => {
99
+ const character = characters[index];
100
+ const text = character === undefined ? "" : masked ? "•" : character;
101
+ if (slot.textContent !== text)
102
+ slot.textContent = text;
103
+ const selected = focused && end > start && index >= start && index < end;
104
+ // A caret is drawn on the box after it; at the end of a full code, on the last box.
105
+ const caret = focused && start === end && index === Math.min(start, slots.length - 1);
106
+ mark(slot, "data-active", lookedAt || selected || caret);
107
+ mark(slot, "data-selected", selected);
108
+ mark(slot, "data-caret", caret && character === undefined);
109
+ });
110
+ }
111
+ // A value from the parent, a new shape, or the first render: the input holds the code as the boxes take it. Not
112
+ // while an input method composes: its word goes in when it commits. A saved code the boxes cannot hold says so at once.
113
+ useLayoutEffect(() => {
114
+ const input = field.elementRef.current;
115
+ if (!input)
116
+ return;
117
+ if (!composingRef.current) {
118
+ if (value !== undefined) {
119
+ const code = loadCode(value, shape);
120
+ if (input.value !== code)
121
+ input.value = code;
122
+ }
123
+ lastRef.current = readEdit(input);
124
+ if (editable && input.value && !codeFits(input.value, rules))
125
+ field.touch();
126
+ }
127
+ forgetIfShort(input.value);
128
+ paint();
129
+ });
130
+ // The caret moves without a change (a click, the browser's own keys): follow it while the input has focus.
131
+ const follow = useEffectEvent(() => {
132
+ const input = field.elementRef.current;
133
+ if (!input || input.ownerDocument.activeElement !== input)
134
+ return;
135
+ if (!composingRef.current)
136
+ lastRef.current = readEdit(input);
137
+ paint();
138
+ });
139
+ useEffect(() => {
140
+ const document = field.elementRef.current?.ownerDocument;
141
+ document?.addEventListener("selectionchange", follow);
142
+ return () => document?.removeEventListener("selectionchange", follow);
143
+ }, []);
144
+ /* Text the browser puts in itself: a phone's keyboard, an input method, autofill, a drop, Backspace and Delete */
145
+ const beforeInput = useEffectEvent(() => {
146
+ const input = field.elementRef.current;
147
+ if (input && !composingRef.current)
148
+ beforeRef.current = readEdit(input);
149
+ });
150
+ const compositionStart = useEffectEvent(() => {
151
+ const input = field.elementRef.current;
152
+ if (input)
153
+ composingRef.current = readEdit(input);
154
+ beforeRef.current = null;
155
+ });
156
+ // The input method committed (or gave up): its word goes in once, as typed text does, and is announced.
157
+ const compositionEnd = useEffectEvent(() => {
158
+ const input = field.elementRef.current;
159
+ const composing = composingRef.current;
160
+ composingRef.current = null;
161
+ if (value !== undefined)
162
+ setComposed(null);
163
+ if (input && composing)
164
+ apply(input, codeAfterInput(composing, readEdit(input), rules), true);
165
+ });
166
+ const browserInput = useEffectEvent((event) => {
167
+ const input = field.elementRef.current;
168
+ if (!input || announcingRef.current)
169
+ return;
170
+ if (!composingRef.current && event.isComposing)
171
+ composingRef.current = lastRef.current;
172
+ if (composingRef.current) {
173
+ // Mid-composition the input keeps the input method's text: rewriting it now makes the input method type its whole
174
+ // word again. React does not hear of it either, so nothing checks or reports a word half typed.
175
+ event.stopPropagation();
176
+ if (value !== undefined)
177
+ setComposed(input.value);
178
+ paint();
179
+ return;
180
+ }
181
+ // Before React's onChange: the input holds the code the model makes of the change, and React reports that.
182
+ const before = beforeRef.current ?? lastRef.current ?? readEdit(input);
183
+ beforeRef.current = null;
184
+ apply(input, codeAfterInput(before, readEdit(input), rules), false);
185
+ });
186
+ useLayoutEffect(() => {
187
+ const input = field.elementRef.current;
188
+ if (!input)
189
+ return;
190
+ const listeners = [
191
+ ["beforeinput", () => beforeInput()],
192
+ ["compositionstart", () => compositionStart()],
193
+ ["compositionend", () => compositionEnd()],
194
+ ["input", event => browserInput(event)],
195
+ ];
196
+ for (const [name, listener] of listeners)
197
+ input.addEventListener(name, listener);
198
+ return () => {
199
+ for (const [name, listener] of listeners)
200
+ input.removeEventListener(name, listener);
201
+ };
202
+ }, []);
203
+ /** Puts an edit into the input and draws it; `announce` reports it as typing does, so React and every listener hear. */
204
+ function apply(input, edit, announce) {
205
+ // setRangeText leaves React's own copy of the value alone, so React sees the change and calls onChange.
206
+ if (input.value !== edit.value)
207
+ input.setRangeText(edit.value, 0, input.value.length, "end");
208
+ input.setSelectionRange(edit.start, edit.end);
209
+ lastRef.current = edit;
210
+ if (announce) {
211
+ announcingRef.current = true;
212
+ try {
213
+ input.dispatchEvent(new Event("input", { bubbles: true }));
214
+ }
215
+ finally {
216
+ announcingRef.current = false;
217
+ }
218
+ }
219
+ paint();
220
+ }
221
+ function handleChange(event) {
222
+ lastRef.current = readEdit(event.currentTarget);
223
+ paint();
224
+ field.controlProps.onChange(event);
225
+ }
226
+ function handleKeyDown(event) {
227
+ readOnlyNotice.onKeyDown(event);
228
+ onKeyDown?.(event);
229
+ // An input method's keys are its own until it commits (229 is a key it holds, in every browser).
230
+ const composing = event.nativeEvent.isComposing || event.keyCode === 229;
231
+ if (event.defaultPrevented || !editable || composing || event.ctrlKey || event.metaKey || event.altKey)
232
+ return;
233
+ const input = event.currentTarget;
234
+ if (isCodeMove(event.key) && !event.shiftKey) {
235
+ event.preventDefault();
236
+ apply(input, moveCode(readEdit(input), event.key, rules), false);
237
+ return;
238
+ }
239
+ if ([...event.key].length !== 1)
240
+ return;
241
+ // One character from a keyboard goes in by the model; one the code does not take is not typed at all.
242
+ event.preventDefault();
243
+ const next = typeCode(readEdit(input), event.key, rules);
244
+ if (next)
245
+ apply(input, next, true);
246
+ }
247
+ function handlePaste(event) {
248
+ onPaste?.(event);
249
+ if (event.defaultPrevented || !editable)
250
+ return;
251
+ event.preventDefault();
252
+ const input = event.currentTarget;
253
+ // A whole code replaces what is there, a part of one goes in at the caret, and a message is read for its code.
254
+ const next = typeCode(readEdit(input), event.clipboardData.getData("text"), rules);
255
+ if (next)
256
+ apply(input, next, true);
257
+ }
258
+ function handleClick(event) {
259
+ readOnlyNotice.onClick(event);
260
+ onClick?.(event);
261
+ const input = event.currentTarget;
262
+ const slots = rowRef.current?.querySelectorAll(".nx-code__slot");
263
+ if (!editable || !slots?.length)
264
+ return;
265
+ // The box under the pointer: a filled one is selected, to type over it; past the code, the caret goes to its end.
266
+ let nearest = 0;
267
+ let distance = Infinity;
268
+ slots.forEach((slot, index) => {
269
+ const box = slot.getBoundingClientRect();
270
+ const away = Math.abs(event.clientX - (box.left + box.right) / 2);
271
+ if (away < distance) {
272
+ nearest = index;
273
+ distance = away;
274
+ }
275
+ });
276
+ const filled = [...input.value].length;
277
+ if (nearest < filled)
278
+ input.setSelectionRange(nearest, nearest + 1);
279
+ else
280
+ input.setSelectionRange(filled, filled);
281
+ paint();
282
+ }
283
+ function handleFocus(event) {
284
+ onFocus?.(event);
285
+ paint();
286
+ }
287
+ function handleBlur(event) {
288
+ readOnlyNotice.hide();
289
+ field.controlProps.onBlur(event);
290
+ paint();
291
+ }
292
+ const hints = [...(slotPlaceholder ?? "")];
293
+ let next = 0;
294
+ const boxes = sizes.map((count, group) => (_jsxs(Fragment, { children: [group > 0 && _jsx("span", { className: "nx-code__separator", children: separator }), _jsx("span", { className: "nx-code__group", children: Array.from({ length: count }, () => {
295
+ const index = next++;
296
+ return _jsx("span", { className: "nx-code__slot", "data-placeholder": (hints.length === 1 ? hints[0] : hints[index]) || undefined }, index);
297
+ }) })] }, group)));
298
+ return (_jsxs(_Fragment, { children: [_jsx(Field, { controlId: field.controlId, messageId: field.messageId, label: label, required: required, size: size, hint: hint, error: field.error, actions: readOnlyNotice.lock, disabled: disabled, readOnly: readOnly, className: cx("nx-field--code", className), style: style, children: _jsxs("span", { className: "nx-code", "data-block": block || undefined, children: [_jsx("span", { ref: rowRef, className: "nx-code__slots", "aria-hidden": "true", children: boxes }), _jsx("span", { id: shapeId, className: "nx-visually-hidden", children: describeCode(shape) }), _jsx("input", { autoCorrect: "off", spellCheck: false, autoCapitalize: characters === "digits" && !allowed ? "off" : "characters", ...inputProps, ...field.controlProps, type: masked ? "password" : "text", inputMode: inputMode ?? (characters === "digits" && !allowed ? "numeric" : "text"), autoComplete: autoComplete, className: cx("nx-code__input", inputClassName), value: value === undefined ? undefined : (composed ?? loadCode(value, shape)), defaultValue: value === undefined ? loadCode(defaultValue, shape) : undefined, disabled: disabled, readOnly: readOnly, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, onClick: handleClick, onFocus: handleFocus, onBlur: handleBlur, onSelect: event => {
299
+ onSelect?.(event);
300
+ paint();
301
+ } })] }) }), readOnlyNotice.notice] }));
302
+ }
303
+ function mark(element, name, on) {
304
+ if (on)
305
+ element.setAttribute(name, "");
306
+ else
307
+ element.removeAttribute(name);
308
+ }
309
+ /** The input's code and selection, as the typing model takes them. */
310
+ function readEdit(input) {
311
+ const value = input.value;
312
+ const start = input.selectionStart ?? value.length;
313
+ return { value, start, end: input.selectionEnd ?? start };
314
+ }
315
+ const CODE_MOVES = new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
316
+ const isCodeMove = (key) => CODE_MOVES.has(key);
@@ -10,6 +10,7 @@ import { Calendar } from "./Calendar.js";
10
10
  import { Field, hasContent } from "./Field.js";
11
11
  import { setInputValue, useInputField } from "./InputField.js";
12
12
  import { useReadOnlyNotice } from "./ReadOnlyNotice.js";
13
+ import { useInputDesign } from "../../Helpers/UserPreferences.js";
13
14
  const GAP = 4;
14
15
  const MARGIN = 8;
15
16
  /**
@@ -20,7 +21,9 @@ const MARGIN = 8;
20
21
  * @example <DatePicker name="checkIn" label="Check-in" required min={todayIsoDate()} />
21
22
  */
22
23
  export function DatePicker(props) {
23
- const { label, hint, error, design = "standard", size = "md", icon, prefix, clearable = true, closeOnSelect = true, fieldName, readOnlyMessage = "Read-only: this date cannot be changed.", validateOn, value, defaultValue, onValueChange, weekStart, weekendDays, isDateDisabled, showOutsideDays, className, inputClassName, style, required, min, max, validate, name, form, placeholder, disabled, readOnly, ref, onChange, onBlur, onFocus, onInvalid, onKeyDown, onClick, ...inputProps } = props;
24
+ const { label, hint, error, design: ownDesign, size = "md", icon, prefix, clearable = true, closeOnSelect = true, fieldName, readOnlyMessage = "Read-only: this date cannot be changed.", validateOn, value, defaultValue, onValueChange, weekStart, weekendDays, isDateDisabled, showOutsideDays, className, inputClassName, style, required, min, max, validate, name, form, placeholder, disabled, readOnly, ref, onChange, onBlur, onFocus, onInvalid, onKeyDown, onClick, ...inputProps } = props;
25
+ // The person's input design (the quick preferences) when this page and its form name none, for a labelled input.
26
+ const design = useInputDesign(ownDesign, Boolean(label));
24
27
  const preferences = useDatePreferences();
25
28
  const settings = {
26
29
  calendar: props.calendar ?? preferences.calendar,
@@ -10,6 +10,7 @@ import { Calendar } from "./Calendar.js";
10
10
  import { Field, hasContent } from "./Field.js";
11
11
  import { setInputValue, useInputField } from "./InputField.js";
12
12
  import { useReadOnlyNotice } from "./ReadOnlyNotice.js";
13
+ import { useInputDesign } from "../../Helpers/UserPreferences.js";
13
14
  const GAP = 4;
14
15
  const MARGIN = 8;
15
16
  // Two months of the small calendar are about 480px wide; a narrower window gets one month.
@@ -26,7 +27,9 @@ const sameRange = (a, b) => (a === null || b === null ? a === b : a[0] === b[0]
26
27
  * @example <DateRangePicker startName="checkIn" endName="checkOut" label="Stay" required min={todayIsoDate()} minDistance={1} />
27
28
  */
28
29
  export function DateRangePicker(props) {
29
- const { label, hint, error, design = "standard", size = "md", icon, prefix, months = 2, clearable = true, closeOnSelect = true, fieldName, readOnlyMessage = "Read-only: these dates cannot be changed.", validateOn, value, defaultValue, onValueChange, startName, endName, weekStart, weekendDays, isDateDisabled, showOutsideDays, className, inputClassName, style, required, min, max, minDistance, maxDistance, validate, name, form, placeholder, disabled, readOnly, ref, onChange, onBlur, onFocus, onInvalid, onKeyDown, onClick, ...inputProps } = props;
30
+ const { label, hint, error, design: ownDesign, size = "md", icon, prefix, months = 2, clearable = true, closeOnSelect = true, fieldName, readOnlyMessage = "Read-only: these dates cannot be changed.", validateOn, value, defaultValue, onValueChange, startName, endName, weekStart, weekendDays, isDateDisabled, showOutsideDays, className, inputClassName, style, required, min, max, minDistance, maxDistance, validate, name, form, placeholder, disabled, readOnly, ref, onChange, onBlur, onFocus, onInvalid, onKeyDown, onClick, ...inputProps } = props;
31
+ // The person's input design (the quick preferences) when this page and its form name none, for a labelled input.
32
+ const design = useInputDesign(ownDesign, Boolean(label));
30
33
  const preferences = useDatePreferences();
31
34
  const settings = {
32
35
  calendar: props.calendar ?? preferences.calendar,
@@ -15,6 +15,7 @@ import { Field, hasContent } from "./Field.js";
15
15
  import { setInputValue, useInputField } from "./InputField.js";
16
16
  import { useReadOnlyNotice } from "./ReadOnlyNotice.js";
17
17
  import { TimePanel } from "./TimePanel.js";
18
+ import { useInputDesign } from "../../Helpers/UserPreferences.js";
18
19
  const PANEL_GAP = 4;
19
20
  const PANEL_MARGIN = 8;
20
21
  /**
@@ -27,7 +28,9 @@ const PANEL_MARGIN = 8;
27
28
  * @example <DateTimePicker name="pickupAt" label="Pickup" required min={new Date()} step={900} />
28
29
  */
29
30
  export function DateTimePicker(props) {
30
- const { label, hint, error, design = "standard", size = "md", icon, prefix, calendar: calendarProp, format: formatProp, timeFormat: timeFormatProp, script: scriptProp, timeZone: timeZoneProp, showTimeZone = Boolean(timeZoneProp), view = "columns", defaultTime, clearable = true, fieldName, readOnlyMessage = "Read-only: this date and time cannot be changed.", validateOn, value, defaultValue, onValueChange, isDateDisabled, isTimeDisabled, weekStart, weekendDays, showOutsideDays, className, inputClassName, style, required, min, max, minTime, maxTime, step, validate, name, form, placeholder, disabled, readOnly, ref, onChange, onBlur, onFocus, onInvalid, onKeyDown, onClick, ...inputProps } = props;
31
+ const { label, hint, error, design: ownDesign, size = "md", icon, prefix, calendar: calendarProp, format: formatProp, timeFormat: timeFormatProp, script: scriptProp, timeZone: timeZoneProp, showTimeZone = Boolean(timeZoneProp), view = "columns", defaultTime, clearable = true, fieldName, readOnlyMessage = "Read-only: this date and time cannot be changed.", validateOn, value, defaultValue, onValueChange, isDateDisabled, isTimeDisabled, weekStart, weekendDays, showOutsideDays, className, inputClassName, style, required, min, max, minTime, maxTime, step, validate, name, form, placeholder, disabled, readOnly, ref, onChange, onBlur, onFocus, onInvalid, onKeyDown, onClick, ...inputProps } = props;
32
+ // The person's input design (the quick preferences) when this page and its form name none, for a labelled input.
33
+ const design = useInputDesign(ownDesign, Boolean(label));
31
34
  const preferences = useDatePreferences();
32
35
  const browserZone = useBrowserTimeZone();
33
36
  // Unknown on the server and while the page hydrates: the box stays empty until then rather than show another zone's time.
@@ -8,6 +8,11 @@ interface DropdownBaseProps<TItem extends DropdownItem, TValue extends OptionVal
8
8
  hint?: ReactNode;
9
9
  /** An error from outside, such as a server message. Shown before the field's own message. */
10
10
  error?: string | null;
11
+ /**
12
+ * Drawn as invalid with no message line, for a box whose reason the page says elsewhere (`Field` `invalid`); a screen
13
+ * reader hears that reason through `aria-describedby`. A CRUD's narrowing box is drawn so while it asks for a pick.
14
+ */
15
+ invalid?: boolean;
11
16
  /** Default `standard`. */
12
17
  design?: InputDesign;
13
18
  /** Default `md`. */
@@ -29,6 +34,14 @@ interface DropdownBaseProps<TItem extends DropdownItem, TValue extends OptionVal
29
34
  selectedOptions?: readonly TItem[];
30
35
  /** Loads the items of values that no list or `selectedOptions` has, e.g. by id. Values it does not return show as "Not found". */
31
36
  resolveOptions?: (values: TValue[]) => Promise<readonly TItem[]>;
37
+ /**
38
+ * The item of a value (as text) that the page already holds, at once and with no call: a list kept for the page, a
39
+ * record looked up before. Asked before `resolveOptions`, so a value it knows is named from the first draw - and a form
40
+ * can name the values it holds without a call (a CRUD's save does). It answers only what is fresh: a record marked old
41
+ * since may have changed, and the dropdown then asks `resolveOptions`. With `aged` it answers an old one too, which a
42
+ * CRUD's save takes over no name at all. `crudPicker` gives one.
43
+ */
44
+ peekOption?: (key: string, aged?: boolean) => TItem | null | undefined;
32
45
  /** The item field that holds the value. Default `value`. */
33
46
  valueKey?: string;
34
47
  /** The item field shown as its name, in the box and in the list. Default `label`. */
@@ -7,10 +7,11 @@ import { columnTrack, defaultSearchKeys, filterItems, foldSearchText, indexItems
7
7
  import { followAnchor, isPopoverOpen, placeListPanel } from "../../Helpers/PopoverHelpers.js";
8
8
  import { DEFAULT_FIELD_NAME, validateSelection } from "../../Helpers/ValidationHelpers.js";
9
9
  import { Button } from "../Documents/Button.js";
10
- import { DropdownChip, DropdownMedia, DropdownOption, DropdownTag, hasIcon } from "./DropdownParts.js";
10
+ import { columnAlignStyle, DropdownChip, DropdownMedia, DropdownOption, DropdownTag, hasIcon } from "./DropdownParts.js";
11
11
  import { Field } from "./Field.js";
12
12
  import { useInputField } from "./InputField.js";
13
13
  import { useReadOnlyNotice } from "./ReadOnlyNotice.js";
14
+ import { useInputDesign } from "../../Helpers/UserPreferences.js";
14
15
  /** Rows a local list renders at first, and adds each time it scrolls near its end, so 10,000 options open at once. */
15
16
  const RENDER_STEP = 100;
16
17
  /** Closer to the end of the list than this, in pixels, renders or loads more. */
@@ -27,7 +28,9 @@ const NAMES_LISTED = 20;
27
28
  const SKELETON_WIDTHS = ["72%", "54%", "64%", "46%"];
28
29
  const IDLE_LIST = { search: "", loadKey: undefined, items: [], page: 0, hasMore: false, total: undefined, status: "idle", errorPage: 1, firstKeys: new Set() };
29
30
  export function Dropdown(props) {
30
- const { label, hint, error, design = "standard", size = "md", icon, options, loadOptions, loadKey, pageSize = 50, selectedOptions, resolveOptions, valueKey = "value", labelKey = "label", descriptionKey = "description", iconKey = "icon", columns, searchKeys, isOptionDisabled = isDisabledItem, isOptionInactive = isInactiveItem, searchable: searchableProp, clearable = true, loading = false, emptyMessage = "No options", renderValue, panelWidth, readOnlyMessage, fieldName, validateOn, className, inputClassName, style, required, validate, name, form, placeholder, disabled, readOnly, ref, onBlur, onFocus, onKeyDown, onClick, onInvalid, multiple: multipleProp, value: valueProp, defaultValue, onValueChange, min, max, selectAll = true, maxChips = 10, ...inputProps } = props;
31
+ const { label, hint, error, invalid = false, design: ownDesign, size = "md", icon, options, loadOptions, loadKey, pageSize = 50, selectedOptions, resolveOptions, peekOption, valueKey = "value", labelKey = "label", descriptionKey = "description", iconKey = "icon", columns, searchKeys, isOptionDisabled = isDisabledItem, isOptionInactive = isInactiveItem, searchable: searchableProp, clearable = true, loading = false, emptyMessage = "No options", renderValue, panelWidth, readOnlyMessage, fieldName, validateOn, className, inputClassName, style, required, validate, name, form, placeholder, disabled, readOnly, ref, onBlur, onFocus, onKeyDown, onClick, onInvalid, multiple: multipleProp, value: valueProp, defaultValue, onValueChange, min, max, selectAll = true, maxChips = 10, ...inputProps } = props;
32
+ // The person's input design (the quick preferences) when this page and its form name none, for a labelled input.
33
+ const design = useInputDesign(ownDesign, Boolean(label));
31
34
  const multiple = multipleProp === true;
32
35
  const controlled = valueProp !== undefined;
33
36
  const editable = !disabled && !readOnly;
@@ -45,7 +48,7 @@ export function Dropdown(props) {
45
48
  const optionIndex = useMemo(() => (options ? indexItems(options, valueKey) : null), [options, valueKey]);
46
49
  const selectedIndex = useMemo(() => (selectedOptions ? indexItems(selectedOptions, valueKey) : null), [selectedOptions, valueKey]);
47
50
  const knownRef = useRef(new Map());
48
- const findItem = (key) => optionIndex?.get(key) ?? knownRef.current.get(key) ?? selectedIndex?.get(key) ?? null;
51
+ const findItem = (key) => optionIndex?.get(key) ?? knownRef.current.get(key) ?? selectedIndex?.get(key) ?? peekOption?.(key) ?? null;
49
52
  const labelOf = (item) => itemLabel(item, labelKey, valueKey);
50
53
  const listId = `${useId()}-list`;
51
54
  const selectionId = `${listId}-selection`;
@@ -774,7 +777,7 @@ export function Dropdown(props) {
774
777
  const failed = Boolean(loadOptions) && server.status === "error";
775
778
  const empty = rows.length === 0 && !skeleton && !failed;
776
779
  const typed = deferredSearch.trim();
777
- listContent = (_jsxs(_Fragment, { children: [showHeader && (_jsxs("div", { className: "nx-dropdown-list__header", "aria-hidden": "true", children: [_jsx("span", {}), typedColumns.map((column, index) => (_jsx("span", { className: "nx-dropdown-list__heading", style: column.align && column.align !== "start" ? { justifySelf: column.align, textAlign: column.align } : undefined, children: column.header }, index)))] })), rows.map((row, index) => {
780
+ listContent = (_jsxs(_Fragment, { children: [showHeader && (_jsxs("div", { className: "nx-dropdown-list__header", "aria-hidden": "true", children: [_jsx("span", {}), typedColumns.map((column, index) => (_jsx("span", { className: "nx-dropdown-list__heading", style: columnAlignStyle(column), children: column.header }, index)))] })), rows.map((row, index) => {
778
781
  const labelField = row.item ? readField(row.item, labelKey) : undefined;
779
782
  const description = row.item && !typedColumns ? readField(row.item, descriptionKey) : undefined;
780
783
  const pending = isPending(row.key, row.item);
@@ -806,7 +809,7 @@ export function Dropdown(props) {
806
809
  // The check column, the columns, and an empty last track whose gap keeps text off the row's end.
807
810
  const gridColumns = columns ? `auto ${columns.map(column => columnTrack(column)).join(" ")} 0px` : undefined;
808
811
  const panelStyle = panelWidth === undefined ? undefined : { width: typeof panelWidth === "number" ? `${panelWidth}px` : panelWidth };
809
- return (_jsxs(_Fragment, { children: [_jsxs(Field, { controlId: field.controlId, messageId: field.messageId, label: label, required: required, design: design, size: size, hint: hint, error: field.error, icon: icon, actions: _jsxs(_Fragment, { children: [busy && (_jsx("span", { className: "nx-dropdown__spinner", title: "Loading", "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) })), showClear && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__clear", tabIndex: -1, "aria-label": "Clear", title: "Clear", onMouseDown: event => event.preventDefault(), onClick: clear, children: _jsx(InlineIcon, { icon: iconX }) })), readOnlyNotice.lock, !readOnly && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__toggle", tabIndex: -1, "aria-label": open ? "Close the list" : "Open the list", "aria-expanded": open, "aria-controls": open ? listId : undefined, disabled: disabled, onMouseDown: event => event.preventDefault(), onClick: () => (open ? closePanel() : openPanel()), children: _jsx(InlineIcon, { icon: iconChevronDown }) }))] }), disabled: disabled, readOnly: readOnly, raiseLabel: values.length > 0 || Boolean(placeholder), className: cx("nx-field--dropdown", className), style: style, children: [_jsxs("div", { className: cx("nx-dropdown__control", multiple ? "nx-dropdown__control--multiple" : "nx-dropdown__control--single"), "data-searchable": searchable || undefined, "data-has-value": values.length > 0 || undefined, onMouseDown: handleControlMouseDown, children: [boxContent, _jsx("input", { autoComplete: "off", spellCheck: false, ...inputProps, ...field.controlProps, type: "text", role: "combobox", "aria-expanded": open, "aria-haspopup": "listbox", "aria-controls": open ? listId : undefined, "aria-activedescendant": activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined, "aria-autocomplete": searchable ? "list" : "none", inputMode: searchable ? inputProps.inputMode : "none", name: undefined, form: form, className: cx("nx-field__control", "nx-dropdown__input", inputClassName), placeholder: values.length > 0 ? " " : placeholder || " ", disabled: disabled, readOnly: readOnly, onBlur: handleBlur, onFocus: onFocus, onKeyDown: handleKeyDown, onClick: handleClick }), _jsx("span", { id: selectionId, className: "nx-visually-hidden", children: values.length > 0 ? `Selected: ${namesOf(values)}` : "" })] }), name &&
812
+ return (_jsxs(_Fragment, { children: [_jsxs(Field, { controlId: field.controlId, messageId: field.messageId, label: label, required: required, design: design, size: size, hint: hint, error: field.error, invalid: invalid, icon: icon, actions: _jsxs(_Fragment, { children: [busy && (_jsx("span", { className: "nx-dropdown__spinner", title: "Loading", "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) })), showClear && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__clear", tabIndex: -1, "aria-label": "Clear", title: "Clear", onMouseDown: event => event.preventDefault(), onClick: clear, children: _jsx(InlineIcon, { icon: iconX }) })), readOnlyNotice.lock, !readOnly && (_jsx("button", { type: "button", className: "nx-field__action nx-dropdown__toggle", tabIndex: -1, "aria-label": open ? "Close the list" : "Open the list", "aria-expanded": open, "aria-controls": open ? listId : undefined, disabled: disabled, onMouseDown: event => event.preventDefault(), onClick: () => (open ? closePanel() : openPanel()), children: _jsx(InlineIcon, { icon: iconChevronDown }) }))] }), disabled: disabled, readOnly: readOnly, raiseLabel: values.length > 0 || Boolean(placeholder), className: cx("nx-field--dropdown", className), style: style, children: [_jsxs("div", { className: cx("nx-dropdown__control", multiple ? "nx-dropdown__control--multiple" : "nx-dropdown__control--single"), "data-searchable": searchable || undefined, "data-has-value": values.length > 0 || undefined, onMouseDown: handleControlMouseDown, children: [boxContent, _jsx("input", { autoComplete: "off", spellCheck: false, ...inputProps, ...field.controlProps, type: "text", role: "combobox", "aria-expanded": open, "aria-haspopup": "listbox", "aria-controls": open ? listId : undefined, "aria-activedescendant": activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined, "aria-autocomplete": searchable ? "list" : "none", "aria-busy": busy || undefined, inputMode: searchable ? inputProps.inputMode : "none", name: undefined, form: form, className: cx("nx-field__control", "nx-dropdown__input", inputClassName), placeholder: values.length > 0 ? " " : placeholder || " ", disabled: disabled, readOnly: readOnly, onBlur: handleBlur, onFocus: onFocus, onKeyDown: handleKeyDown, onClick: handleClick }), _jsx("span", { id: selectionId, className: "nx-visually-hidden", children: values.length > 0 ? `Selected: ${namesOf(values)}` : busy ? "Loading" : "" })] }), name &&
810
813
  (multiple ? (values.map(value => _jsx("input", { type: "hidden", name: name, form: form, disabled: disabled, value: optionKey(value) }, optionKey(value)))) : (_jsx("input", { type: "hidden", name: name, form: form, disabled: disabled, value: optionKey(values[0]) })))] }), readOnlyNotice.notice, !readOnly && (_jsx("div", { ref: panelRef, popover: "manual", className: cx("nx-popover", "nx-dropdown-panel", `nx-dropdown-panel--${size}`), style: panelStyle, onKeyDown: handlePanelKeyDown, onBlur: handlePanelBlur,
811
814
  // The box keeps focus while rows and buttons are pressed. A press on the list itself is left alone, so its
812
815
  // scroll bar can be dragged.