@nxgt/material 3.68.0 → 3.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +13 -9
  3. package/dist/{alert-dialog-BhARh_qC.js → alert-dialog-y2pbowVh.js} +1 -1
  4. package/dist/{async-slice-CHyRU5V3.js → async-slice-CGfWBOaV.js} +1 -1
  5. package/dist/{button-Du7fJ9i_.js → button-D54Hrjo0.js} +1 -1
  6. package/dist/charts.js +3 -3
  7. package/dist/{chip-BVB6UYl-.js → chip-B5MhXBfm.js} +2 -2
  8. package/dist/{code-editor-D-GBs2yu.js → code-editor-mULs4SL8.js} +5 -5
  9. package/dist/code-editor.js +5 -5
  10. package/dist/{code-language-items-xrgq1Am0.js → code-language-items-BWDnqUqn.js} +2 -2
  11. package/dist/components/ui/collections/server-table/server-table-phone-row.css +6 -0
  12. package/dist/components/ui/display/code/code-moonlight.css +37 -0
  13. package/dist/{components-iNCh4A6M.js → components-B_taYVcd.js} +19740 -18682
  14. package/dist/components.js +28 -28
  15. package/dist/{dialog-DFY_nsqF.js → dialog-AjCYZ2Vz.js} +4 -4
  16. package/dist/dnd.js +1 -1
  17. package/dist/{dropdown-menu-B6Yh4XJu.js → dropdown-menu-LN60hu_3.js} +1 -1
  18. package/dist/editor.js +673 -673
  19. package/dist/{empty-state-DGtwE50c.js → empty-state-Bo8G8W8G.js} +2 -2
  20. package/dist/{field-blur-Br7ij-a1.js → field-blur-Cnd6FfCH.js} +1 -1
  21. package/dist/{field-blur-context-CucMAZLj.js → field-blur-context-bUFZj8iN.js} +1 -1
  22. package/dist/{hooks-CYQJkKGc.js → hooks-Dt4PkQiZ.js} +2 -2
  23. package/dist/hooks.js +3 -3
  24. package/dist/{hover-card-C2J0GwWf.js → hover-card-QtGnvsnc.js} +1 -1
  25. package/dist/i18n.js +3 -3
  26. package/dist/{icon-badge-LzO5K6Wu.js → icon-badge-DLbpCQIm.js} +3 -3
  27. package/dist/{icon-button-vLJ4YrEW.js → icon-button-C59DqL7L.js} +1 -1
  28. package/dist/lib/components/ui/actions/fab/fab-dock-inset.d.ts +29 -0
  29. package/dist/lib/components/ui/actions/fab/fab-dock.d.ts +6 -1
  30. package/dist/lib/components/ui/collections/data-table/select-checkbox.d.ts +3 -2
  31. package/dist/lib/components/ui/collections/filter/use-filter-field-config.d.ts +3 -3
  32. package/dist/lib/components/ui/collections/index.d.ts +1 -0
  33. package/dist/lib/components/ui/collections/record-actions-menu/index.d.ts +2 -0
  34. package/dist/lib/components/ui/collections/record-actions-menu/record-actions-dialogs.d.ts +15 -0
  35. package/dist/lib/components/ui/collections/record-actions-menu/record-actions-menu.d.ts +72 -0
  36. package/dist/lib/components/ui/collections/record-actions-menu/record-actions-rules.d.ts +68 -0
  37. package/dist/lib/components/ui/collections/record-actions-menu/record-actions-words.d.ts +52 -0
  38. package/dist/lib/components/ui/collections/record-actions-menu/use-record-actions.d.ts +51 -0
  39. package/dist/lib/components/ui/collections/record-actions-menu/use-record-flight.d.ts +16 -0
  40. package/dist/lib/components/ui/collections/row-menu/row-menu-column.d.ts +4 -0
  41. package/dist/lib/components/ui/collections/row-menu/row-menu.d.ts +1 -1
  42. package/dist/lib/components/ui/collections/server-table/partials/server-table-band-classes.d.ts +7 -5
  43. package/dist/lib/components/ui/collections/server-table/partials/server-table-column-sorting.d.ts +7 -0
  44. package/dist/lib/components/ui/collections/server-table/types.d.ts +3 -1
  45. package/dist/lib/components/ui/display/markdown/markdown.d.ts +12 -1
  46. package/dist/lib/components/ui/display/markdown/markdown.utils.d.ts +2 -0
  47. package/dist/lib/components/ui/display/markdown/mermaid-csp-scope.d.ts +8 -0
  48. package/dist/lib/components/ui/display/markdown/mermaid-markup.d.ts +20 -0
  49. package/dist/lib/components/ui/display/markdown/partials/markdown-code.d.ts +5 -1
  50. package/dist/lib/components/ui/feedback/confirm-dialog/action-refusal.d.ts +24 -0
  51. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-alert.d.ts +17 -0
  52. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-description.d.ts +10 -0
  53. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-dialog.d.ts +24 -2
  54. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-refusal.d.ts +7 -0
  55. package/dist/lib/components/ui/feedback/confirm-dialog/index.d.ts +1 -0
  56. package/dist/lib/components/ui/feedback/confirm-dialog/use-confirm-action.d.ts +6 -0
  57. package/dist/lib/components/ui/feedback/index.d.ts +1 -0
  58. package/dist/lib/components/ui/feedback/stale-notice/index.d.ts +1 -0
  59. package/dist/lib/components/ui/feedback/stale-notice/stale-notice.d.ts +15 -0
  60. package/dist/lib/components/ui/fields/contacts/placeholders.d.ts +1 -1
  61. package/dist/lib/components/ui/fields/country-field/index.d.ts +2 -0
  62. package/dist/lib/components/ui/fields/country-field/iso-countries.d.ts +2 -0
  63. package/dist/lib/components/ui/fields/index.d.ts +1 -0
  64. package/dist/lib/components/ui/fields/phone-field/index.d.ts +1 -1
  65. package/dist/lib/components/ui/fields/phone-field/parse-phone.d.ts +2 -0
  66. package/dist/lib/components/ui/fields/phone-field/phone-field.d.ts +8 -1
  67. package/dist/lib/components/ui/fields/phone-field/phone-form-field.d.ts +8 -1
  68. package/dist/lib/components/ui/fields/phone-field/use-phone-value.d.ts +2 -1
  69. package/dist/lib/components/ui/fields/postal-address/utils.d.ts +15 -0
  70. package/dist/lib/components/ui/fields/quantity-field/index.d.ts +3 -0
  71. package/dist/lib/components/ui/fields/quantity-field/quantity-field.d.ts +8 -0
  72. package/dist/lib/components/ui/fields/quantity-field/quantity-form-field.d.ts +8 -0
  73. package/dist/lib/components/ui/fields/quantity-field/quantity-row.d.ts +13 -0
  74. package/dist/lib/components/ui/fields/quantity-field/quantity-unit.d.ts +12 -0
  75. package/dist/lib/components/ui/fields/quantity-field/quantity-value.d.ts +9 -0
  76. package/dist/lib/components/ui/fields/quantity-field/types.d.ts +45 -0
  77. package/dist/lib/components/ui/forms/index.d.ts +1 -0
  78. package/dist/lib/components/ui/forms/use-server-field-errors.d.ts +21 -0
  79. package/dist/lib/components/ui/layout/index.d.ts +1 -0
  80. package/dist/lib/components/ui/layout/record-page/index.d.ts +2 -0
  81. package/dist/lib/components/ui/layout/record-page/record-details.d.ts +40 -0
  82. package/dist/lib/components/ui/layout/record-page/record-page.d.ts +27 -0
  83. package/dist/lib/components/ui/media/upload/index.d.ts +1 -0
  84. package/dist/lib/components/ui/media/upload/network-upload-field/network-upload-field.d.ts +6 -1
  85. package/dist/lib/components/ui/media/upload/network-upload-field/network-upload-field.errors.d.ts +8 -0
  86. package/dist/lib/components/ui/media/upload/network-upload-field/network-upload-field.utils.d.ts +2 -7
  87. package/dist/lib/components/ui/media/upload/network-upload-field/use-start-upload.d.ts +7 -3
  88. package/dist/lib/components/ui/media/upload/network-upload-field/use-upload-item-handlers.d.ts +2 -1
  89. package/dist/lib/components/ui/media/upload/s3-upload-field/s3-upload-field.d.ts +2 -5
  90. package/dist/lib/components/ui/media/upload/s3-upload-field/s3-upload-field.utils.d.ts +26 -1
  91. package/dist/lib/components/ui/media/upload/upload-error.d.ts +33 -0
  92. package/dist/lib/components/ui/selection/autocomplete/autocomplete-enter.d.ts +18 -0
  93. package/dist/lib/components/ui/selection/autocomplete/partials/autocomplete-chip-input.d.ts +2 -1
  94. package/dist/lib/components/ui/selection/autocomplete/partials/autocomplete-text-input.d.ts +2 -1
  95. package/dist/lib/components/ui/selection/autocomplete/types.d.ts +3 -1
  96. package/dist/lib/components/ui/widgets/chat/chat-conversation-item.d.ts +8 -2
  97. package/dist/lib/components/ui/widgets/chat/chat-markdown.d.ts +4 -0
  98. package/dist/lib/components/ui/widgets/chat/chat-message-list.d.ts +8 -1
  99. package/dist/lib/components/ui/widgets/chat/index.d.ts +2 -0
  100. package/dist/lib/components/ui/widgets/chat/partials/chat-conversation-item-text.d.ts +2 -1
  101. package/dist/lib/components/ui/widgets/chat/partials/chat-empty-thread.d.ts +2 -0
  102. package/dist/lib/components/ui/widgets/chat/proposal/chat-action-proposal-parts.d.ts +24 -0
  103. package/dist/lib/components/ui/widgets/chat/proposal/chat-action-proposal.d.ts +39 -0
  104. package/dist/lib/components/ui/widgets/chat/proposal/chat-action-proposal.rules.d.ts +17 -0
  105. package/dist/lib/components/ui/widgets/chat/proposal/chat-tool-activity.d.ts +16 -0
  106. package/dist/lib/components/ui/widgets/education/questions/partials/question-content.d.ts +1 -1
  107. package/dist/lib/components/ui/widgets/education/questions/question-text.d.ts +1 -1
  108. package/dist/lib/components/ui/widgets/education/questions/question.d.ts +1 -1
  109. package/dist/lib/components/ui/widgets/education/questions/types.d.ts +10 -0
  110. package/dist/lib/i18n/resources/en/en.d.ts +49 -0
  111. package/dist/lib/i18n/resources/en/material.json.d.ts +50 -1
  112. package/dist/lib/i18n/resources/fr/fr.d.ts +49 -0
  113. package/dist/lib/i18n/resources/fr/material.json.d.ts +50 -1
  114. package/dist/lib/i18n/resources/resources.d.ts +98 -0
  115. package/dist/lib/i18n/translator.d.ts +1 -1
  116. package/dist/lib/print/output/to-pdf-element.d.ts +6 -0
  117. package/dist/lib.js +1 -1
  118. package/dist/main.js +42 -42
  119. package/dist/map.js +8 -8
  120. package/dist/material.css +1 -1
  121. package/dist/pdf-reader.js +1 -1
  122. package/dist/print.js +135 -131
  123. package/dist/{public-BHthpdpe.js → public-BSaSCwy7.js} +9 -9
  124. package/dist/{rich-code-block-DoW7l2ND.js → rich-code-block-BFVj1GWz.js} +5 -5
  125. package/dist/{sortable-provider-FLNMfr0o.js → sortable-provider-REwyjiOZ.js} +2 -2
  126. package/dist/{spinner-DCgDw8eq.js → spinner-BFZxqdWC.js} +1 -1
  127. package/dist/theme-picker.js +2 -2
  128. package/dist/{theme-toggle-BMDAwMLh.js → theme-toggle-BHi6miz-.js} +1 -1
  129. package/dist/{toolbar-items-CkPEhQq7.js → toolbar-items-BTtoKVlx.js} +3 -3
  130. package/dist/{translate-material-DkcuQBNr.js → translate-material-R-uLthBy.js} +1 -1
  131. package/dist/{translator-DXdVRdMm.js → translator-ClbXOGQO.js} +100 -2
  132. package/dist/{typography-BNS_IbFI.js → typography-v-PJ4Zco.js} +1 -1
  133. package/dist/{use-chat-composer-B-tGRLkI.js → use-chat-composer-Rhan4Xv0.js} +2463 -2405
  134. package/dist/{use-heading-spy-DaqibCCm.js → use-heading-spy-CJnBkCY0.js} +179 -87
  135. package/dist/{use-material-translation-Bxl8sv1n.js → use-material-translation-CCWUeiEn.js} +1 -1
  136. package/dist/{use-return-focus-Y_Kx0QlW.js → use-return-focus-Duk40DZI.js} +1 -1
  137. package/docs/components/buttons-and-actions.md +24 -0
  138. package/docs/components/data-display.md +33 -0
  139. package/docs/components/data-table-filter-and-lists.md +117 -5
  140. package/docs/components/feedback-and-status.md +58 -2
  141. package/docs/components/form-fields.md +97 -3
  142. package/docs/components/media-and-files.md +55 -9
  143. package/docs/components/navigation-and-layout.md +58 -0
  144. package/docs/components/rich-widgets.md +37 -3
  145. package/docs/components/selection-and-choice.md +10 -3
  146. package/docs/guides/cards-and-tiles.md +4 -2
  147. package/docs/guides/csp.md +44 -6
  148. package/docs/guides/forms.md +55 -7
  149. package/docs/guides/i18n.md +6 -5
  150. package/docs/guides/printing.md +17 -7
  151. package/docs/previews/data-table-filter-lists-recordactionsmenu.webp +0 -0
  152. package/docs/previews/feedback-status-stalenotice.webp +0 -0
  153. package/docs/previews/form-fields-quantityfield.webp +0 -0
  154. package/docs/previews/media-files-networkuploadfield.webp +0 -0
  155. package/docs/previews/media-files-s3uploadfield.webp +0 -0
  156. package/docs/previews/media-files-uploadfield.webp +0 -0
  157. package/docs/previews/navigation-layout-recordpage.webp +0 -0
  158. package/docs/previews/rich-widgets-chat-action-proposal.webp +0 -0
  159. package/docs/roadmap.md +14 -1
  160. package/docs/troubleshooting.md +58 -11
  161. package/lib/components/ui/collections/server-table/server-table-phone-row.css +6 -0
  162. package/lib/components/ui/display/code/code-moonlight.css +37 -0
  163. package/package.json +2 -2
@@ -16,6 +16,7 @@ either can be used alone.
16
16
  | <img src="../previews/data-table-filter-lists-datatable.webp" width="96" alt=""> | [DataTable](#datatable) | TanStack Table v9 with sorting, selection, expansion and pagination, client-side or one server page at a time |
17
17
  | <img src="../previews/data-table-filter-lists-servertable.webp" width="96" alt=""> | [ServerTable](#servertable) | A server-mode `DataTable` driven by the URL, with the Filter in its top band (a title and a description, or neither): sort, cursor paging, the empty page with a scene, an action and removable chips, one count announced |
18
18
  | <img src="../previews/data-table-filter-lists-rowmenu.webp" width="96" alt=""> | [RowMenu](#rowmenu) | A row's "..." button named after its row ("Actions for Ada"), its menu aligned to the end with the destructive item last, a loading state while an action runs, `rowMenuColumn` to drop both in a table, and `useRowMenuFocus` to hand the focus back to a row's button after an action |
19
+ | <img src="../previews/data-table-filter-lists-recordactionsmenu.webp" width="96" alt=""> | [RecordActionsMenu](#recordactionsmenu) | A record's archive, restore and delete menu over `RowMenu`: Archive runs at once with an Undo toast that stays until dismissed, Restore runs at once, Delete permanently asks first and keeps a refusal in its dialog; `archivePolicy="confirm"` asks before archiving, `consequence` notes what an action entails, `successAction` adds a toast action, and an `ActionRefusal` (`isActionRefusal`) carries a refusal's details and action |
19
20
  | <img src="../previews/data-table-filter-lists-listempty.webp" width="96" alt=""> | [ListEmpty](#listempty) | A list's empty page: nothing yet, with a scene and an action, or nothing matches and the way out |
20
21
  | <img src="../previews/data-table-filter-lists-pagefooter.webp" width="96" alt=""> | [PageFooter](#pagefooter) | The page footer of a feed that is not a table (cards, a news feed) under a FilterBar: the range and Previous / Next as links from the URL's cursor trail, `pageControls="icon"`; no `totalCount` for a feed with no total (no range, Previous / Next only); `hideOnSinglePage` hides it on a single page |
21
22
  | <img src="../previews/data-table-filter-lists-datagrid.webp" width="96" alt=""> | [DataGrid](#datagrid) | An editable grid: spreadsheet keys, paste, a fill handle with series, undo, totals, saving with versions and conflicts (lazy ones fetch theirs on "Keep mine"), `onRefuse` for a refused paste or fill, row selection (`enableRowSelection`), and resizable, hideable and reorderable columns (`columnControls`, `columnLayout`, `DataGridColumnsMenu`) |
@@ -1042,7 +1043,7 @@ Its props are `ServerTableProps<T>`; `filter` is a `ServerTableFilter`,
1042
1043
  | `hideOnSinglePage` | `boolean` | `false` | hides the footer's range and Previous / Next when the result is one page (no previous, no next), in the table, cards and tiles layouts; the count is still announced by the live region but no longer shown, so put it in `description` or keep the footer. With `bulkActions` the footer stays for "N rows selected", without its pager |
1043
1044
  | `bandActions` | `'stacked' \| 'inline'` | `'stacked'` | with `filter`, where `actions` sit below 768px: `'stacked'` gives them a row above the search; `'inline'` keeps them on the search's row (the search takes the room left), for a lone icon action such as a "+". An action wider than 3.5rem wraps under the search. From 768px both are the same. With a cards or tiles list on a phone, neither applies: [the two-row header](#the-phone-header-of-a-list-in-cards-or-tiles) puts the actions on its second row |
1044
1045
  | `filterRef` | `Ref<ServerTableFilterHandle>` | — | with `filter`: `filterRef.current.focus()` focuses the Filter's first control, the search, else the first quick filter, "Add filter" or "Filters (N)" the width shows |
1045
- | `sorting` | `createSorting(…)` | — | headers sorted from the URL |
1046
+ | `sorting` | `createSorting(…)` | — | headers sorted from the URL; without it, no column is sortable (the server sorts, so pass `sorting` to let people sort), and the phone list bar shows only for selection |
1046
1047
  | `filters` | `tableFilters(model)` | — | without `filter`: "nothing matches" and its clear |
1047
1048
  | `empty` | `ServerTableEmpty`: `{ none, noMatch, clear?, headingLevel?, iconStyle? }` | — | the empty page: `none` and `noMatch` are a title or a `ListEmptyPage` (`{ title, description?, illustration?, action? }`); `headingLevel` (default 2) is the level of its title and of the error's; `iconStyle` styles the scenes (see below) |
1048
1049
  | `error` | `ServerTableError \| null` | — | the fetch failed: the failure and a Retry replace the rows |
@@ -1071,6 +1072,11 @@ not three:
1071
1072
  "Select all on this page"); at the end, the sort menu, "Filters (N)" and
1072
1073
  the page's `actions`.
1073
1074
 
1075
+ On a phone (below 768px) the "Select all" text shortens with an ellipsis (the box keeps its
1076
+ full name) rather than run under the sort, "Filters (N)" or the actions. When
1077
+ the end group is too wide to leave the start 6rem, it wraps to a line of its
1078
+ own under "Select all", in the same order.
1079
+
1074
1080
  While rows are selected, row 2 becomes the selection bar: "N rows selected"
1075
1081
  (`material.table.pagination.selected`) and "Clear selection" take the label's
1076
1082
  place, and the `bulkActions` take the place of the sort, "Filters (N)" and the
@@ -1092,8 +1098,8 @@ Filters sheet are the same. A list that cannot be selected has no checkbox
1092
1098
  without `actions`, `variant: 'facets'` and every width from 768px are
1093
1099
  unchanged, and `bandActions` has no effect while the second row is there.
1094
1100
 
1095
- The row is narrow, so it compacts to the band's width, never wrapping to a
1096
- third row: below 30rem the sort shows its icon and arrow (its name, "Sort: Name,
1101
+ The row is narrow, so it compacts to the band's width: when the end group is
1102
+ still too wide it wraps under "Select all" (a third line, see above). Below 30rem the sort shows its icon and arrow (its name, "Sort: Name,
1097
1103
  Ascending", is unchanged); "Filters (N)" keeps its icon and count (and its
1098
1104
  name) while rows are selected, and on a band narrower than 19rem; the count
1099
1105
  may take two lines. A button of `actions` that has an icon (a lucide glyph or
@@ -1195,10 +1201,16 @@ const columns = [
1195
1201
 
1196
1202
  `rowMenuColumn` returns a column for `DataTable` and `ServerTable`: a header
1197
1203
  that is visually hidden but named "Actions" (`material.row-menu.column`), a cell
1198
- per row, no sorting and no hiding. Build the columns once (outside the render,
1199
- or in a `useMemo`), and keep a dialog's state in the page, not in the cell: a
1204
+ per row, no sorting and no hiding. A row whose `items(row)` is empty (or whose
1205
+ `items` option is absent) and whose `menu(row)` returns nothing (`null`,
1206
+ `undefined` or `false`, as in `cond && <X />`) renders an empty cell, with no
1207
+ button, and the column keeps its width. A fragment counts as content, even an
1208
+ empty one. Build the columns once (outside the render, or in a `useMemo`), and
1209
+ keep a dialog's state in the page, not in the cell: a
1200
1210
  cell that remounts would lose it.
1201
1211
 
1212
+ When any item has an icon, the items without one are inset by the icon's width, so the labels line up.
1213
+
1202
1214
  For a menu of your own, compose the parts: `RowMenuTrigger` forwards its props
1203
1215
  and `ref`, so it is the child of a `DropdownMenuTrigger asChild`.
1204
1216
 
@@ -1303,6 +1315,106 @@ a table re-render that remounts the button.
1303
1315
 
1304
1316
  **Storybook:** `Data table, filter & lists/RowMenu`, `Guides/Accessibility/Touch targets/Row menu` · **Vue:** not yet (tracked as a `vue` issue)
1305
1317
 
1318
+
1319
+ ---
1320
+
1321
+ ## RecordActionsMenu
1322
+
1323
+ ![RecordActionsMenu](../previews/data-table-filter-lists-recordactionsmenu.webp)
1324
+
1325
+ A record's archive, restore and delete menu, built over [RowMenu](#rowmenu): the same
1326
+ button ("Actions for {name}", 44px on touch), the same focus return, and the
1327
+ record's actions after any of your own.
1328
+
1329
+ - **Archive** (a record that is not archived) runs at once and shows a toast, "{name} archived", with an **Undo** action. Each archive leaves a toast until it is dismissed (WCAG 2.2.1: no timeout to race). Undo restores; a successful Undo calls `onDone('restore')` and shows no second toast; a failure of it is shown in a toast.
1330
+ - **Restore** (an archived record) runs at once.
1331
+ - **Delete permanently** (an archived record only) comes after a separator, in the error colour, and **always asks**: a `ConfirmDialog` with `lockOnRefusal`. A refusal stays in the dialog and locks the confirm button; Cancel stays.
1332
+ - **`archivePolicy="confirm"`** makes Archive ask first, in a `ConfirmDialog` (default tone) whose note is the `consequence`; its success toast is plain, with no Undo. A refusal then stays in that dialog, as delete's does (not locked: Cancel and a retry stay possible).
1333
+ - **`consequence`** is a note of what the action entails, shown under the dialog's description (delete, and archive under `'confirm'`). One node for every action, or a map per intent: `{ delete: 'Its files are deleted too.' }`.
1334
+ - **`successAction`** adds an action to the success toast, per intent (`archive`, `restore`): `{ archive: { label: 'View archive', href: '/archive' } }`, or `onAction` for a client-side navigation. A toast has one button, so beside Undo the action is a link in the toast's text.
1335
+ - **`ActionRefusal`**: reject `send` with `new ActionRefusal(message, { action, details? })` to carry a refusal with an action (`{ label, href }` or `{ label, onAction }`). The delete dialog (and the confirm-archive one) shows the message, the details and the action, as any [ConfirmDialog](feedback-and-status.md#confirmdialog) does by default; a link or handler closes the dialog. An archive or restore refusal shows the action as the toast's button. A plain `Error` keeps showing its message only. The class, `isActionRefusal` and `RecordAction` belong to `ConfirmDialog`'s section.
1336
+ - A refused archive or restore shows the error's message in a toast.
1337
+ - Undo goes through the same flight as Archive and Restore: the menu's trigger is busy meanwhile and the menu cannot start another action until it is over. An Undo clicked while another action runs waits for it, then runs only if the record is archived by then (a failed archive or a successful restore leaves nothing to undo; the hook decides from what settled, not from a re-render); a second click on the same Undo does nothing.
1338
+ - When any item of the menu has an icon, the ones without (your own `items`) are inset so the labels line up.
1339
+ - The whole menu renders nothing when `can` leaves no action and you gave no item of your own.
1340
+
1341
+ ```tsx
1342
+ import { RecordActionsMenu } from '@nxgt/material/components';
1343
+
1344
+ <RecordActionsMenu
1345
+ name={project.title}
1346
+ archived={project.archivedAt != null}
1347
+ can={(intent) => permissions.has(`project:${intent}`)}
1348
+ send={(intent) => api.projects.act(project.id, intent)}
1349
+ onDone={(intent) => intent === 'delete' && navigate('/projects')}
1350
+ items={[{ label: 'Edit', onSelect: () => navigate(`${project.id}/edit`) }]}
1351
+ />
1352
+ ```
1353
+
1354
+ | Prop | Type | Default | Effect |
1355
+ | --- | --- | --- | --- |
1356
+ | `name` | `string` | — | the record's name: the button's name ("Actions for {name}"), the toasts' and the dialog's text |
1357
+ | `archived` | `boolean` | — | `true` offers Restore, then Delete permanently; `false` offers Archive |
1358
+ | `send` | `(intent: 'archive' \| 'restore' \| 'delete') => Promise<unknown>` | — | does the action. A rejection's message is shown in a toast (archive, restore) or in the dialog (delete) |
1359
+ | `can` | `(intent) => boolean` | everything | the actions refused are left out |
1360
+ | `onDone` | `(intent) => void` | — | after a success, an Undo's restore included (`'restore'`): refresh a list, or leave a deleted record's route |
1361
+ | `items` | `RowMenuItem[]` | — | items of your own, before the record's actions |
1362
+ | `children` | `ReactNode` | — | menu content of your own (submenus, groups), above `items` |
1363
+ | `messages` | `RecordActionsMessages` | translated | wording overrides (camelCase keys; the i18n keys are kebab-case), `{name}` replaced by the record's name: `archive`, `restore`, `delete`, `archived`, `restored`, `undo`, `failed`, `archiveTitle`, `archiveDescription`, `archiveConfirm`, `deleteTitle`, `deleteDescription`, `deleteConfirm` |
1364
+ | `archivePolicy` | `'undo' \| 'confirm'` | `'undo'` | `'undo'` archives at once with an Undo toast; `'confirm'` asks in a dialog first, with no Undo |
1365
+ | `consequence` | `ReactNode \| Partial<Record<ConsequenceIntent, ReactNode>>` | — | the dialog's note of what the action entails (delete, and archive under `'confirm'`) |
1366
+ | `successAction` | `{ archive?: RecordAction; restore?: RecordAction }` | — | an action on the success toast; `RecordAction` is `{ label, href?, onAction? }`: `onAction` wins when both are given, and `href` is a full page load (use `onAction` for client-side navigation) |
1367
+ | `refusal` | `(ctx: ConfirmRefusalContext) => ReactNode` | — | replaces `ConfirmDialog`'s default refusal (the message, plus an `ActionRefusal`'s details and action) in both dialogs, delete and archive (`'confirm'`); `ctx.cause` is what `send` rejected with. Toasts are not affected |
1368
+ | `rowId`, `triggerProps`, `className` | as `RowMenu` | — | passed to the `RowMenu` |
1369
+
1370
+ ```tsx
1371
+ import { ActionRefusal, RecordActionsMenu } from '@nxgt/material/components';
1372
+
1373
+ <RecordActionsMenu
1374
+ name={project.title}
1375
+ archived={project.archivedAt != null}
1376
+ archivePolicy="confirm"
1377
+ consequence={{ archive: 'Open tasks stay assigned.', delete: 'Its files are deleted too.' }}
1378
+ successAction={{ restore: { label: 'Open project', onAction: () => navigate(project.id) } }}
1379
+ send={async (intent) => {
1380
+ const blockers = await api.projects.act(project.id, intent);
1381
+ if (blockers) {
1382
+ throw new ActionRefusal(`Still used by ${blockers.count} invoices`, {
1383
+ details: 'Remove them first.',
1384
+ action: { label: 'View the invoices', href: `/invoices?project=${project.id}` },
1385
+ });
1386
+ }
1387
+ }}
1388
+ />
1389
+ ```
1390
+
1391
+ Types: `RecordActionsMenuProps`, `RecordIntent` (`'archive' | 'restore' | 'delete'`), `ConsequenceIntent` (`'archive' | 'delete'`, the intents with a dialog), `ArchivePolicy`, `RecordSuccessActions`, `RecordConsequence`, `RecordActionsMessages` and `UseRecordActionsOptions`, all exported (`RecordAction`, `ActionRefusal` and `isActionRefusal` come with [ConfirmDialog](feedback-and-status.md#confirmdialog)). A throw from `onDone` is logged with `console.error`; it does not hide the toast nor count as a refusal. A second archive or restore while one is pending is ignored; an Undo waits for it.
1392
+
1393
+ The default words are the `material.record-actions.*` keys (`archive`, `restore`,
1394
+ `delete`, `archived`, `restored`, `undo`, `failed`, `archive-title`, `archive-description`,
1395
+ `archive-confirm`, `delete-title`, `delete-description`, `delete-confirm`). `useRecordActions` holds the logic if you
1396
+ need to build the menu from other parts. The archive toast is shown by `toasts`,
1397
+ so mount a `Toaster`.
1398
+
1399
+ ### useRecordActions
1400
+
1401
+ The menu's logic, for a menu built from other parts. It takes `UseRecordActionsOptions` (`name`, `archived`, `send`, and optionally `can`, `onDone`, `messages`, `archivePolicy`, `successAction`) and returns:
1402
+
1403
+ | Field | Type | Meaning |
1404
+ | --- | --- | --- |
1405
+ | `intents` | `RecordIntent[]` | the actions offered, `can` applied |
1406
+ | `running` | `'archive' \| 'restore' \| null` | the action in flight (an Undo counts as `'restore'`) |
1407
+ | `run(intent)` | `(intent: 'archive' \| 'restore') => Promise<void>` | runs it and shows the toasts (Undo included) |
1408
+ | `runUndo()` | `() => Promise<void>` | the archive toast's Undo: queued behind a pending action, then restores through the same flight, so `running` is set meanwhile |
1409
+ | `confirmingDelete`, `setConfirmingDelete` | `boolean`, setter | the delete dialog's open state |
1410
+ | `confirmingArchive`, `setConfirmingArchive` | `boolean`, setter | the archive dialog's open state (`archivePolicy="confirm"`) |
1411
+ | `chooseArchive()` | `() => void` | what choosing Archive does: opens the dialog under `'confirm'`, else runs at once |
1412
+ | `archiveConfirmed()` | `() => Promise<void>` | the archive dialog's `onConfirm`: sends `archive`, throws a refusal |
1413
+ | `deleteConfirmed()` | `() => Promise<void>` | the delete dialog's `onConfirm`: sends `delete`, throws a refusal |
1414
+ | `text` | strings | the words: `archive`, `restore`, `delete`, `archiveTitle`, `archiveDescription`, `archiveConfirm`, `deleteTitle`, `deleteDescription`, `deleteConfirm` |
1415
+
1416
+ **Storybook:** `Data table, filter & lists/RecordActionsMenu` (`Overview`, `Archive Then Undo`, `Archived Record`, `Delete Refused Stays Locked`, `Delete Confirmed`, `Iconless Item Aligned`) and `…/RecordActionsMenu/Confirm and refusals` (`Archive With Confirm`, `Archive Cancel Returns Focus`, `Note Is Described`, `Delete Refused With Action`, `Archive Refused With Handler`, the `Refusal Override` pair, the refused-toast pair, `Success Action On Toast`) · **Vue:** not yet (#2092)
1417
+
1306
1418
  ---
1307
1419
 
1308
1420
  ## ListEmpty
@@ -9,10 +9,11 @@ centre.
9
9
  | <img src="../previews/feedback-status-alert.webp" width="96" alt=""> | [Alert](#alert) | Callout with icon, title and description in seven tones |
10
10
  | <img src="../previews/feedback-status-banner.webp" width="96" alt=""> | [Banner](#banner) | Dismissible banner with an action, in the look of Alert |
11
11
  | <img src="../previews/feedback-status-alertdialog.webp" width="96" alt=""> | [AlertDialog](#alertdialog) | Parts for a blocking confirmation dialog |
12
- | <img src="../previews/feedback-status-confirmdialog.webp" width="96" alt=""> | [ConfirmDialog](#confirmdialog) | Confirm / cancel dialog, `ConfirmationDialog`, `CustomAlertDialog` |
12
+ | <img src="../previews/feedback-status-confirmdialog.webp" width="96" alt=""> | [ConfirmDialog](#confirmdialog) | Confirm / cancel dialog, `ConfirmationDialog`, `CustomAlertDialog`; `ActionRefusal` (`isActionRefusal`) carries a refusal's details and action |
13
13
  | <img src="../previews/feedback-status-customdialog.webp" width="96" alt=""> | [CustomDialog](#customdialog) | Ready-made dialog with trigger, icon, title, body and footer |
14
14
  | <img src="../previews/feedback-status-stepupdialog.webp" width="96" alt=""> | [StepUpDialog](#stepupdialog) | "Confirm it's you": a dialog that asks for a one-time code, driven by props |
15
15
  | <img src="../previews/feedback-status-unsaveddialog.webp" width="96" alt=""> | [UnsavedDialog](#unsaveddialog) | "Leave without saving?": a destructive confirm with Stay focused, used by the unsaved-changes guards |
16
+ | <img src="../previews/feedback-status-stalenotice.webp" width="96" alt=""> | [StaleNotice](#stalenotice) | "This record changed": a warning that another write came first, with one Reload action and no overwrite |
16
17
  | <img src="../previews/feedback-status-emptystate.webp" width="96" alt=""> | [EmptyState](#emptystate) | Illustration, title, description and action, by kind (empty, search, not-found, unauthorized) |
17
18
  | <img src="../previews/feedback-status-pageloadfailed.webp" width="96" alt=""> | [PageLoadFailed](#pageloadfailed) | An empty state for a page opened at a cursor that failed, with a link back to the start |
18
19
  | <img src="../previews/feedback-status-errorstate.webp" width="96" alt=""> | [ErrorState](#errorstate) | Failure block with Retry and technical details |
@@ -239,14 +240,44 @@ function DeleteProject({ onDelete }: { onDelete: () => Promise<void> }) {
239
240
  | --- | --- | --- | --- |
240
241
  | `open` / `onOpenChange` | `boolean` / `(open: boolean) => void` | required | controlled only |
241
242
  | `onConfirm` | `() => void \| Promise<void>` | required | awaited with the dialog open and both buttons disabled; closes on success; on a throw or rejection the dialog stays open and shows the error's message, or the translated `material.confirm-dialog.failed` ("The action failed.") when it has none |
243
+ | `title` / `description` | `string` / `ReactNode` | required | a string `description` renders in a paragraph; any other node (several `<p>`, a list, a link) renders in a `div`, so a consent notice of several paragraphs is valid HTML; both name the dialog (`aria-describedby`) |
242
244
  | `variant` | `'default' \| 'destructive'` | `'default'` | `destructive` paints the confirm button `error` |
243
245
  | `loading` | `boolean` | `false` | disables both buttons and shows the translated `material.confirm-dialog.processing` label, as a pending `onConfirm` does |
244
246
  | `confirmDisabled` | `boolean` | `false` | disables the confirm button, for example while an error of yours stands |
245
247
  | `lockOnRefusal` | `boolean` | `false` | once `onConfirm` has refused (thrown), the confirm button stays disabled while its error shows, so a retry that cannot succeed is not invited; Cancel stays enabled. The error clears when the dialog closes |
248
+ | `details` | `ReactNode` | — | a note under the description, read with it by assistive technology, for what else is affected or cannot be undone |
249
+ | `refusal` | `(refusal: ConfirmRefusalContext) => ReactNode` | the message; an `ActionRefusal`'s details and action too | renders the refusal inside the error alert (still `role="alert"`, a `div`) instead of the default, so it can carry a link. Receives `{ message, cause, close }`: `message` is the thrown error's message or the translated `material.confirm-dialog.failed` text, `cause` is what `onConfirm` threw, `close()` closes the dialog |
246
250
  | `confirmLabel` / `cancelLabel` | `string` | "Confirm" / "Cancel", translated (`material.confirm-dialog.confirm` / `.cancel`) | button text |
247
251
  | `cancelVariant` | `ButtonVariant` | `'tonal'` | the Cancel button's variant; `'outlined'` restores the earlier look |
248
252
  | `onCloseAutoFocus` | `(event: Event) => void` | — | called as the dialog closes, before the focus goes back; `event.preventDefault()` places it yourself, e.g. `ctx.focusTrigger()` of a `ServerTable` bulk action whose button a redraw replaced |
249
253
 
254
+ ```tsx
255
+ <ConfirmDialog
256
+ open={open}
257
+ onOpenChange={setOpen}
258
+ title="Delete item"
259
+ description="This cannot be undone."
260
+ details="Its attachments are deleted with it."
261
+ onConfirm={remove}
262
+ refusal={({ message, close }) => (
263
+ <>
264
+ {message}. <a href="/usage" onClick={close}>See what still uses it</a>
265
+ </>
266
+ )}
267
+ />
268
+ ```
269
+
270
+ **`ActionRefusal`** is the refusal that carries an action. Throw `new ActionRefusal(message, { action, details? })` from `onConfirm`, with `action` `{ label, href }` (a link, a full page load) or `{ label, onAction }` (a handler, for client-side navigation; it wins when both are given) and `details` a note. The alert then shows the message, the details and the action, and the action closes the dialog; any other error shows its message only. `isActionRefusal(error)` is the guard (it also accepts a copy from another install of the package), and `RecordAction` is the action's type. `RecordActionsMenu` reuses all of this.
271
+
272
+ ```tsx
273
+ throw new ActionRefusal('Still used by 3 invoices', {
274
+ details: 'Remove them first.',
275
+ action: { label: 'View the invoices', href: '/invoices' },
276
+ });
277
+ ```
278
+
279
+ The `details` note is part of the dialog's accessible description (`aria-describedby`), with the `description`. While `onConfirm` runs, the buttons are disabled and the focus stays on the dialog, not on the page behind it.
280
+
250
281
  On a phone (below 640 px) the footer stacks. A `destructive` dialog puts Cancel first and the destructive action last; a default one keeps the action on top.
251
282
 
252
283
  `ConfirmDialog` opens with its Cancel focused, so Enter cancels. On closing
@@ -312,7 +343,7 @@ built on `AlertDialog`, so it does not close on an outside click. Put
312
343
  `AlertDialogCancel` / `AlertDialogAction` in its `footer`.
313
344
 
314
345
  **Storybook:** `Feedback & status/ConfirmDialog`, `Feedback & status/ConfirmationDialog`,
315
- `Feedback & status/CustomAlertDialog` · **Vue:** `ConfirmDialog`, `ConfirmationDialog` (`form-options` + `#form` slot for `renderForm`), `CustomAlertDialog`
346
+ `Feedback & status/CustomAlertDialog` · **Vue:** `ConfirmDialog` (no `details` / `refusal` yet), `ConfirmationDialog` (`form-options` + `#form` slot for `renderForm`), `CustomAlertDialog`
316
347
 
317
348
  ---
318
349
 
@@ -491,6 +522,31 @@ import { UnsavedDialog } from '@nxgt/material/components';
491
522
 
492
523
  ---
493
524
 
525
+ ## StaleNotice
526
+
527
+ ![StaleNotice](../previews/feedback-status-stalenotice.webp)
528
+
529
+ Another write came first (a `stale-version` refusal on a versioned record). Keep what was typed, show this warning `Alert`, and let the user read the record again. It takes the focus and scrolls itself into view (`block: "center"`) when it mounts, and it has no "overwrite" action. Built on `Alert` and `Button`; see [Server errors](../guides/forms.md#a-save-refused-because-another-write-came-first).
530
+
531
+ ```tsx
532
+ import { StaleNotice } from '@nxgt/material/components';
533
+
534
+ {stale && <StaleNotice onReload={revalidate} busy={state === 'loading'} />}
535
+ ```
536
+
537
+ | Prop | Type | Default | Effect |
538
+ | --- | --- | --- | --- |
539
+ | `onReload` | `() => void` | — | runs on "Reload the record" |
540
+ | `busy` | `boolean` | `false` | the button shows as loading |
541
+ | `message` | `string` | translated | replaces the description |
542
+ | `className` | `string` | — | on the wrapper that takes the focus |
543
+
544
+ The title, description and button come from `material.stale-notice.*`.
545
+
546
+ **Storybook:** `Feedback & status/StaleNotice` · **Vue:** not yet (`vue` issue #2159)
547
+
548
+ ---
549
+
494
550
  ## EmptyState
495
551
 
496
552
  ![EmptyState](../previews/feedback-status-emptystate.webp)
@@ -19,6 +19,7 @@ for the value type each one writes. Everything is imported from
19
19
  | <img src="../previews/form-fields-passwordfield.webp" width="96" alt=""> | [PasswordField](#passwordfield) | Show / hide toggle and a strength meter |
20
20
  | <img src="../previews/form-fields-numberfield.webp" width="96" alt=""> | [NumberField](#numberfield) | `number \| null` with clamping and +/− buttons |
21
21
  | <img src="../previews/form-fields-percentfield.webp" width="96" alt=""> | [PercentField](#percentfield) | `NumberField` with a `%` suffix |
22
+ | <img src="../previews/form-fields-quantityfield.webp" width="96" alt=""> | [QuantityField](#quantityfield) | One labelled field holding an amount and its unit |
22
23
  | <img src="../previews/form-fields-currencyfield.webp" width="96" alt=""> | [CurrencyField](#currencyfield) | Locale-formatted amount with a currency suffix |
23
24
  | <img src="../previews/form-fields-phonefield.webp" width="96" alt=""> | [PhoneField](#phonefield) | International number, emits E.164 |
24
25
  | <img src="../previews/form-fields-urlfield.webp" width="96" alt=""> | [UrlField](#urlfield) | Validates on blur, open and copy buttons |
@@ -610,6 +611,79 @@ ratio (`0.15`).
610
611
 
611
612
  ---
612
613
 
614
+ ## QuantityField
615
+
616
+ ![QuantityField](../previews/form-fields-quantityfield.webp)
617
+
618
+ One labelled field holding an amount and its unit ("2 capsule", "500 mg"): a
619
+ [`NumberField`](#numberfield)'s number beside a unit picker, under one label
620
+ and one error message. The value is `{ value: number | null, unit: string }`.
621
+ `QuantityFormField` wires it to react-hook-form.
622
+
623
+ ```tsx
624
+ import { useForm } from 'react-hook-form';
625
+ import { Button, QuantityFormField } from '@nxgt/material/components';
626
+ import type { QuantityValue } from '@nxgt/material/components';
627
+
628
+ type Values = { dose: QuantityValue };
629
+
630
+ function DoseForm() {
631
+ const { control, handleSubmit } = useForm<Values>({
632
+ defaultValues: { dose: { value: null, unit: '' } },
633
+ });
634
+ return (
635
+ <form onSubmit={handleSubmit((values) => console.log(values.dose))}>
636
+ <QuantityFormField
637
+ control={control}
638
+ name="dose"
639
+ label="Dose"
640
+ min={0}
641
+ step={0.5}
642
+ units={[
643
+ { value: 'mg', label: 'mg' },
644
+ { value: 'ml', label: 'mL' },
645
+ ]}
646
+ />
647
+ <Button type="submit">Save</Button>
648
+ </form>
649
+ );
650
+ }
651
+ ```
652
+
653
+ Without a form library, `QuantityField` takes `value` and `onChange` (or
654
+ `defaultValue`). The number is the one `NumberField` has: the language's
655
+ decimal separator, `min`, `max` and `step` (clamped on blur), and steppers
656
+ named after the field ("Increase Dose"). Both parts stay on one line on a
657
+ phone: the number shrinks, the unit keeps a fixed 7rem.
658
+
659
+ | Prop | Type | Default | What it does |
660
+ | --- | --- | --- | --- |
661
+ | `control`, `name` | `Control<T>`, `Path<T>` | — | the form and its `{ value, unit }` (`QuantityFormField`) |
662
+ | `value`, `defaultValue`, `onChange` | `QuantityValue`, `(value) => void` | empty | the amount and its unit; `onChange` gets both on each change |
663
+ | `label` | `ReactNode` | none | the one label; it names the group |
664
+ | `units` | `{ value, label }[]` | none | the units offered |
665
+ | `allowCustomUnit` | `boolean` | `false` | the unit is typed in a text input and `units` are suggestions; otherwise it is picked from `units` |
666
+ | `min`, `max`, `step`, `hideSteppers` | | `step` 1 | as `NumberField` |
667
+ | `unitLabel`, `unitPlaceholder` | `string` | "<label> unit" | the unit part's accessible name and its placeholder |
668
+ | `helperText`, `error` | `ReactNode`, `boolean` | none | the one message under the pair; in a form, an error on `name`, `name.value` or `name.unit` shows there |
669
+ | `className`, `aria-label` | `string` | none | `className` goes on the group; `aria-label` names it when `label` is not plain text |
670
+ | `name`, `onBlur` | | none | `QuantityField` only (the form field takes them from the form): `name` names the parts `<name>.value` and `<name>.unit`; `onBlur` fires when the number input loses focus |
671
+ | `required`, `disabled`, `placeholder`, `id`, `ref` | | | `placeholder`, `id` and `ref` reach the number input (React Hook Form's `field.ref` focuses it on an error) |
672
+
673
+ - **Semantics:** a `<fieldset>` (role `group`) named by the label; the number
674
+ is a textbox named after the label, the unit a combobox (or, with
675
+ `allowCustomUnit`, a text input) named "Dose unit". The message describes
676
+ the group, the number and the unit, and both are `aria-invalid` on an error.
677
+ - **Translations:** `material.quantity-field.unit` and `.unit-named` (English
678
+ and French) name the unit part.
679
+
680
+ **Emits:** `{ value: number | null, unit: string }`.
681
+ **Form wrapper:** `QuantityFormField` — `control` and `name`, see
682
+ [forms](../guides/forms.md#the-pair) and [what each field emits](../guides/forms.md#what-each-field-emits).
683
+ **Storybook:** `Form fields/QuantityField` · **Vue:** none yet (#2106)
684
+
685
+ ---
686
+
613
687
  ## CurrencyField
614
688
 
615
689
  ![CurrencyField](../previews/form-fields-currencyfield.webp)
@@ -665,9 +739,19 @@ export function Mobile() {
665
739
  | `value` / `defaultValue` | `string \| null` | `null` | an E.164 number (`+33612345678`) |
666
740
  | `onChange` | `(value: string \| null) => void` | — | `null` until the typed number is possible for the country |
667
741
  | `defaultCountry` | `string` (ISO 3166-1) | `'FR'` | the country used when the value has no `+` prefix |
668
-
669
- **Emits:** E.164 `string`, or `null` while the number is incomplete.
670
- **Form wrapper:** `PhoneFormField` — `control` and `name`, see
742
+ | `onValidityChange` | `(incomplete: boolean) => void` | — | `true` while the draft is not empty but not yet a possible number (`onChange` gives `null` for it, as for an empty field); `false` once it is complete or empty. Fires before the matching `onChange`; not called while the field has never been incomplete |
743
+
744
+ **Emits:** E.164 `string`, or `null` while the number is empty or incomplete.
745
+ Outside a form, tell the two apart with `onValidityChange` (or
746
+ `isIncompletePhone(draft, iso)`).
747
+ **Form wrapper:** `PhoneFormField` — `control` and `name`; an empty field is
748
+ `null`, and a half-typed number is a field error « Incomplete number »
749
+ (`material.phone-field.incomplete`, « Numéro incomplet » in French) that blocks
750
+ the submit, so a stored number is never cleared silently. `PhoneFormField` takes `rules`: your own `rules.validate` (a function or an
751
+ object of them) runs beside the incomplete check, as its `custom` key for a
752
+ function. A `rules.required` is checked first by React Hook Form, so a half-typed
753
+ number reads as the required message there. A form `reset()` to `null` does not clear a half-typed draft (the
754
+ value did not change), so an incomplete draft stays an error until it is edited. See
671
755
  [forms](../guides/forms.md#the-pair) and [what each field emits](../guides/forms.md#what-each-field-emits).
672
756
  **Storybook:** `Form fields/PhoneField` (and `/Field look`) · **Vue:** `PhoneField`, `PhoneFormField`
673
757
 
@@ -1870,6 +1954,16 @@ Parts: `PostalAddress` (the card), `PostalAddressDialog` (the edit form).
1870
1954
  | `onEdit` | `() => void` | — | shows the edit button |
1871
1955
  | `disabled` | `boolean` | — | hides it |
1872
1956
 
1957
+ **One line and country codes:** `formatPostalAddress(address, { language?, separator? })`
1958
+ returns the address as one line for a read view, `"12 rue du Lac, 74000 Annecy, France"`:
1959
+ street, city line, then the country named in `language` (default `'en'`, as
1960
+ `postalAddressCountryName`). The city line puts the postal code before the city
1961
+ (FR, BE, CH, DE and any other country) or after the city and region (GB, US, CA,
1962
+ IE, AU, NZ, IN: `"London SW1A 2AA"`). Empty parts are skipped; `separator`
1963
+ defaults to `', '`. `ISO_COUNTRY_CODES` (the 249 ISO 3166-1 alpha-2 codes) and
1964
+ `isCountryCode(code)` (a guard to `string`, upper case only) are exported from
1965
+ `@nxgt/material/components` too.
1966
+
1873
1967
  `PostalAddressFormField` and `PostalAddressDialog` take `cancelVariant` (the
1874
1968
  dialog's Cancel button, a `Button` `variant`, default `'tonal'`; `'outlined'`
1875
1969
  restores the earlier look).
@@ -65,9 +65,10 @@ function Attachments() {
65
65
  <NetworkUploadField
66
66
  mode="single"
67
67
  label="Contract"
68
- onUpload={async (file, onProgress) => {
68
+ onUpload={async (file, onProgress, signal) => {
69
+ const res = await fetch('/api/files', { method: 'POST', body: file, signal });
69
70
  onProgress?.(100);
70
- return `https://files.example.com/${file.name}`;
71
+ return (await res.json()).url;
71
72
  }}
72
73
  />;
73
74
  ```
@@ -75,8 +76,8 @@ function Attachments() {
75
76
  | Prop | Type | Default | Effect |
76
77
  | --- | --- | --- | --- |
77
78
  | `mode` | `'single' \| 'multiple'` | — (required) | `value` is a `string` or a `string[]` |
78
- | `onUpload` | `(file, onProgress?) => Promise<string>` | — | `NetworkUploadField` only; the resolved string becomes the value |
79
- | `getPresignedUrl` | `(file) => Promise<{ uploadUrl, publicUrl, headers? }>` | — | `S3UploadField` only; PUTs the file to `uploadUrl`, stores `publicUrl` |
79
+ | `onUpload` | `(file, onProgress?, signal?) => Promise<string>` | — | `NetworkUploadField` only; the resolved string becomes the value. Removing an uploading row or unmounting the field aborts `signal`: pass it to your request, or the upload still completes (its value is dropped). An abort after the request completed cannot unsend it: the value is dropped and `onRemove` is not called. The S3 fields abort their request themselves and reject with an `UploadError` of kind `aborted` (named `AbortError`, no longer a `DOMException`) |
80
+ | `getPresignedUrl` | `(file) => Promise<PresignedUploadTarget>` | — | `S3UploadField` only; `{ uploadUrl, publicUrl, headers? }` PUTs the file and stores `publicUrl`; `{ method: 'POST', url, fields, value }` posts a form and stores `value` (see [presigned POST](#presigned-post)) |
80
81
  | `accept` | `string` | — | file input `accept`, also shown under the drop zone |
81
82
  | `maxSize` | `number` (bytes) | — | larger files are rejected before upload |
82
83
  | `maxFiles` | `number` | — | `mode="multiple"` only; the drop zone is disabled once reached |
@@ -95,9 +96,51 @@ description instead, so the error is still said: with `error` and that
95
96
  group, so to set files in a test, reach the input through it:
96
97
  `getByRole('group', { name: 'Contract' }).locator('input[type=file]')`.
97
98
 
99
+ ### Presigned POST
100
+
101
+ A backend that signs a POST policy (so the bucket enforces content type and
102
+ size) returns `{ method: 'POST', url, fields, value }`. The field sends
103
+ `multipart/form-data` with `fields` in their order and the file appended last
104
+ under the name `file`, then stores `value` (the key). The same target works for `S3UploadFormField`,
105
+ `ImageS3Field`, `ImageS3FormField`, `FileListS3Field` and `FileListS3FormField`;
106
+ the PUT shape stays valid. Progress, retry, cancel and errors behave as for a PUT.
107
+
108
+ ```tsx
109
+ <S3UploadFormField
110
+ control={control}
111
+ name="file"
112
+ label="Contract"
113
+ getPresignedUrl={async (file) => {
114
+ const { url, fields, key } = await api.presignPost({ name: file.name, contentType: file.type });
115
+ return { method: 'POST', url, fields, value: key }; // fields exactly as signed
116
+ }}
117
+ />
118
+ ```
119
+
120
+ A refused policy (`403`, or `400 EntityTooLarge`) reaches `onError(file, error)`
121
+ and the field's error state; when S3 answers with its XML error, the message
122
+ carries the code. The S3 helpers reject with an `UploadError` (`kind`:
123
+ `status` | `network` | `aborted`, plus `status` and `code`), and the field words
124
+ it in the active language from `material.upload.error-status`,
125
+ `error-status-code`, `error-network` and `error-aborted` (`Échec de l'envoi
126
+ (statut 400, EntityTooLarge)` in French). `error.message` stays English, for
127
+ logs. An error thrown by your own `onUpload` or `getPresignedUrl` is not an
128
+ `UploadError`: its `message` is shown as is, so translate it yourself. A stored
129
+ `value` that is a key needs `resolveUrl` to preview it.
130
+
131
+ The bucket must allow the browser to POST from your origin:
132
+
133
+ ```json
134
+ [{ "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["POST"], "AllowedHeaders": ["*"] }]
135
+ ```
136
+
137
+ If the app sends a `Content-Security-Policy`, add the bucket host to
138
+ `connect-src` (uploads use `XMLHttpRequest`), for example
139
+ `connect-src 'self' https://my-bucket.s3.eu-west-1.amazonaws.com`.
140
+
98
141
  **Form wrapper:** `S3UploadFormField`, `NetworkUploadFormField` — `control` and `name`, see
99
142
  [forms](../guides/forms.md).
100
- **Storybook:** `Media & files/S3UploadField`, `Media & files/NetworkUploadField` · **Vue:** `S3UploadField`, `NetworkUploadField`, `S3UploadFormField`, `NetworkUploadFormField`
143
+ **Storybook:** `Media & files/S3UploadField`, `Media & files/NetworkUploadField` · **Vue:** `S3UploadField`, `NetworkUploadField`, `S3UploadFormField`, `NetworkUploadFormField` (PUT only, #2095)
101
144
 
102
145
  ---
103
146
 
@@ -199,7 +242,9 @@ function Attachments() {
199
242
  ```
200
243
 
201
244
  Parts: `FileList` (display only), `FileListField` (uploads through `onUpload`),
202
- `FileListS3Field` (uploads through `getPresignedUrl`, like `S3UploadField`).
245
+ `FileListS3Field` (uploads through `getPresignedUrl`, like `S3UploadField`;
246
+ with a POST target the value is `value`, a key, so pass `resolveUrl` to preview
247
+ saved images).
203
248
 
204
249
  | Prop | Type | Default | Effect |
205
250
  | --- | --- | --- | --- |
@@ -224,7 +269,7 @@ dropzone as well read the error twice, and the group still says it once
224
269
  `maxFiles` hides the dropzone. The dropzone is outlined through `data-invalid`.
225
270
 
226
271
  **Form wrapper:** `FileListFormField`, `FileListS3FormField` — see [forms](../guides/forms.md).
227
- **Storybook:** `Media & files/FileList`, `Media & files/FileListField` · **Vue:** `FileList`, `FileListField`, `FileListS3Field`, `FileListFormField`, `FileListS3FormField`
272
+ **Storybook:** `Media & files/FileList`, `Media & files/FileListField` · **Vue:** `FileList`, `FileListField`, `FileListS3Field`, `FileListFormField`, `FileListS3FormField` (S3 variants PUT only, #2095)
228
273
 
229
274
  ---
230
275
 
@@ -364,7 +409,8 @@ function Avatar() {
364
409
  ```
365
410
 
366
411
  Parts: `ImageField` (uploads through `onUpload`), `ImageS3Field` (through
367
- `getPresignedUrl`).
412
+ `getPresignedUrl`; with a POST target the value is `value`, a key, so pass
413
+ `resolveUrl` to preview saved images).
368
414
 
369
415
  | Prop | Type | Default | Effect |
370
416
  | --- | --- | --- | --- |
@@ -389,7 +435,7 @@ with no message of its own, by a visually hidden "This value is not valid."
389
435
  (`material.zod.default`, translated), and outlined through `data-invalid`.
390
436
 
391
437
  **Form wrapper:** `ImageFormField`, `ImageS3FormField` — see [forms](../guides/forms.md).
392
- **Storybook:** `Media & files/ImageField` · **Vue:** `ImageField`, `ImageS3Field`, `ImageFormField`, `ImageS3FormField`
438
+ **Storybook:** `Media & files/ImageField` · **Vue:** `ImageField`, `ImageS3Field`, `ImageFormField`, `ImageS3FormField` (S3 variants PUT only, #2095)
393
439
 
394
440
  ---
395
441
 
@@ -17,6 +17,7 @@ documentation-site shell. Everything is imported from
17
17
  | <img src="../previews/navigation-layout-listdetailslayout.webp" width="96" alt=""> | [ListDetailsLayout, PaneLayout, ThreadLayout](#listdetailslayout) | Page layouts that adapt to phones |
18
18
  | <img src="../previews/navigation-layout-responsivegrid.webp" width="96" alt=""> | [ResponsiveGrid](#responsivegrid) | Container-query grid, 1 to 5 columns, or `auto-fill` columns by a minimum tile width (`minItem`) |
19
19
  | <img src="../previews/navigation-layout-sectioncolumns.webp" width="96" alt=""> | [SectionColumns](#sectioncolumns) | Short page sections side by side from a viewport breakpoint, stacked below it |
20
+ | <img src="../previews/navigation-layout-recordpage.webp" width="96" alt=""> | [RecordPage, RecordDetails](#recordpage) | The page of one record: header card, content, facts as a side panel from `xl` |
20
21
  | <img src="../previews/navigation-layout-resizable.webp" width="96" alt=""> | [ResizableGroup](#resizablegroup) | Resizable panels |
21
22
  | <img src="../previews/navigation-layout-skiplink.webp" width="96" alt=""> | [SkipLink](#skiplink) | Skip-to-content link |
22
23
  | <img src="../previews/navigation-layout-pager.webp" width="96" alt=""> | [Pager](#pager) | Previous / next cards |
@@ -574,6 +575,63 @@ props, so `className` adds to it.
574
575
 
575
576
  ---
576
577
 
578
+ ## RecordPage
579
+
580
+ ![RecordPage](../previews/navigation-layout-recordpage.webp)
581
+
582
+ The page of one record (an item, a place, a person): a header card, the content, and the record's facts as a side panel from `xl` that stacks after the content, or before it, below. `RecordDetails` is the facts card that goes in the panel.
583
+
584
+ ```tsx
585
+ import { Badge, Button, Card, CardContent, Description, RecordDetails, RecordPage } from '@nxgt/material/components';
586
+
587
+ <RecordPage
588
+ title="Torque wrench 3/8"
589
+ actions={<Button>Edit</Button>}
590
+ details={
591
+ <RecordDetails
592
+ title="Details"
593
+ status={<Badge>Active</Badge>}
594
+ created={{ label: 'Added on', at: item.createdAt }}
595
+ updated={{ label: 'Updated on', at: item.updatedAt }}
596
+ timeZone="Europe/Paris"
597
+ >
598
+ <Description label="Code">TW-3804</Description>
599
+ <Description label="Category">Hand tools</Description>
600
+ </RecordDetails>
601
+ }
602
+ detailsLabel="Item details"
603
+ detailsStack="after"
604
+ >
605
+ <Card><CardContent>…</CardContent></Card>
606
+ </RecordPage>
607
+ ```
608
+
609
+ `RecordPage` is [`ActivityContent`](#activitylayout) with the side panel's props renamed, so `title`, `subtitle`, `actions`, `header`, `notice`, `width`, `inset` and `titleAs` work as there. It adds no data, state or request.
610
+
611
+ | Prop | Type | Default | Effect |
612
+ | --- | --- | --- | --- |
613
+ | `details` | `ReactNode` | — | The facts, usually a `RecordDetails`. No panel without it |
614
+ | `detailsLabel` | `string` | — | The panel's accessible name: it is a `complementary` landmark. Or `detailsLabelledBy`, the id of a heading inside it |
615
+ | `detailsStack` | `'before' \| 'after'` | `'after'` | Where the panel goes below `asideFrom` |
616
+ | `detailsClassName` | `string` | — | Classes for the panel |
617
+ | `asideFrom`, `asideWidth`, `asideSticky` | as on `ActivityContent` | `xl`, `md`, `true` | The breakpoint, the panel's width and the sticky offset |
618
+
619
+ `RecordDetails` props:
620
+
621
+ | Prop | Type | Default | Effect |
622
+ | --- | --- | --- | --- |
623
+ | `title`, `titleAs` | `ReactNode`, heading | —, `'h2'` | The card's heading (under the page's `h1`) |
624
+ | `status` | `ReactNode` | — | Beside the title: a `Badge` or `StatusIndicator` |
625
+ | `created`, `updated` | `{ label: string; at: Date \| string \| number }` | — | The two segments of the muted meta line; each « label date » stays on one line |
626
+ | `timeZone` | IANA zone | `'UTC'` | The zone the dates are written in, the same on a server and in a browser. Dates have no seconds and no zone suffix |
627
+ | `maxColumns` | `1`-`6` | `4` | The most columns of the facts: the panel's own width gives one, a stacked and wide panel up to this |
628
+
629
+ The facts are a [`DescriptionList`](data-display.md#descriptionlist), so the columns follow the card's width, not the window's.
630
+
631
+ **Storybook:** `Navigation & layout/RecordPage` · **Vue:** later ([#2127](https://github.com/softistx/nxgt-material/issues/2127))
632
+
633
+ ---
634
+
577
635
  ## ResizableGroup
578
636
 
579
637
  ![ResizableGroup](../previews/navigation-layout-resizable.webp)