@nxgt/material 3.70.0 → 3.72.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 (151) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +9 -6
  3. package/dist/{alert-dialog-CBkWG32g.js → alert-dialog-A8K8zai1.js} +1 -1
  4. package/dist/{async-slice-CNZ3N55P.js → async-slice-B8933IHY.js} +1 -1
  5. package/dist/{button-D3qzqDY_.js → button-C8moFnIT.js} +1 -1
  6. package/dist/charts.js +3 -3
  7. package/dist/{chip-CMTe4sQd.js → chip-B6AY1E91.js} +2 -2
  8. package/dist/{code-editor-Cs7mz1J-.js → code-editor-DQZIKL54.js} +5 -5
  9. package/dist/code-editor.js +5 -5
  10. package/dist/{code-language-items-D_QMgjVy.js → code-language-items-8VL0aMKG.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/ui/fields/attributes/attribute-values-form/attribute-values-form.css +5 -0
  14. package/dist/{components-BbWcozcj.js → components-EHdmrvV4.js} +20862 -19924
  15. package/dist/components.js +28 -28
  16. package/dist/{dialog-B3BSdRci.js → dialog-WuLTwxkr.js} +5 -5
  17. package/dist/dnd.js +1 -1
  18. package/dist/{dropdown-menu-BaupOWgS.js → dropdown-menu-tadySeGH.js} +1 -1
  19. package/dist/editor.js +35 -35
  20. package/dist/{empty-state-B2Um5UpI.js → empty-state-ZYiFSSBQ.js} +2 -2
  21. package/dist/{field-blur-1XLyxRPr.js → field-blur-BYR0yE5L.js} +1 -1
  22. package/dist/{field-blur-context-XNvCDnMT.js → field-blur-context-bEhByoK-.js} +1 -1
  23. package/dist/{hooks-BU21Jpaa.js → hooks-GpwD8iDu.js} +2 -2
  24. package/dist/hooks.js +3 -3
  25. package/dist/{hover-card-Bvm3svX9.js → hover-card-BowaI_ra.js} +1 -1
  26. package/dist/i18n.js +3 -3
  27. package/dist/{icon-badge-wip9bfa2.js → icon-badge-B1B0Lg02.js} +3 -3
  28. package/dist/{icon-button-QwIeBTdw.js → icon-button-BbGU17cb.js} +1 -1
  29. package/dist/lib/components/ui/actions/fab/fab-dock-inset.d.ts +29 -0
  30. package/dist/lib/components/ui/actions/fab/fab-dock.d.ts +6 -1
  31. package/dist/lib/components/ui/collections/data-table/select-checkbox.d.ts +3 -2
  32. package/dist/lib/components/ui/collections/filter/use-filter-field-config.d.ts +3 -0
  33. package/dist/lib/components/ui/collections/index.d.ts +1 -0
  34. package/dist/lib/components/ui/collections/record-actions-menu/index.d.ts +2 -0
  35. package/dist/lib/components/ui/collections/record-actions-menu/record-actions-dialogs.d.ts +15 -0
  36. package/dist/lib/components/ui/collections/record-actions-menu/record-actions-menu.d.ts +72 -0
  37. package/dist/lib/components/ui/collections/record-actions-menu/record-actions-rules.d.ts +68 -0
  38. package/dist/lib/components/ui/collections/record-actions-menu/record-actions-words.d.ts +52 -0
  39. package/dist/lib/components/ui/collections/record-actions-menu/use-record-actions.d.ts +51 -0
  40. package/dist/lib/components/ui/collections/record-actions-menu/use-record-flight.d.ts +16 -0
  41. package/dist/lib/components/ui/collections/row-menu/row-menu-column.d.ts +4 -0
  42. package/dist/lib/components/ui/collections/row-menu/row-menu.d.ts +1 -1
  43. package/dist/lib/components/ui/collections/server-table/partials/server-table-band-classes.d.ts +7 -5
  44. package/dist/lib/components/ui/collections/server-table/partials/server-table-column-sorting.d.ts +7 -0
  45. package/dist/lib/components/ui/collections/server-table/types.d.ts +3 -1
  46. package/dist/lib/components/ui/display/markdown/markdown.d.ts +12 -1
  47. package/dist/lib/components/ui/display/markdown/markdown.utils.d.ts +2 -0
  48. package/dist/lib/components/ui/display/markdown/mermaid-csp-scope.d.ts +8 -0
  49. package/dist/lib/components/ui/display/markdown/mermaid-markup.d.ts +20 -0
  50. package/dist/lib/components/ui/display/markdown/partials/markdown-code.d.ts +5 -1
  51. package/dist/lib/components/ui/feedback/confirm-dialog/action-refusal.d.ts +24 -0
  52. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-alert.d.ts +17 -0
  53. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-description.d.ts +10 -0
  54. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-dialog.d.ts +24 -2
  55. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-refusal.d.ts +7 -0
  56. package/dist/lib/components/ui/feedback/confirm-dialog/index.d.ts +1 -0
  57. package/dist/lib/components/ui/feedback/confirm-dialog/use-confirm-action.d.ts +6 -0
  58. package/dist/lib/components/ui/feedback/index.d.ts +1 -0
  59. package/dist/lib/components/ui/feedback/stale-notice/index.d.ts +1 -0
  60. package/dist/lib/components/ui/feedback/stale-notice/stale-notice.d.ts +15 -0
  61. package/dist/lib/components/ui/fields/attributes/attribute-values-form-field-set/attribute-values-form-field-set.d.ts +5 -1
  62. package/dist/lib/components/ui/fields/contacts/contact-dialog.d.ts +8 -2
  63. package/dist/lib/components/ui/fields/contacts/contacts-dialog-form-field.d.ts +4 -1
  64. package/dist/lib/components/ui/fields/contacts/contacts-editor.d.ts +15 -2
  65. package/dist/lib/components/ui/fields/contacts/contacts-form-field.d.ts +6 -1
  66. package/dist/lib/components/ui/fields/contacts/logic/offered-types.d.ts +3 -0
  67. package/dist/lib/components/ui/fields/contacts/models.d.ts +2 -0
  68. package/dist/lib/components/ui/fields/contacts/partials/contact-fields.d.ts +4 -2
  69. package/dist/lib/components/ui/fields/contacts/partials/contact-groups.d.ts +4 -2
  70. package/dist/lib/components/ui/fields/contacts/partials/contact-row.d.ts +4 -2
  71. package/dist/lib/components/ui/fields/contacts/partials/phone-sheet.d.ts +4 -2
  72. package/dist/lib/components/ui/fields/contacts/partials/quick-add.d.ts +4 -2
  73. package/dist/lib/components/ui/fields/contacts/partials/row-actions.d.ts +3 -1
  74. package/dist/lib/components/ui/fields/contacts/placeholders.d.ts +1 -1
  75. package/dist/lib/components/ui/forms/index.d.ts +1 -0
  76. package/dist/lib/components/ui/forms/use-server-field-errors.d.ts +21 -0
  77. package/dist/lib/components/ui/layout/index.d.ts +1 -0
  78. package/dist/lib/components/ui/layout/record-page/index.d.ts +2 -0
  79. package/dist/lib/components/ui/layout/record-page/record-details.d.ts +40 -0
  80. package/dist/lib/components/ui/layout/record-page/record-page.d.ts +27 -0
  81. package/dist/lib/components/ui/media/upload/network-upload-field/network-upload-field.d.ts +5 -0
  82. package/dist/lib/components/ui/media/upload/network-upload-field/partials/network-upload-field-items.d.ts +2 -1
  83. package/dist/lib/components/ui/media/upload/network-upload-field/upload-constraints.d.ts +10 -2
  84. package/dist/lib/components/ui/overlays/dialog/dialog-body-overflow.d.ts +7 -0
  85. package/dist/lib/components/ui/overlays/dialog/dialog-body.d.ts +10 -0
  86. package/dist/lib/components/ui/overlays/dialog/dialog-variants.d.ts +4 -0
  87. package/dist/lib/components/ui/overlays/dialog/dialog.d.ts +1 -0
  88. package/dist/lib/components/ui/widgets/chat/chat-conversation-item.d.ts +8 -2
  89. package/dist/lib/components/ui/widgets/chat/chat-markdown.d.ts +4 -0
  90. package/dist/lib/components/ui/widgets/chat/chat-message-list.d.ts +8 -1
  91. package/dist/lib/components/ui/widgets/chat/index.d.ts +2 -0
  92. package/dist/lib/components/ui/widgets/chat/partials/chat-conversation-item-text.d.ts +2 -1
  93. package/dist/lib/components/ui/widgets/chat/partials/chat-empty-thread.d.ts +2 -0
  94. package/dist/lib/components/ui/widgets/chat/proposal/chat-action-proposal-parts.d.ts +24 -0
  95. package/dist/lib/components/ui/widgets/chat/proposal/chat-action-proposal.d.ts +39 -0
  96. package/dist/lib/components/ui/widgets/chat/proposal/chat-action-proposal.rules.d.ts +17 -0
  97. package/dist/lib/components/ui/widgets/chat/proposal/chat-tool-activity.d.ts +16 -0
  98. package/dist/lib/i18n/resources/en/en.d.ts +47 -0
  99. package/dist/lib/i18n/resources/en/material.json.d.ts +47 -0
  100. package/dist/lib/i18n/resources/fr/fr.d.ts +47 -0
  101. package/dist/lib/i18n/resources/fr/material.json.d.ts +47 -0
  102. package/dist/lib/i18n/resources/resources.d.ts +94 -0
  103. package/dist/lib/i18n/translator.d.ts +1 -1
  104. package/dist/lib/print/output/to-pdf-element.d.ts +6 -0
  105. package/dist/lib.js +1 -1
  106. package/dist/main.js +42 -42
  107. package/dist/map.js +8 -8
  108. package/dist/material.css +1 -1
  109. package/dist/pdf-reader.js +1 -1
  110. package/dist/print.js +135 -131
  111. package/dist/{public-BuGgOtAl.js → public-CFj2qn7l.js} +9 -9
  112. package/dist/{rich-code-block-DdHqzzek.js → rich-code-block-B7Teg7-e.js} +5 -5
  113. package/dist/{sortable-provider-DMyaSfZZ.js → sortable-provider-y5sFhRDj.js} +2 -2
  114. package/dist/{spinner-ByYdcukU.js → spinner-C5Kb6MWu.js} +1 -1
  115. package/dist/theme-picker.js +2 -2
  116. package/dist/{theme-toggle-DB3aZZnO.js → theme-toggle-B1JZg7mU.js} +1 -1
  117. package/dist/{toolbar-items-DbzIKjo7.js → toolbar-items-m_1FTFPQ.js} +3 -3
  118. package/dist/{translate-material-Dh7MYkHT.js → translate-material-D1ndrd7a.js} +1 -1
  119. package/dist/{translator-Byhohi1S.js → translator-CtEC_AQa.js} +94 -0
  120. package/dist/{typography-rUf0mE5a.js → typography-prQHb3gI.js} +1 -1
  121. package/dist/{use-chat-composer-uUUTRB3Z.js → use-chat-composer-DBiyNXvL.js} +107 -107
  122. package/dist/{use-heading-spy-CVzW2NM3.js → use-heading-spy-Bzj5mIUZ.js} +179 -87
  123. package/dist/{use-material-translation-BAYyZOcH.js → use-material-translation-C6H0B4kE.js} +1 -1
  124. package/dist/{use-return-focus-CQAuk9kh.js → use-return-focus-CkUX1HFl.js} +1 -1
  125. package/docs/components/buttons-and-actions.md +24 -0
  126. package/docs/components/data-display.md +33 -0
  127. package/docs/components/data-table-filter-and-lists.md +117 -5
  128. package/docs/components/feedback-and-status.md +58 -2
  129. package/docs/components/form-fields.md +63 -3
  130. package/docs/components/media-and-files.md +4 -1
  131. package/docs/components/navigation-and-layout.md +58 -0
  132. package/docs/components/overlays-and-menus.md +20 -1
  133. package/docs/components/rich-widgets.md +35 -2
  134. package/docs/guides/cards-and-tiles.md +4 -2
  135. package/docs/guides/csp.md +44 -6
  136. package/docs/guides/forms.md +59 -7
  137. package/docs/guides/i18n.md +7 -6
  138. package/docs/guides/printing.md +17 -7
  139. package/docs/previews/data-table-filter-lists-recordactionsmenu.webp +0 -0
  140. package/docs/previews/feedback-status-stalenotice.webp +0 -0
  141. package/docs/previews/media-files-networkuploadfield.webp +0 -0
  142. package/docs/previews/media-files-s3uploadfield.webp +0 -0
  143. package/docs/previews/media-files-uploadfield.webp +0 -0
  144. package/docs/previews/navigation-layout-recordpage.webp +0 -0
  145. package/docs/previews/rich-widgets-chat-action-proposal.webp +0 -0
  146. package/docs/roadmap.md +11 -1
  147. package/docs/troubleshooting.md +126 -8
  148. package/lib/components/ui/collections/server-table/server-table-phone-row.css +6 -0
  149. package/lib/components/ui/display/code/code-moonlight.css +37 -0
  150. package/lib/components/ui/fields/attributes/attribute-values-form/attribute-values-form.css +5 -0
  151. package/package.json +3 -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)
@@ -1700,6 +1700,37 @@ import { AttributesFormField } from '@nxgt/material/components';
1700
1700
  <AttributesFormField control={control} name="attributes" label="Attributes" />
1701
1701
  ```
1702
1702
 
1703
+ **`AttributeValuesFormFieldSet`** renders one input per attribute and writes
1704
+ `{ [attribute.name]: value }` to the form at `name`:
1705
+
1706
+ ```tsx
1707
+ import { AttributeValuesFormFieldSet, FormSection } from '@nxgt/material/components';
1708
+
1709
+ const form = useForm({ defaultValues: { attributes: { color: '#2563eb', size: 'M' } } });
1710
+
1711
+ <FormSection title="Attributes">
1712
+ <AttributeValuesFormFieldSet
1713
+ control={form.control}
1714
+ name="attributes"
1715
+ label="Attributes"
1716
+ hideLabel
1717
+ attributes={[
1718
+ { name: 'color', label: 'Colour', type: 'COLOR' },
1719
+ { name: 'size', label: 'Size', type: 'SELECT', options: ['S', 'M', 'L'] },
1720
+ ]}
1721
+ />
1722
+ </FormSection>
1723
+ ```
1724
+
1725
+ | Prop | |
1726
+ | --- | --- |
1727
+ | `attributes` | the attributes to fill, one input each |
1728
+ | `control`, `name` | the form and the path of the object it writes |
1729
+ | `label` | the fieldset's accessible name (required) |
1730
+ | `hideLabel` | default `false`. `true` keeps `label` as the name but draws nothing, for a `FormSection` that already has the title |
1731
+ | `defaultValues` | optional fallback, used only while the form value at `name` is empty: `undefined`, `null` or an object with no keys (`{}`). Any other value in the form wins |
1732
+ | `className` | on the `<fieldset>` |
1733
+
1703
1734
  **The inline editor** is one row per attribute: a grip, the type's icon, the
1704
1735
  label with `name · Type` under it, the unit as a badge and the default value
1705
1736
  (a Yes or No, a colour swatch, a number with its unit). A row opens to its
@@ -1811,7 +1842,10 @@ const schema = z.object({ attributes: attributesListSchema() });
1811
1842
 
1812
1843
  **Emits:** `Attribute[]`; the inline editor writes a date as `yyyy-MM-dd`, a
1813
1844
  date-time as an ISO instant and a time as `HH:mm`. `AttributeValuesFormFieldSet`
1814
- writes an object keyed by attribute `name`; `AttributeValuesForm` reads a bare
1845
+ writes an object keyed by attribute `name`, reads the form's value at `name`
1846
+ (`defaultValues` is only the fallback while it holds nothing), fills the row
1847
+ width, and takes `hideLabel` to keep `label` as the fieldset's accessible name
1848
+ without drawing the heading; `AttributeValuesForm` reads a bare
1815
1849
  `yyyy-MM-dd` default as that local day.
1816
1850
  **Form wrapper:** `AttributesFormField` — `control` and `name`, see
1817
1851
  [forms](../guides/forms.md#the-pair), [what each field emits](../guides/forms.md#what-each-field-emits)
@@ -1863,6 +1897,32 @@ The editor checks the shape of a value, not that the line exists. Below 640px
1863
1897
  each contact is a summary row; tapping it opens a bottom sheet with the same
1864
1898
  fields and actions, and closing it with an empty value drops the contact.
1865
1899
 
1900
+ **Limit the types, hide the star.** A backend that stores only some types, or
1901
+ no primary flag, says so on `ContactsFormField` and `ContactsEditor` (`primary` is inline only):
1902
+
1903
+ ```tsx
1904
+ <ContactsFormField
1905
+ control={control}
1906
+ name="contacts"
1907
+ label="Contacts"
1908
+ types={['EMAIL', 'PHONE', 'FAX', 'WEBSITE']}
1909
+ primary={false}
1910
+ />
1911
+ ```
1912
+
1913
+ `types` (an array of `ContactTypeName`, i.e. `'EMAIL' | 'PHONE' | …` or the
1914
+ `ContactType` values; every type by default) limits the "Add" chips and the
1915
+ type select; list at least one type, since an empty `types={[]}` is not
1916
+ supported. The chips keep the library's order (phone, mobile, email,
1917
+ website, fax); the select follows the order given. With `editing="dialog"`,
1918
+ `types` limits the dialog's type select (`ContactDialog` takes it too, and a
1919
+ new contact starts with the first type); `primary` has no effect there, since
1920
+ the dialog has no star. A contact
1921
+ already in the value with another type is kept and shown, and its own type
1922
+ stays in its select, so nothing is changed silently. `primary={false}` (`true`
1923
+ by default) hides the star and the primary marks; a `primary` flag already in
1924
+ the value is left untouched.
1925
+
1866
1926
  `variant="cards"` is the Expressive variant: each contact is its own tinted
1867
1927
  card with a round tile for the primary one. The fields keep the library's
1868
1928
  field radius in both. `variant` is on `ContactsFormField`, `ContactsEditor`
@@ -1895,9 +1955,9 @@ only, and a value that fails its check is shown without a link.
1895
1955
  | `empty` | `ReactNode` | "No contacts" | shown when there is nothing to list; `null` renders nothing |
1896
1956
 
1897
1957
  `ContactsEditor` takes `value`, `onValueChange`, `label`, `headingLevel` (`3`
1898
- by default; the type headings sit one below it), `variant` and `disabled`. A
1958
+ by default; the type headings sit one below it), `variant`, `types`, `primary` and `disabled`. A
1899
1959
  wrong format is said once the field was left, a duplicate at once. `ContactsFormField` takes `editing` (`'inline' \| 'dialog'`,
1900
- default `'inline'`), `variant` (inline only) and `cancelVariant` (dialog only,
1960
+ default `'inline'`), `variant` (inline only), `types`, `primary` (inline only) and `cancelVariant` (dialog only,
1901
1961
  the Cancel button's `Button` variant, default `'tonal'`; `'outlined'` restores
1902
1962
  the earlier look). `Contacts` takes `data`, `onAdd`, `onEdit` and `disabled`.
1903
1963
 
@@ -78,12 +78,15 @@ function Attachments() {
78
78
  | `mode` | `'single' \| 'multiple'` | — (required) | `value` is a `string` or a `string[]` |
79
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
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)) |
81
- | `accept` | `string` | — | file input `accept`, also shown under the drop zone |
81
+ | `accept` | `string` | — | file input `accept`; the drop zone's hint shows it as short names (`image/jpeg,image/png` reads `JPEG, PNG`, `.pdf` reads `PDF`, `image/*` reads `Images`; unknown entries stay as given) |
82
+ | `acceptLabel` | `string` | — | replaces those names in the hint (`"Photos only"`); `""` hides the type part. |
82
83
  | `maxSize` | `number` (bytes) | — | larger files are rejected before upload |
83
84
  | `maxFiles` | `number` | — | `mode="multiple"` only; the drop zone is disabled once reached |
84
85
  | `onRemove` | `(value) => void \| Promise<void>` | — | called when a committed file is removed (delete it server-side here) |
85
86
  | `resolveUrl` | `(value) => string \| null \| undefined` | — | turns a stored key into an `<img src>` so saved images preview |
86
87
 
88
+ The drop zone's hint wraps in a narrow dialog instead of being clipped.
89
+
87
90
  The field is a `group` named by its `label`. The drop zone is a `button`, which
88
91
  may not carry `aria-invalid`: with `error` it is described by `helperText`, or
89
92
  without one by a visually hidden "This value is not valid."
@@ -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)
@@ -61,10 +61,27 @@ Your own `onEscapeKeyDown` on the content receives the Escape that only closes a
61
61
  >
62
62
  ```
63
63
 
64
+ **A long dialog scrolls its body, not the whole box.** Wrap the fields in `DialogBody` (a direct child of `DialogContent`, between `DialogHeader` and `DialogFooter`): the content becomes a clipped column, the header and the footer stay in view, and only the body scrolls. A hairline shows on the edge where content is hidden beneath the header or the footer. It works the same as a bottom sheet or full screen on a phone, with the safe-area padding kept under the footer. A dialog without `DialogBody` is unchanged and scrolls as a whole. Give the body an `aria-label` (or `aria-labelledby`) to make it a named region; `className` sets its inner layout (`grid gap-3`). It sets `data-overflow-start` and `data-overflow-end` while content is hidden above or below. `DialogScrollContent`, `Sheet`, `Drawer` and `AlertDialog` do not take it yet.
65
+
66
+ ```tsx
67
+ <DialogContent>
68
+ <DialogHeader>
69
+ <DialogTitle>New item</DialogTitle>
70
+ </DialogHeader>
71
+ <DialogBody aria-label="Item fields" className="grid gap-3">
72
+ {/* twelve fields */}
73
+ </DialogBody>
74
+ <DialogFooter>
75
+ <Button>Save</Button>
76
+ </DialogFooter>
77
+ </DialogContent>
78
+ ```
79
+
64
80
  ```tsx
65
81
  import {
66
82
  Button,
67
83
  Dialog,
84
+ DialogBody,
68
85
  DialogClose,
69
86
  DialogContent,
70
87
  DialogDescription,
@@ -94,7 +111,8 @@ import {
94
111
  ```
95
112
 
96
113
  Parts: `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`,
97
- `DialogTitle`, `DialogDescription`, `DialogFooter`, `DialogClose`, plus
114
+ `DialogTitle`, `DialogDescription`, `DialogFooter`, `DialogBody` (the part
115
+ that scrolls alone), `DialogClose`, plus
98
116
  `DialogOverlay` and `DialogPortal`, and `DialogScrollContent`: a content for
99
117
  a dialog taller than the screen, which sits in a scrolling overlay with a
100
118
  margin instead of being clipped, and renders its own close button. It takes
@@ -132,6 +150,7 @@ export function Terms() {
132
150
  | `variant`, `color` (`DialogClose`) | as `IconButton` | `'ghost'` | a top-right X button, named "Close" (`material.buttons.close`, translated) while it shows only the icon; text children or an `aria-label` name it instead, and icon-only children leave it unnamed; children replace the X |
133
151
  | `size` (`DialogContent`) | `'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'` | `'md'` | its width from 640 px up: 384, 512, 672, 896 px, or the screen less 2rem a side |
134
152
  | `mobilePresentation` (`DialogContent`) | `'dialog' \| 'bottom-sheet' \| 'top' \| 'fullscreen'` | `'dialog'` | below 640 px: a centred dialog, a sheet that springs up from the bottom edge (rounded top corners), a dialog pinned under the top edge, or the whole screen |
153
+ | `className`, `aria-label` / `aria-labelledby`, `onScroll`, `ref` (`DialogBody`) | `ComponentProps<'div'>` | — | the scroll region: `className` lays out its content (`grid gap-3`); an `aria-label` or `aria-labelledby` makes it a `region` with that name; `onScroll` and `ref` are called after its own overflow flags update. It sets `data-overflow-start` / `data-overflow-end` while content is hidden above / below, and bleeds by `--dialog-px` (1.5rem) so a `DialogContent` with its own padding sets both: `p-4 [--dialog-px:1rem]` |
135
154
  | `scrim` (`DialogContent`, `SheetContent`, `DrawerContent`) | `'dim' \| 'blur'` | `'dim'` | the scrim behind; `blur` also blurs the page a little |
136
155
 
137
156
  The look lives in the stylesheet (`nxgt-dialog`, `nxgt-scrim`), under the
@@ -83,8 +83,8 @@ function Conversation({ messages }: { messages: Message[] }) {
83
83
  Parts: `ChatInbox` (`list`, `thread`, `listHeader`, `pane`), `ChatConversationItem`,
84
84
  `ChatThread`, `ChatThreadHeader`, `ChatMessageList`, `ChatMessage`,
85
85
  `ChatMessageSeparator`, `ChatReplyQuote`, `ChatReactionGroup`, `ChatComposer`,
86
- `ChatComposerAttachments`, `ChatComposerAttachmentChip`, `ChatMarkdown`,
87
- `ChatTypingIndicator`, `ChatWidget`, `chatGroupPosition`, `ChatLayoutContext`,
86
+ `ChatComposerAttachments`, `ChatComposerAttachmentChip`, `ChatMarkdown`, `Markdown` documents `math`, `mermaid` and the untrusted-input guarantees (data-display.md#markdown).
87
+ `ChatTypingIndicator`, `ChatActionProposal`, `ChatToolActivity`, `ChatWidget`, `chatGroupPosition`, `ChatLayoutContext`,
88
88
  `useChatComposerAttachments`. Built on [TypingIndicator](feedback-and-status.md#typingindicator),
89
89
  [DaySeparator](data-display.md#dayseparator) and
90
90
  [ScrollToLatestButton](buttons-and-actions.md#scrolltolatestbutton).
@@ -94,6 +94,30 @@ Parts: `ChatInbox` (`list`, `thread`, `listHeader`, `pane`), `ChatConversationIt
94
94
  [<img src="../previews/rich-widgets-chat-composer.webp" height="96" alt="ChatComposer">](#chat-kit)
95
95
  [<img src="../previews/rich-widgets-chat-widget.webp" height="96" alt="ChatWidget">](#chat-kit)
96
96
  [<img src="../previews/rich-widgets-chat-advanced.webp" height="96" alt="ChatComposer with mentions">](#chat-kit)
97
+ [<img src="../previews/rich-widgets-chat-action-proposal.webp" height="96" alt="ChatActionProposal">](#chat-kit)
98
+
99
+ An assistant's proposed action and its tool steps, inside a list:
100
+
101
+ ```tsx
102
+ import {
103
+ ChatActionProposal,
104
+ ChatMessageList,
105
+ ChatToolActivity,
106
+ } from '@nxgt/material/components';
107
+
108
+ <ChatMessageList>
109
+ <ChatToolActivity name="find_absences" state="ok" />
110
+ <ChatActionProposal
111
+ title="Mark 12 absences as justified"
112
+ summary="Class 5B, 3 to 7 November."
113
+ deadline="2026-11-02T16:00:00Z"
114
+ status={status} // 'pending' until answered
115
+ busy={sending}
116
+ onConfirm={confirm}
117
+ onReject={reject}
118
+ />
119
+ </ChatMessageList>;
120
+ ```
97
121
 
98
122
  | Prop | Type | Default | Effect |
99
123
  | --- | --- | --- | --- |
@@ -105,8 +129,16 @@ Parts: `ChatInbox` (`list`, `thread`, `listHeader`, `pane`), `ChatConversationIt
105
129
  | `ChatMessage` `onReply` / `onReact` / `onDelete` / `menuItems` | callbacks / `ReactNode` | — | the toolbar shown on hover or focus (a tap on touch): react, reply, copy, and a "more" menu |
106
130
  | `ChatMessage` `reactions` / `onReactionToggle` | `ChatReaction[]` | — | the reaction pills under the bubble |
107
131
  | `ChatMessage` `isStreaming` / `copyable` | `boolean` | `false` / `true` | streaming caret; Copy in the toolbar |
132
+ | `ChatActionProposal` `title` / `summary` / `toolLabel` / `children` | `ReactNode` | — | a card for an action the assistant proposes: what, its effect, the tool behind it (a slot) and extra detail |
133
+ | `ChatActionProposal` `status` | `'pending' \| 'running' \| 'confirmed' \| 'failed' \| 'rejected' \| 'expired'` | `'pending'` | a toned badge with its icon and words (colour is never the only signal); only `pending` shows the buttons; leaving `pending` moves the focus to the status |
134
+ | `ChatActionProposal` `onConfirm` / `onReject` | `() => void` | — | the Confirm (filled) and Reject (tonal) buttons; `confirmLabel` / `rejectLabel` reword them; `actions` replaces both |
135
+ | `ChatActionProposal` `busy` / `disabled` | `boolean` | `false` | an answer is being sent (both disabled, Confirm spins) / a read-only viewer |
136
+ | `ChatActionProposal` `deadline` | `Date \| string \| number` | — | "Respond by …" in a `<time>`, in the active language; `expired` says "Expired on" |
137
+ | `ChatActionProposal` `failureCode` / `failureMessage` | `string` / `ReactNode` | — | a `failed` card's reason, in an `alert`, with the code |
138
+ | `ChatToolActivity` `name` / `state` / `detail` | `ReactNode` / `'running' \| 'ok' \| 'failed'` / `ReactNode` | — / `'running'` | one compact row for a tool step in a `ChatMessageList`: the name in mono, Running (spinner), Done or Failed |
108
139
  | `ChatThread` / `ChatMessageList` `layout` | `'bubbles' \| 'flat'` | `'bubbles'` | a messenger's bubbles, or a team chat's rows (name and time over each run) |
109
140
  | `ChatThread` / `ChatMessageList` `density` | `'comfortable' \| 'compact'` | `'comfortable'` | tighter bubbles, smaller avatars |
141
+ | `ChatMessageList` `viewportProps` | `ComponentProps` of the viewport | `{ tabIndex: 0 }` | attributes of the scrolling element; it is a tab stop so the keyboard can scroll the transcript (`tabIndex: -1` opts out) |
110
142
  | `ChatThread` `surface` | `'plain' \| 'card'` | `'plain'` | fills its parent, or draws a rounded bordered card |
111
143
  | `ChatThread` `composerProps` | `ChatComposerProps` | — | renders a `ChatComposer` under the list |
112
144
  | `ChatMessageList` `isTyping` / `typingAvatar` | `boolean` / `ReactNode` | `false` | a typing bubble at the end of the list |
@@ -117,6 +149,7 @@ Parts: `ChatInbox` (`list`, `thread`, `listHeader`, `pane`), `ChatConversationIt
117
149
  | `ChatComposer` `sendOnEnter` | `boolean` | `true` | Enter sends and Shift+Enter breaks the line; `false`: Ctrl/⌘+Enter sends. Never while an IME composes |
118
150
  | `ChatComposer` `emojis` / `mentions` | `EmojiItem[]` / `MentionItem[]` | — | enables the emoji picker and the `@` list |
119
151
  | `ChatThreadHeader` | `title`, `subtitle`, `avatarSrc`, `status`, `actions`, `onBack` | — | the title bar; inside a `ChatInbox` it carries the back button on a narrow screen |
152
+ | `ChatConversationItem` `title` / `titleText` | `ReactNode` / `string` | — | the clipped title carries its full text in a `title` attribute when it is a string; `titleText` gives it when `title` is a node |
120
153
  | `ChatConversationItem` `status` / `isTyping` | `StatusTone` / `boolean` | — | presence dot; "Typing…" instead of the preview |
121
154
  | `ChatInbox` `pane` / `defaultPane` | `'list' \| 'thread'` | `'list'` | which pane shows below the `@md` container width |
122
155
  | `ChatWidget` `open` / `defaultOpen` / `onOpenChange` | `boolean` | `false` | the floating launcher's panel (full screen below `sm`); `unreadCount` on the launcher, `side`, `strategy` |
@@ -146,7 +146,9 @@ sort control writes it), the cursor paging in the footer, `bulkActions` and the
146
146
  selection kept across pages, the empty and error pages. An empty page has no
147
147
  list bar over it, and the focus a bulk action's `clear()` hands back goes to the
148
148
  list. The sort control names its columns as in a `DataTable`: set `meta.label`
149
- on a column whose header is a render function (see [Sort](#sort)).
149
+ on a column whose header is a render function (see [Sort](#sort)). Without
150
+ `sorting`, no column is sortable (the server sorts, so pass `sorting` to let people
151
+ sort), and the phone list bar shows only for selection.
150
152
 
151
153
  ```tsx
152
154
  import { ServerTable } from '@nxgt/material/components';
@@ -341,7 +343,7 @@ the `Guides/Recipes/Status board` story, and the Claude Code plugin's
341
343
 
342
344
  ## Troubleshooting
343
345
 
344
- **The sort control is missing, or an option reads like the column id.** The control needs a column that can be sorted (an accessor, `enableSorting` not `false`); an option is the column's `meta.label`, else a string `header`. Set `meta.label`.
346
+ **The sort control is missing, or an option reads like the column id.** The control needs a column that can be sorted (an accessor, `enableSorting` not `false`); an option is the column's `meta.label`, else a string `header`. Set `meta.label`. On a `ServerTable` the control is also missing when `sorting` is not passed: no column is sortable without it. Pass `sorting` (the server sorts).
345
347
 
346
348
  **The phone shows the table for a moment.** `{ phone, wide }` renders `wide` on
347
349
  the server and for the first client render, so that hydration matches; a phone