@nxgt/material 3.45.0 → 3.47.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 (149) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +10 -3
  3. package/dist/{async-slice-D9LAUgth.js → async-slice-DlvKCKAa.js} +2 -2
  4. package/dist/{button-b_kruV_L.js → button-yaltbQmC.js} +1 -1
  5. package/dist/charts.js +3 -3
  6. package/dist/{chip-6O3t9GXt.js → chip-BqWbbr1b.js} +1 -1
  7. package/dist/{code-editor-BtDOgUET.js → code-editor-C9qBNoYb.js} +4 -4
  8. package/dist/code-editor.js +4 -4
  9. package/dist/{code-language-items-374m-OK8.js → code-language-items-DAHtfQ7W.js} +2 -2
  10. package/dist/{components-BG-BTfJP.js → components-DFPy96C3.js} +16282 -14755
  11. package/dist/components.js +26 -26
  12. package/dist/{dialog-8Ua9uuJW.js → dialog-CCCcnTIx.js} +4 -4
  13. package/dist/dnd.js +1 -1
  14. package/dist/{dropdown-menu-DJEeT1XT.js → dropdown-menu-Df8785nt.js} +1 -1
  15. package/dist/editor.js +7 -7
  16. package/dist/{empty-state-CrhrmNqn.js → empty-state-DcF4TBzi.js} +1 -1
  17. package/dist/hooks.js +28 -40
  18. package/dist/{i18n-provider-hrtFm9jD.js → i18n-provider-C0fB0sEL.js} +1 -1
  19. package/dist/i18n.js +5 -5
  20. package/dist/{icon-badge-Cpke1tCf.js → icon-badge-aJ22mKGh.js} +1 -1
  21. package/dist/lib/components/ui/display/enrolment-card/enrolment-card.d.ts +12 -0
  22. package/dist/lib/components/ui/display/enrolment-card/index.d.ts +2 -0
  23. package/dist/lib/components/ui/display/enrolment-card/partials/enrolment-header.d.ts +14 -0
  24. package/dist/lib/components/ui/display/enrolment-card/partials/enrolment-verification.d.ts +15 -0
  25. package/dist/lib/components/ui/display/enrolment-card/types.d.ts +49 -0
  26. package/dist/lib/components/ui/display/enrolment-card/use-enrolment-card-labels.d.ts +3 -0
  27. package/dist/lib/components/ui/display/index.d.ts +2 -0
  28. package/dist/lib/components/ui/display/recovery-codes-status/index.d.ts +2 -0
  29. package/dist/lib/components/ui/display/recovery-codes-status/partials/recovery-count.d.ts +11 -0
  30. package/dist/lib/components/ui/display/recovery-codes-status/recovery-codes-status.d.ts +12 -0
  31. package/dist/lib/components/ui/display/recovery-codes-status/types.d.ts +44 -0
  32. package/dist/lib/components/ui/display/recovery-codes-status/use-recovery-codes-status-labels.d.ts +3 -0
  33. package/dist/lib/components/ui/display/timeline/index.d.ts +1 -1
  34. package/dist/lib/components/ui/display/timeline/partials/timeline-day-heading.d.ts +14 -0
  35. package/dist/lib/components/ui/display/timeline/partials/timeline-empty.d.ts +8 -0
  36. package/dist/lib/components/ui/display/timeline/partials/timeline-groups.d.ts +22 -0
  37. package/dist/lib/components/ui/display/timeline/partials/timeline-item-body.d.ts +13 -0
  38. package/dist/lib/components/ui/display/timeline/partials/timeline-item-card.d.ts +5 -0
  39. package/dist/lib/components/ui/display/timeline/partials/timeline-item-rail.d.ts +8 -0
  40. package/dist/lib/components/ui/display/timeline/partials/timeline-item-time.d.ts +12 -0
  41. package/dist/lib/components/ui/display/timeline/partials/timeline-skeleton.d.ts +4 -0
  42. package/dist/lib/components/ui/display/timeline/timeline-item.d.ts +13 -2
  43. package/dist/lib/components/ui/display/timeline/timeline-marker.d.ts +12 -0
  44. package/dist/lib/components/ui/display/timeline/timeline-rows.d.ts +23 -0
  45. package/dist/lib/components/ui/display/timeline/timeline.d.ts +32 -2
  46. package/dist/lib/components/ui/display/timeline/types.d.ts +6 -0
  47. package/dist/lib/components/ui/feedback/alert/alert.d.ts +7 -1
  48. package/dist/lib/components/ui/feedback/index.d.ts +1 -0
  49. package/dist/lib/components/ui/feedback/step-up-dialog/index.d.ts +2 -0
  50. package/dist/lib/components/ui/feedback/step-up-dialog/partials/step-up-body.d.ts +15 -0
  51. package/dist/lib/components/ui/feedback/step-up-dialog/partials/step-up-footer.d.ts +22 -0
  52. package/dist/lib/components/ui/feedback/step-up-dialog/partials/step-up-header.d.ts +10 -0
  53. package/dist/lib/components/ui/feedback/step-up-dialog/step-up-dialog.d.ts +16 -0
  54. package/dist/lib/components/ui/feedback/step-up-dialog/types.d.ts +55 -0
  55. package/dist/lib/components/ui/feedback/step-up-dialog/use-step-up-labels.d.ts +3 -0
  56. package/dist/lib/components/ui/feedback/step-up-dialog/use-step-up-state.d.ts +12 -0
  57. package/dist/lib/components/ui/feedback/step-up-dialog/use-title-focus.d.ts +5 -0
  58. package/dist/lib/components/ui/fields/contacts/placeholders.d.ts +1 -1
  59. package/dist/lib/components/ui/fields/index.d.ts +1 -0
  60. package/dist/lib/components/ui/fields/verification-code/describe-verification.d.ts +53 -0
  61. package/dist/lib/components/ui/fields/verification-code/index.d.ts +4 -0
  62. package/dist/lib/components/ui/fields/verification-code/partials/code-input.d.ts +12 -0
  63. package/dist/lib/components/ui/fields/verification-code/partials/code-message.d.ts +11 -0
  64. package/dist/lib/components/ui/fields/verification-code/partials/code-slots.d.ts +18 -0
  65. package/dist/lib/components/ui/fields/verification-code/partials/code-surface.d.ts +12 -0
  66. package/dist/lib/components/ui/fields/verification-code/partials/form-actions.d.ts +14 -0
  67. package/dist/lib/components/ui/fields/verification-code/partials/label-row.d.ts +10 -0
  68. package/dist/lib/components/ui/fields/verification-code/partials/locked-alert.d.ts +17 -0
  69. package/dist/lib/components/ui/fields/verification-code/partials/method-switch.d.ts +8 -0
  70. package/dist/lib/components/ui/fields/verification-code/partials/recovery-input.d.ts +7 -0
  71. package/dist/lib/components/ui/fields/verification-code/partials/resend-line.d.ts +10 -0
  72. package/dist/lib/components/ui/fields/verification-code/partials/status-line.d.ts +10 -0
  73. package/dist/lib/components/ui/fields/verification-code/throttle.d.ts +10 -0
  74. package/dist/lib/components/ui/fields/verification-code/types.d.ts +138 -0
  75. package/dist/lib/components/ui/fields/verification-code/use-answer-focus.d.ts +32 -0
  76. package/dist/lib/components/ui/fields/verification-code/use-code-entry.d.ts +43 -0
  77. package/dist/lib/components/ui/fields/verification-code/use-code-lock.d.ts +36 -0
  78. package/dist/lib/components/ui/fields/verification-code/use-code-resend.d.ts +29 -0
  79. package/dist/lib/components/ui/fields/verification-code/use-code-submit.d.ts +23 -0
  80. package/dist/lib/components/ui/fields/verification-code/use-entry-anchors.d.ts +6 -0
  81. package/dist/lib/components/ui/fields/verification-code/use-focus-on-switch.d.ts +7 -0
  82. package/dist/lib/components/ui/fields/verification-code/use-resend-focus.d.ts +20 -0
  83. package/dist/lib/components/ui/fields/verification-code/use-resend.d.ts +11 -0
  84. package/dist/lib/components/ui/fields/verification-code/use-short-reminder.d.ts +24 -0
  85. package/dist/lib/components/ui/fields/verification-code/use-stale-answer.d.ts +52 -0
  86. package/dist/lib/components/ui/fields/verification-code/use-throttle-focus.d.ts +16 -0
  87. package/dist/lib/components/ui/fields/verification-code/use-throttle.d.ts +10 -0
  88. package/dist/lib/components/ui/fields/verification-code/use-typed-value.d.ts +18 -0
  89. package/dist/lib/components/ui/fields/verification-code/use-verification-labels.d.ts +3 -0
  90. package/dist/lib/components/ui/fields/verification-code/verification-code.d.ts +11 -0
  91. package/dist/lib/components/ui/fields/verification-code/verification-lock-provider.d.ts +54 -0
  92. package/dist/lib/components/ui/layout/stepper/partials/header-nav.d.ts +10 -0
  93. package/dist/lib/components/ui/layout/stepper/partials/header-segments.d.ts +6 -0
  94. package/dist/lib/components/ui/layout/stepper/partials/header-summary.d.ts +12 -0
  95. package/dist/lib/components/ui/layout/stepper/partials/stepper-content.d.ts +8 -0
  96. package/dist/lib/components/ui/layout/stepper/partials/stepper-step-text.d.ts +13 -0
  97. package/dist/lib/components/ui/layout/stepper/stepper-connector.d.ts +6 -0
  98. package/dist/lib/components/ui/layout/stepper/stepper-header.d.ts +12 -0
  99. package/dist/lib/components/ui/layout/stepper/stepper-icon.d.ts +5 -0
  100. package/dist/lib/components/ui/layout/stepper/stepper-layout.d.ts +25 -0
  101. package/dist/lib/components/ui/layout/stepper/stepper-step.d.ts +9 -2
  102. package/dist/lib/components/ui/layout/stepper/stepper.d.ts +13 -3
  103. package/dist/lib/components/ui/overlays/dialog/dialog-parts.d.ts +8 -1
  104. package/dist/lib/i18n/resources/en/en.d.ts +59 -0
  105. package/dist/lib/i18n/resources/en/material.json.d.ts +61 -2
  106. package/dist/lib/i18n/resources/fr/fr.d.ts +59 -0
  107. package/dist/lib/i18n/resources/fr/material.json.d.ts +61 -2
  108. package/dist/lib/i18n/resources/resources.d.ts +118 -0
  109. package/dist/lib/i18n/translator.d.ts +1 -1
  110. package/dist/lib.js +1 -1
  111. package/dist/main.js +43 -43
  112. package/dist/map.js +4 -4
  113. package/dist/material.css +1 -1
  114. package/dist/pdf-reader.js +1 -1
  115. package/dist/print.js +2 -2
  116. package/dist/{public-DPPmNtZB.js → public-zVb_Nq_D.js} +6 -6
  117. package/dist/{rich-code-block-KQYR0qt3.js → rich-code-block-DMWZZJma.js} +4 -4
  118. package/dist/{sortable-provider-hPPTMtJA.js → sortable-provider-DstbgMVf.js} +1 -1
  119. package/dist/{spinner-Dd0os4cb.js → spinner-C4CgDEYm.js} +1 -1
  120. package/dist/{stored-language-Blyb68vN.js → stored-language-IaL1Ftn_.js} +122 -4
  121. package/dist/styles.css +8 -0
  122. package/dist/theme-picker.js +2 -2
  123. package/dist/{theme-toggle-BDRRkkiA.js → theme-toggle-CGW2_K54.js} +1 -1
  124. package/dist/{toolbar-items-D3hw7XZE.js → toolbar-items-CsLGJMEJ.js} +2 -2
  125. package/dist/{translate-material-CdJBbURx.js → translate-material-v9u-e7Kd.js} +2 -2
  126. package/dist/{translator-38JuN_VD.js → translator-Dk4_qE6U.js} +1 -1
  127. package/dist/{use-chat-composer-utGkZQ6X.js → use-chat-composer-Brcb5f7r.js} +12 -12
  128. package/dist/{use-heading-spy-bTccmEMF.js → use-heading-spy-DjKixjLm.js} +43 -31
  129. package/dist/{use-material-translation-DLsfkMxp.js → use-material-translation-DOkTaWK_.js} +2 -2
  130. package/docs/README.md +1 -0
  131. package/docs/components/README.md +1 -1
  132. package/docs/components/buttons-and-actions.md +2 -1
  133. package/docs/components/data-display.md +133 -4
  134. package/docs/components/feedback-and-status.md +113 -1
  135. package/docs/components/form-fields.md +163 -0
  136. package/docs/components/navigation-and-layout.md +66 -23
  137. package/docs/components/overlays-and-menus.md +3 -1
  138. package/docs/guides/i18n.md +4 -1
  139. package/docs/guides/ssr.md +3 -1
  140. package/docs/guides/two-factor.md +78 -0
  141. package/docs/previews/data-display-enrolmentcard.webp +0 -0
  142. package/docs/previews/data-display-recoverycodesstatus.webp +0 -0
  143. package/docs/previews/data-display-timeline.webp +0 -0
  144. package/docs/previews/feedback-status-stepupdialog.webp +0 -0
  145. package/docs/previews/form-fields-verificationcode.webp +0 -0
  146. package/docs/previews/navigation-layout-stepper.webp +0 -0
  147. package/docs/roadmap.md +30 -0
  148. package/lib/styles.css +8 -0
  149. package/package.json +2 -2
@@ -24,7 +24,7 @@ from `@nxgt/material/components`.
24
24
  | <img src="../previews/data-display-entityheader.webp" width="96" alt=""> | [EntityHeader](#entityheader) | Page header with status, metadata and actions |
25
25
  | <img src="../previews/data-display-listtile.webp" width="96" alt=""> | [ListTile](#listtile) | Row with leading / trailing slots, row actions, chips and a divided variant |
26
26
  | <img src="../previews/data-display-accordioncard.webp" width="96" alt=""> | [AccordionCard](#accordioncard) | Accordion rendered from `items` |
27
- | <img src="../previews/data-display-timeline.webp" width="96" alt=""> | [Timeline](#timeline) | Vertical event timeline with relative times |
27
+ | <img src="../previews/data-display-timeline.webp" width="96" alt=""> | [Timeline](#timeline) | Vertical event timeline: shaped markers, cards, alternating sides, day groups (`DaySeparator` headings, words from `material.day-separator.*`), relative times; `layout="auto"` needs a parent with a definite width (`rail` in a `w-fit` parent) |
28
28
  | <img src="../previews/data-display-changehistory.webp" width="96" alt=""> | [ChangeHistory](#changehistory) | History of the changes made to anything, as a rail or as cards |
29
29
  | <img src="../previews/data-display-zonedtime.webp" width="96" alt=""> | [ZonedTime](#zonedtime) | A moment written in a given time zone, identical on the server and the client |
30
30
  | <img src="../previews/data-display-eventchip.webp" width="96" alt=""> | [EventChip](#eventchip) | Calendar event pill, soft, solid or outlined |
@@ -36,8 +36,10 @@ from `@nxgt/material/components`.
36
36
  | <img src="../previews/data-display-paramtable.webp" width="96" alt=""> | [ParamTable](#paramtable) | Name / type / default / description table |
37
37
  | <img src="../previews/data-display-qrcode.webp" width="96" alt=""> | [QRCode](#qrcode) | QR code on a padded tile |
38
38
  | <img src="../previews/data-display-barecode.webp" width="96" alt=""> | [Barecode](#barecode) | 1D barcode |
39
+ | <img src="../previews/data-display-enrolmentcard.webp" width="96" alt=""> | [EnrolmentCard](#enrolmentcard) | Turning on an authenticator app: the key, a notice and the first code to check, driven by props |
39
40
  | <img src="../previews/data-display-enrolmentsecret.webp" width="96" alt=""> | [EnrolmentSecret](#enrolmentsecret) | A TOTP enrolment: QR code, app link, the key grouped by four |
40
41
  | <img src="../previews/data-display-recoverycodes.webp" width="96" alt=""> | [RecoveryCodes](#recoverycodes) | One-time recovery codes: copy, download, print, confirm |
42
+ | <img src="../previews/data-display-recoverycodesstatus.webp" width="96" alt=""> | [RecoveryCodesStatus](#recoverycodesstatus) | How many recovery codes are left, and a new set behind a code |
41
43
 
42
44
  ## Avatar
43
45
 
@@ -963,7 +965,12 @@ import { AccordionCard } from '@nxgt/material/components';
963
965
 
964
966
  ![Timeline](../previews/data-display-timeline.webp)
965
967
 
966
- Vertical event timeline with toned icons and relative times.
968
+ Vertical event timeline with toned markers and relative times. Each marker is
969
+ 44px and takes a shape by tone (circle, squircle, cookie, clover, diamond,
970
+ burst) in the tone's container colour; an item with `children` is a rounded
971
+ card with a subtle tone bar. Once the timeline's own container is 48rem wide
972
+ (a container query, not the viewport) the two sides alternate; narrower, it is
973
+ a single rail. `grouped` opens each day with a `DaySeparator` heading.
967
974
 
968
975
  ```tsx
969
976
  import { Package, Pencil } from 'lucide-react';
@@ -982,11 +989,45 @@ Parts: `Timeline`, `TimelineItem` (one row, for your own list),
982
989
 
983
990
  | Prop | Type | Default | Effect |
984
991
  | --- | --- | --- | --- |
985
- | `items` | `TimelineItemData[]` | — | `title`, `description`, `timestamp`, `icon` or `avatar`, `tone`, `children` |
992
+ | `items` | `TimelineItemData[]` | — | `title`, `description`, `timestamp`, `icon` or `avatar`, `tone`, `children` (rendered as a card) |
993
+ | `grouped` | `boolean` | `false` | a heading per day ("Today", "Yesterday", a weekday or a date: a `DaySeparator`, real `<h3>`, with a `<time dateTime>` and its count); items keep the order given |
994
+ | `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `3` | the level of the day headings |
995
+ | `layout` | `'auto' \| 'rail'` | `'auto'` | `'auto'` alternates sides in a wide container (it measures its own container: its parent needs a definite width, see below); `'rail'` never does |
996
+ | `timeZone` | `string` | — | the zone the days are cut in |
997
+ | `now` | `Date` | — | the reference instant for "Today" and "Yesterday" |
986
998
  | `loading` | `boolean` | `false` | skeleton rows |
987
999
  | `empty` | `ReactNode` | "No activity yet" | shown when `items` is empty |
988
1000
 
989
- **Storybook:** `Data display/Timeline` · **Vue:** `Timeline`, `TimelineItem`, `RelativeTime` (without `now`, `tooltip`, `focusable` and `ref`, React only)
1001
+ ```tsx
1002
+ // Grouped on the server too: fix the zone and the reference instant.
1003
+ <Timeline items={items} grouped timeZone="UTC" now={new Date('2026-03-15T12:00:00Z')} aria-label="Order history" />
1004
+
1005
+ // One rail, never alternating sides.
1006
+ <Timeline items={items} layout="rail" aria-label="Order history" />
1007
+ ```
1008
+
1009
+ **Server rendering.** Without both `timeZone` and `now`, `grouped` renders no
1010
+ day headings on the server and on the first client render, and adds them once
1011
+ mounted (and logs a development warning once). With both, the headings render
1012
+ on the server too. The list is one flat `<ol>`: each heading sits inside the first `<li>` of
1013
+ its day, ahead of the item's content (a list may only hold `<li>`s, so a
1014
+ standalone heading row fails axe), never around the items, so an item keeps its
1015
+ parent, its key and its state when they appear (a counter inside an item is not reset).
1016
+ **Width.** `layout="auto"` is a container query on the Timeline itself, so its
1017
+ parent needs a definite width (a block, a grid cell, a fixed width). In a
1018
+ shrink-to-fit parent (`w-fit`, an inline flex item) it collapses to 0 wide:
1019
+ use `layout="rail"` there, which sets no container. In right-to-left the wide
1020
+ layout mirrors, time labels included. `now` also fixes the relative times
1021
+ ("5 minutes ago") of the items. **Accessibility:** the list keeps its
1022
+ `aria-label`, markers are decorative, each day heading is a real heading whose
1023
+ count reads as "N events", and every item of a day has `aria-describedby`
1024
+ pointing to that heading (a nested list labelled by it would put the items
1025
+ under a new parent). **Migrating:** the new look is the default; pass
1026
+ `layout="rail"` for a single rail. Children now render as a card, not as text
1027
+ under the title. New key: `material.timeline.events`; the day words come from
1028
+ `material.day-separator.today` and `yesterday`.
1029
+
1030
+ **Storybook:** `Data display/Timeline` · **Vue:** `Timeline`, `TimelineItem`, `RelativeTime` (without `now`, `tooltip`, `focusable` and `ref`, React only); the redesign and `grouped`, `layout`, `timeZone`, `now` are React only for now (#1810)
990
1031
 
991
1032
  ---
992
1033
 
@@ -1375,6 +1416,56 @@ not that span.
1375
1416
 
1376
1417
  ---
1377
1418
 
1419
+ ## EnrolmentCard
1420
+
1421
+ ![EnrolmentCard](../previews/data-display-enrolmentcard.webp)
1422
+
1423
+ Turning on an authenticator app, in one card: the [EnrolmentSecret](#enrolmentsecret) (QR code, app link, the key) on a tinted surface, a notice that the other devices are signed out, and the first code to prove the app works, with "Activate" and a tonal Cancel. It is **driven by props**, like the [VerificationCode](form-fields.md#verificationcode) it holds: `attemptsLeft`, `locked`, `error` and `pending` are your answer to the last code, `onSubmit` your call. It fetches nothing.
1424
+
1425
+ ```tsx
1426
+ import { EnrolmentCard, VerificationLockProvider } from '@nxgt/material/components';
1427
+
1428
+ <VerificationLockProvider>
1429
+ <EnrolmentCard
1430
+ uri={enrolment.uri}
1431
+ secret={enrolment.secret}
1432
+ onSubmit={(code) => activate(code)} // the code is an app's code
1433
+ onCancel={() => navigate('/settings/security')}
1434
+ error={answer.error}
1435
+ attemptsLeft={answer.attemptsLeft}
1436
+ locked={answer.locked}
1437
+ pending={activating}
1438
+ />
1439
+ </VerificationLockProvider>
1440
+ ```
1441
+
1442
+ | Prop | Type | Default | Effect |
1443
+ | --- | --- | --- | --- |
1444
+ | `uri`, `secret` | `string` | — | passed to `EnrolmentSecret` |
1445
+ | `onSubmit` | `(code, { kind }) => unknown` | — | called with the six digits; return a promise to hold a second submit |
1446
+ | `onCancel` | `() => void` | — | draws a Cancel beside Activate; left out, no Cancel |
1447
+ | `cancelVariant` | `ButtonVariant` | `'tonal'` | the Cancel button's variant; `'outlined'` or `'ghost'` restores an earlier look |
1448
+ | `attemptsLeft`, `locked`, `error`, `pending` | as `VerificationCode` | — | the answer to the last code: a wrong code empties the field and focuses it with the tries left; no tries left, or `locked`, locks the form and the page-wide lock. `error="throttled"` shows the general wait line only (no Resend, no `retryAfter` here). |
1449
+ | `title`, `titleAs`, `description` | `ReactNode`, `'h2'…`, `ReactNode \| false` | built-in, `'h2'` | the heading ("Set up your authenticator app"), its level, and the line under it |
1450
+ | `focusTitle` | `boolean` | `true` | the title takes the focus once mounted |
1451
+ | `length` | `number` | `6` | the slots in the code field, as `VerificationCode` |
1452
+ | `sessionsNotice` | `ReactNode \| false` | "Turning this on signs you out on your other devices." | the notice that the other devices are (or were) signed out; pass the after-the-fact wording once they were, `false` for none |
1453
+ | `status` | `ReactNode` | — | a notice line over the field ("Your session is confirmed. Type the next code.") |
1454
+ | `secretProps` | `Partial<EnrolmentSecretProps>` | — | the QR size, labels and copy icon |
1455
+ | `submitLabel`, `labels`, `icons` | — | "Activate" | the button, every string (`title`, `description`, `activate`, `cancel`, `sessionsNotice`, and those of `VerificationCode`), the locked alert's icon |
1456
+
1457
+ There is no recovery-code switch: no recovery code exists yet. The card reads
1458
+ the lock of the nearest `VerificationLockProvider` (or the document's), so a
1459
+ lock set by another code form disables its field and leaves Cancel usable.
1460
+
1461
+ **In a test**, find the field by `getByRole('textbox', { name: 'Code' })`, the
1462
+ button by `getByRole('button', { name: 'Activate' })` and the lock by
1463
+ `getByRole('alert')`.
1464
+
1465
+ **Storybook:** `Data display/EnrolmentCard`, `…/Shared lock`, `Guides/Accessibility/Touch targets/Verification code` · **Vue:** not yet
1466
+
1467
+ ---
1468
+
1378
1469
  ## EnrolmentSecret
1379
1470
 
1380
1471
  ![EnrolmentSecret](../previews/data-display-enrolmentsecret.webp)
@@ -1477,3 +1568,41 @@ useEffect(() => {
1477
1568
  ```
1478
1569
 
1479
1570
  **Storybook:** `Data display/RecoveryCodes` · **Vue:** `RecoveryCodes` (`v-model:saved`, `@done`, `@copy`; slots `title`, `description`, `warning`, `copy-icon`, `download-icon`, `print-icon`)
1571
+
1572
+ ---
1573
+
1574
+ ## RecoveryCodesStatus
1575
+
1576
+ ![RecoveryCodesStatus](../previews/data-display-recoverycodesstatus.webp)
1577
+
1578
+ Where an account stands on its recovery codes: how many are left, a warning when none is, and the way to make a new set. It is **driven by props**; show the new codes with [RecoveryCodes](#recoverycodes) once `onRegenerate` succeeds.
1579
+
1580
+ ```tsx
1581
+ import { RecoveryCodesStatus, VerificationLockProvider } from '@nxgt/material/components';
1582
+
1583
+ <VerificationLockProvider>
1584
+ <RecoveryCodesStatus
1585
+ remaining={account.recoveryCodesLeft}
1586
+ onRegenerate={(code, options) => regenerate(code, options?.kind)}
1587
+ error={answer.error}
1588
+ attemptsLeft={answer.attemptsLeft}
1589
+ locked={answer.locked}
1590
+ pending={regenerating}
1591
+ />
1592
+ </VerificationLockProvider>
1593
+ ```
1594
+
1595
+ | Prop | Type | Default | Effect |
1596
+ | --- | --- | --- | --- |
1597
+ | `remaining` | `number \| null` | — | the count, an ICU plural ("6 recovery codes left"); `0` turns it red and adds a warning; `null` or left out shows no count |
1598
+ | `onRegenerate` | `(code?, { kind }?) => unknown` | — | with `requireCode`, called with the code and its kind (`'code'` or `'recovery'`); without it, called with no argument |
1599
+ | `requireCode` | `boolean` | `true` | `true`: a `VerificationCode` whose button is "New codes"; `false`: a plain button |
1600
+ | `allowRecovery` | `boolean` | `true` | offers "Use a recovery code" in the form |
1601
+ | `length` | `number` | `6` | the slots in the code field, as `VerificationCode` |
1602
+ | `attemptsLeft`, `locked`, `error`, `pending` | as `VerificationCode` | — | the answer to the last code; the same locked state. `error="throttled"` shows the general wait line only (no Resend, no `retryAfter` here) |
1603
+ | `title`, `titleAs`, `description` | `ReactNode`, `'h2'…`, `ReactNode \| false` | built-in, `'h2'` | the heading ("Recovery codes"), its level, the line under it |
1604
+ | `labels`, `icons` | partial objects | built-in | `title`, `description`, `remaining` (`(count) => string`), `noneLeft`, `noneLeftHint`, `newCodes`, `newCodesHint`, and those of `VerificationCode` |
1605
+
1606
+ It reads the page-wide lock like [EnrolmentCard](#enrolmentcard) does. With `requireCode={false}` there is no code form, so `error` and `attemptsLeft` are not shown: the plain button follows `pending`, and is disabled by `locked` and by the page-wide lock; then the lock's text (the message of the answer that locked, or "Too many wrong codes. Try again later.") shows above it as a muted line that describes the button.
1607
+
1608
+ **Storybook:** `Data display/RecoveryCodesStatus`, `Data display/EnrolmentCard/Shared lock` · **Vue:** not yet
@@ -11,6 +11,7 @@ centre.
11
11
  | <img src="../previews/feedback-status-alertdialog.webp" width="96" alt=""> | [AlertDialog](#alertdialog) | Parts for a blocking confirmation dialog |
12
12
  | <img src="../previews/feedback-status-confirmdialog.webp" width="96" alt=""> | [ConfirmDialog](#confirmdialog) | Confirm / cancel dialog, `ConfirmationDialog`, `CustomAlertDialog` |
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
+ | <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 |
14
15
  | <img src="../previews/feedback-status-emptystate.webp" width="96" alt=""> | [EmptyState](#emptystate) | Illustration, title, description and action, by kind (empty, search, not-found, unauthorized) |
15
16
  | <img src="../previews/feedback-status-errorstate.webp" width="96" alt=""> | [ErrorState](#errorstate) | Failure block with Retry and technical details |
16
17
  | <img src="../previews/feedback-status-progress.webp" width="96" alt=""> | [Progress](#progress) | Material 3 Expressive linear bar, circular ring and labelled progress field, flat or wavy |
@@ -56,9 +57,25 @@ keep the old look.
56
57
  | `icon` | `ReactNode` | the tone's icon | shown before the text; `null` (or `false`) shows none, any node replaces the tone's icon |
57
58
  | `title` | `ReactNode` | — | bold first line |
58
59
  | `description` | `ReactNode` | — | muted body text |
60
+ | `actions` | `ReactNode` | — | buttons or links under the text, wrapping on a narrow screen (`data-slot="alert-actions"`); a `Button` or `LinkButton` keeps its 44 px target on a coarse pointer |
59
61
 
60
62
  The root carries `data-slot="alert"`, the title `data-slot="alert-title"` and the description `data-slot="alert-description"`, for tests and selectors.
61
63
 
64
+ **Actions.** Give an Alert a way out with `actions`, such as a link to the next step:
65
+
66
+ ```tsx
67
+ <Alert
68
+ variant="warning"
69
+ title="A second step is required"
70
+ description="Turn on two-step sign-in to continue."
71
+ actions={
72
+ <Button asChild size="sm">
73
+ <a href="/settings/security">Continue</a>
74
+ </Button>
75
+ }
76
+ />
77
+ ```
78
+
62
79
  **In a form grid.** Inside `.form-grid-layout`'s `[data-slot="form-fields"]` or a [FormSection](form-fields.md#formsection)'s fields, an `Alert` or a `Banner` placed directly in the grid takes the whole row (`col-span-full w-full`) without any class from you; a utility class of your own on it still wins.
63
80
 
64
81
  ```tsx
@@ -71,7 +88,7 @@ The root carries `data-slot="alert"`, the title `data-slot="alert-title"` and th
71
88
  </form>
72
89
  ```
73
90
 
74
- **Storybook:** `Feedback & status/Alert/Tone icon` (`ToneIcon`), `Feedback & status/Alert/In a form grid` (`InFormGrid`) · **Vue:** `Alert` (the default icon and the form-grid row are React only for now)
91
+ **Storybook:** `Feedback & status/Alert/Tone icon` (`ToneIcon`), `Feedback & status/Alert/In a form grid` (`InFormGrid`), `Feedback & status/Alert` (`WithActions`) · **Vue:** `Alert` (the default icon and the form-grid row are React only for now)
75
92
 
76
93
  ---
77
94
 
@@ -336,6 +353,101 @@ import { Atom } from 'lucide-react';
336
353
 
337
354
  ---
338
355
 
356
+ ## StepUpDialog
357
+
358
+ ![StepUpDialog](../previews/feedback-status-stepupdialog.webp)
359
+
360
+ "Confirm it's you": a dialog that asks for a one-time code before the app goes
361
+ on with a sensitive action. It is **presentational and props-driven**, built on
362
+ `Dialog` and `VerificationCode`: it holds no state machine and fetches nothing,
363
+ so you wire the model of your own (a session, a store, a loader) to its props.
364
+
365
+ ```tsx
366
+ import { StepUpDialog, VerificationLockProvider } from '@nxgt/material/components';
367
+
368
+ type Step = {
369
+ open: boolean;
370
+ via?: 'email' | 'secondFactor';
371
+ address?: string;
372
+ requestError?: string;
373
+ attemptsLeft?: number;
374
+ error?: 'invalid' | 'expired' | 'failed' | 'throttled';
375
+ retryAfter?: number;
376
+ pending: boolean;
377
+ resent: boolean;
378
+ accepted: boolean;
379
+ };
380
+
381
+ export function ConfirmItsYou({
382
+ step,
383
+ close,
384
+ check,
385
+ sendAgain,
386
+ }: {
387
+ step: Step;
388
+ close: () => void;
389
+ check: (code: string, kind: 'code' | 'recovery') => Promise<void>;
390
+ sendAgain: () => Promise<void>;
391
+ }) {
392
+ return (
393
+ <VerificationLockProvider>
394
+ <StepUpDialog
395
+ open={step.open}
396
+ onCancel={close}
397
+ via={step.via} // left out while the code is being requested
398
+ destination={step.address}
399
+ requestError={step.requestError}
400
+ attemptsLeft={step.attemptsLeft}
401
+ error={step.error}
402
+ retryAfter={step.retryAfter}
403
+ pending={step.pending}
404
+ onSubmit={(code, { kind }) => check(code, kind)}
405
+ onResend={sendAgain}
406
+ resent={step.resent}
407
+ confirming={step.accepted}
408
+ />
409
+ </VerificationLockProvider>
410
+ );
411
+ }
412
+ ```
413
+
414
+ | Prop | Type | Default | Effect |
415
+ | --- | --- | --- | --- |
416
+ | `open`, `onCancel` | `boolean`, `() => void` | — | Escape, a click outside and Cancel call `onCancel`, except while `confirming`; you close the dialog |
417
+ | `cancelVariant` | `ButtonVariant` | `'tonal'` | the Cancel button's variant; `'outlined'` or `'ghost'` restores an earlier look |
418
+ | `via` | `'email' \| 'secondFactor'` | — | how the code was issued; left out, the code is still being requested: no field, a status line. Changing it starts the form again (typed value, recovery switch) |
419
+ | `destination` | `ReactNode` | — | where an e-mailed code went; named in the description |
420
+ | `requestError` | `ReactNode` | — | the request for a code failed: an alert in place of the field |
421
+ | `attemptsLeft`, `locked`, `error`, `retryAfter`, `pending` | as `VerificationCode` | — | the answer to the last code (`error="throttled"` with `retryAfter` seconds disables "Resend the code" for that long and never locks; the footer is unchanged). No tries left: an e-mailed code reads "expired, ask for a new code"; an app or recovery code shows the locked alert and writes the page-wide lock |
422
+ | `length`, `allowRecovery` | `number`, `boolean` | `6`, per `via` | the slots in the code field, and "Use a recovery code" (on for `via="secondFactor"`), passed to the `VerificationCode` |
423
+ | `onSubmit`, `onResend`, `resent` | as `VerificationCode` | — | your calls; "Resend the code" sits alone on its own line |
424
+ | `confirming`, `confirmedLabel` | `boolean`, `ReactNode` | — | the code was accepted and you re-run the action: the field goes, a status line shows, Cancel is disabled and Escape does nothing until you close it |
425
+ | `title`, `description` | `ReactNode` | built-in | the title ("Confirm it's you") and the line under it, which follows `via` |
426
+ | `labels`, `icons` | partial objects | built-in | every string of the dialog (`title`, `description`, `descriptionApp`, `descriptionEmail`, `descriptionEmailTo`, `requesting`, `cancel`, `confirmed`) and of `VerificationCode` (`incomplete`, `incompleteRecovery` among them) |
427
+
428
+ Confirm is a footer button that submits the form. Pressed with too few digits
429
+ (or an empty recovery code) it focuses the field and says "Enter all 6 digits."
430
+ instead of calling `onSubmit`.
431
+
432
+ **Focus.** The title takes the focus on open and whenever the field goes away
433
+ (a code being requested, a code accepted); the field takes it after a refusal;
434
+ on close it goes back to the button that opened the dialog. A locked code
435
+ leaves Cancel usable.
436
+
437
+ **On a phone** the footer stays one row, Cancel on the left and Confirm on the
438
+ right: `DialogFooter` takes `stackOnPhone={false}` (`true` by default, the
439
+ buttons stacked full width). Every control keeps a 44 px target on a coarse
440
+ pointer.
441
+
442
+ **In a test**, find the field by `getByRole('textbox', { name: 'Code' })`, the
443
+ dialog by `getByRole('dialog', { name: "Confirm it's you" })` and the lock by
444
+ `getByRole('alert')`.
445
+
446
+ **Storybook:** `Feedback & status/StepUpDialog`, `…/Locks`, `…/Throttled`, `…/Layout`, `Guides/Accessibility/Touch targets/Step-up dialog` ·
447
+ **Vue:** not yet
448
+
449
+ ---
450
+
339
451
  ## EmptyState
340
452
 
341
453
  ![EmptyState](../previews/feedback-status-emptystate.webp)
@@ -24,6 +24,7 @@ for the value type each one writes. Everything is imported from
24
24
  | <img src="../previews/form-fields-searchfield.webp" width="96" alt=""> | [SearchField](#searchfield) | Clear button, spinner, debounced value |
25
25
  | <img src="../previews/form-fields-colorfield.webp" width="96" alt=""> | [ColorField](#colorfield) | Hex input with a picker |
26
26
  | <img src="../previews/form-fields-otpfield.webp" width="96" alt=""> | [OtpField](#otpfield) | One-time code input |
27
+ | <img src="../previews/form-fields-verificationcode.webp" width="96" alt=""> | [VerificationCode](#verificationcode) | The form that checks a one-time code, with a page-wide lock |
27
28
  | <img src="../previews/form-fields-inputgroupfield.webp" width="96" alt=""> | [InputGroupField](#inputgroupfield) | Labelled `InputGroup` with addons |
28
29
  | <img src="../previews/form-fields-localizedtextfields.webp" width="96" alt=""> | [LocalizedTextFields](#localizedtextfields) | One text in several languages, with defaults and a reset |
29
30
  | <img src="../previews/form-fields-inlineedit.webp" width="96" alt=""> | [InlineEdit](#inlineedit) | Click-to-edit text |
@@ -742,6 +743,168 @@ carries `data-invalid`, not `aria-invalid`.
742
743
 
743
744
  ---
744
745
 
746
+ ## VerificationCode
747
+
748
+ ![VerificationCode](../previews/form-fields-verificationcode.webp)
749
+
750
+ A `<form>` that asks for a one-time code, **presentational and controlled**: it
751
+ keeps the typed value, the app or recovery switch, the focus and the submit
752
+ guard, and shows the answer you give it. It fetches nothing and holds no state
753
+ machine; your state (a session, a store, a loader) says what happened, and
754
+ `onSubmit` and `onResend` are yours. Every string is a label.
755
+
756
+ The slots are tinted boxes inside a hairline ring that holds 3:1 against the
757
+ card in light and dark, with a dot while empty, a dash between the halves, 20px
758
+ semibold tabular digits, and a soft caret and a 2px primary ring on the active
759
+ one (the caret stands still under `prefers-reduced-motion`). A wrong code tints
760
+ the slots and digits and shows its message with an icon; a full code is tinted with
761
+ the primary colour (the slots pop in once, only where motion is welcome); a locked form is
762
+ muted, with a "Locked" pill in the label row (`labels.lockedBadge`) and the lock
763
+ message under the slots. The recovery code's input shares the same fill and ring.
764
+
765
+ ```tsx
766
+ import { VerificationCode, VerificationLockProvider } from '@nxgt/material/components';
767
+
768
+ export function SecondStep({ answer, pending, send }: Props) {
769
+ return (
770
+ <VerificationLockProvider>
771
+ <VerificationCode
772
+ via="secondFactor"
773
+ attemptsLeft={answer?.attemptsLeft}
774
+ error={answer?.error}
775
+ pending={pending}
776
+ onSubmit={(code, { kind }) => send(code, kind)}
777
+ />
778
+ </VerificationLockProvider>
779
+ );
780
+ }
781
+ ```
782
+
783
+ | Prop | Type | Default | Effect |
784
+ | --- | --- | --- | --- |
785
+ | `via` | `'email' \| 'secondFactor'` | — | how the code was issued: sent to the person, or from an authenticator app |
786
+ | `attemptsLeft` | `number` | — | the tries left, said under a wrong code; at `0` see below |
787
+ | `error` | `'invalid' \| 'expired' \| 'failed' \| 'throttled' \| ReactNode` | — | the last refusal; a node is shown as a wrong code's message. `'throttled'` is not a refusal of the code: see below. Leave it out when a new code is typed or accepted |
788
+ | `retryAfter` | `number` | — | with `error="throttled"`, the seconds to wait before asking for a new code |
789
+ | `locked` | `boolean` | — | locks this form, and the page-wide lock, for an app or recovery code; ignored for an e-mailed code, which is never locked unless a recovery code is typed in its form |
790
+ | `pending` | `boolean` | — | a request is in flight: field disabled, Confirm busy, a second press dropped |
791
+ | `onSubmit` | `(code, { kind: 'code' \| 'recovery' }) => unknown` | — | the code as typed (a recovery code is trimmed and cut at 40) and what it is (`'code'` is the issued code, from an app or an e-mail). Return a promise to hold a second submit |
792
+ | `onResend`, `resent` | `() => unknown`, `boolean` | — | "Resend the code" alone on its own line (an e-mailed code), "New code" / "Try again" once a code expired; `resent` says a new code was sent |
793
+ | `allowRecovery` | `boolean` | `via === 'secondFactor'` | "Use a recovery code", which swaps the field for a text field and back |
794
+ | `length` | `number` | `6` | slots in the code field |
795
+ | `id` | `string` | generated | the form's id; the buttons of `actions="none"` point at it with `form={id}`. Other `<form>` props pass through |
796
+ | `actions` | `'inline' \| 'none'` | `'inline'` | `'none'` leaves the buttons to you; point them at the form with `form={id}` |
797
+ | `submitLabel`, `secondaryAction` | `ReactNode` | "Confirm" | the button, and what sits beside it (Cancel), which stays usable when locked |
798
+ | `destructive` | `boolean` | — | the button is red (turning protection off) |
799
+ | `status` | `ReactNode` | — | a notice line over the field |
800
+ | `labels`, `icons` | partial objects | built-in | every string (`triesLeft`, `incomplete`, `throttledMinutes` and `throttledSeconds` are `(count) => string`; `incompleteRecovery` is the empty recovery code's message); the locked alert's icon |
801
+
802
+ Submitting too few digits (the inline Confirm, which stays enabled until the
803
+ form is locked, Enter, or a Confirm button of your own pointed at the form)
804
+ does not call `onSubmit`: it focuses the field and says "Enter all 6 digits."
805
+ (an empty recovery code, "Enter your recovery code."), announced once.
806
+
807
+ A wrong code empties the field and focuses it once your answer arrives, and
808
+ says "Wrong code." with the tries left (an ICU plural). `expired` and `failed`
809
+ say their message. One polite live region announces what the focus does not
810
+ read: "A new code was sent.", and a refusal you set without a submit. A refusal
811
+ that follows a submit sends the focus to the field, which is described by the
812
+ message, so the live region leaves it out rather than say it twice. A rejected
813
+ `onSubmit` is swallowed: map the failure to `error="failed"`.
814
+
815
+ **Throttled.** `error="throttled"` says too many new codes were asked for.
816
+ It is not a refusal of the code: the field stays usable, nothing locks (for an
817
+ app or recovery code, `locked` or `attemptsLeft: 0` still does and overrides
818
+ it; an e-mailed code never locks), no try is counted, and an entry switch does
819
+ not clear it. It speaks before "expired": an e-mailed code with no tries left
820
+ that is throttled when "New code" is pressed says the wait, not "This code has
821
+ expired.", and the button keeps the "New code" label. The line stays until you
822
+ clear `error` (the end of the wait only re-enables Resend). With `retryAfter` (seconds) the line reads "Wait 2
823
+ min before asking for a new code." (minutes rounded up, "Wait 45 s…" under a
824
+ minute; on an authenticator or recovery code, `via="secondFactor"`, "Wait 2 min
825
+ before trying again.") and "Resend the code" is disabled, described by that line, until the
826
+ wait elapsed, counted from when the answer arrived: one timer, no countdown,
827
+ so the text does not tick. A new `retryAfter` starts the clock again, and so does a
828
+ resend that settles while `error` is still `"throttled"`, even with the same
829
+ `retryAfter`. Resend
830
+ is disabled once the client has hydrated, not in the server HTML. Without `retryAfter` the line is general ("Too many
831
+ requests. Wait a moment and try again.") and Resend stays on. `EnrolmentCard` and `RecoveryCodesStatus` take `error="throttled"` too, with no Resend and no
832
+ `retryAfter`: they show that general line only.
833
+ It is announced once by the polite live region; when it answers a press of
834
+ Resend, the focus goes to the field (the pressed button is now disabled), which
835
+ is described by the line, and the live region leaves it out. The words are
836
+ `labels.throttled`, `throttledMinutes(n)` and `throttledSeconds(n)` (`throttledMinutesApp(n)` and `throttledSecondsApp(n)` for `via="secondFactor"`). On the
837
+ server nothing waits and no timer runs.
838
+
839
+ **With no tries left** (`attemptsLeft === 0`) the form reads the answer by what
840
+ the code was: an e-mailed code shows "expired, ask for a new code" (nothing is
841
+ held against it), and an app or recovery code shows the locked alert, because
842
+ that lock belongs to the account, so it also writes the page-wide lock below.
843
+ What is typed decides what is held: an e-mailed form in recovery mode is held
844
+ like an app's. Switching the entry clears the held decision of the answer you
845
+ gave for the other kind of code: a stale `attemptsLeft: 0` for the e-mailed
846
+ code does not lock the recovery field, only a refused recovery code does, and
847
+ a lock already written stays written. The stale answer is released once a
848
+ changed answer arrives, or a submit made since the switch ends with `pending`
849
+ down, so raise `pending` in the same tick as your `onSubmit` (or return its
850
+ promise); an `error` node rebuilt on each render reads as a changed answer. The locked message disables the field and Confirm and describes
851
+ them (`aria-describedby`); it takes the focus after a submit that locked it.
852
+ One lock is one alert: only the first form to show it has `role="alert"`, and
853
+ the other forms under the same lock show the same words as a plain element.
854
+ With no provider every form whose own answer locked it is an alert (the server
855
+ and the hydrated page must agree), and the forms the document lock holds are
856
+ plain elements.
857
+
858
+ **Wiring your state.** Map the answer your session or API gives to the props;
859
+ nothing else is needed:
860
+
861
+ ```tsx
862
+ type Answer = {
863
+ via: 'email' | 'secondFactor';
864
+ attemptsLeft?: number;
865
+ error?: 'invalid' | 'expired' | 'failed' | 'throttled';
866
+ retryAfter?: number; // seconds, with 'throttled'
867
+ };
868
+
869
+ function CodeStep({ answer, pending, send, sendAgain }: StepProps<Answer>) {
870
+ return (
871
+ <VerificationCode
872
+ via={answer.via}
873
+ attemptsLeft={answer.attemptsLeft}
874
+ error={answer.error}
875
+ retryAfter={answer.retryAfter}
876
+ pending={pending}
877
+ onSubmit={(code, { kind }) => send({ code, recovery: kind === 'recovery' })}
878
+ onResend={answer.via === 'email' ? sendAgain : undefined}
879
+ />
880
+ );
881
+ }
882
+ ```
883
+
884
+ **The lock.** An app or recovery code that is `locked`, or has no tries left,
885
+ writes a page-wide lock: every app or recovery field under the same
886
+ `VerificationLockProvider` stays locked until the page is loaded again, whether
887
+ it remounts, the step changes or a dialog closes. Without a provider the lock
888
+ covers the whole document. A code sent by e-mail is never held by it, unless a recovery code is typed in its form.
889
+ `useVerificationLock()` returns `{ locked, message, lock(message?) }` for the
890
+ rest of your app; on the server it is always unlocked, so hydration cannot
891
+ mismatch. `createVerificationLock` and `normalizeRecoveryCode` are in
892
+ `@nxgt/material-core/two-factor`.
893
+
894
+ For a layout of your own use `useVerificationLabels`.
895
+
896
+ **On a phone** the buttons stay on one row, the slots shrink to fit 320 px, and
897
+ every control keeps a 44 px target on a coarse pointer.
898
+
899
+ **In a test**, find the field by `getByRole('textbox', { name: 'Code' })`, the
900
+ refusal as the field's accessible description (`toHaveAccessibleDescription`; the `status` region carries only what the focus does not read: after a click on Confirm it stays empty), and the lock by `getByRole('alert')` (one per lock).
901
+
902
+ **Storybook:** `Form fields/VerificationCode`, `…/States`, `…/Lock`, `…/Lock scope`, `…/Answers`, `…/Failures`, `…/Recovery switch`, `…/Throttled`, `…/Throttled expired`, `…/Entry switch`, `…/Layout`, `…/Refined`, `…/Refined checks`,
903
+ `Navigation & layout/AuthCard/Second factor` · **Vue:** not yet (the framework-free
904
+ part is `@nxgt/material-core/two-factor`)
905
+
906
+ ---
907
+
745
908
  ## InputGroupField
746
909
 
747
910
  ![InputGroupField](../previews/form-fields-inputgroupfield.webp)
@@ -21,7 +21,7 @@ documentation-site shell. Everything is imported from
21
21
  | <img src="../previews/navigation-layout-pager.webp" width="96" alt=""> | [Pager](#pager) | Previous / next cards |
22
22
  | <img src="../previews/navigation-layout-seealso.webp" width="96" alt=""> | [SeeAlso](#seealso) | Related links |
23
23
  | <img src="../previews/navigation-layout-toc.webp" width="96" alt=""> | [Toc](#toc) | On-this-page outline |
24
- | <img src="../previews/navigation-layout-stepper.webp" width="96" alt=""> | [Stepper](#stepper) | Clickable steps with progress |
24
+ | <img src="../previews/navigation-layout-stepper.webp" width="96" alt=""> | [Stepper](#stepper) | Clickable steps with progress: shaped indicators, optional steps, a phone header |
25
25
  | <img src="../previews/navigation-layout-steps.webp" width="96" alt=""> | [Steps](#steps) | Numbered editorial steps |
26
26
  | <img src="../previews/navigation-layout-hero.webp" width="96" alt=""> | [Hero](#hero) | Landing band |
27
27
  | <img src="../previews/navigation-layout-doc.webp" width="96" alt=""> | [Doc](#doc) | Documentation site shell |
@@ -657,23 +657,41 @@ const items: TocItem[] = [
657
657
 
658
658
  ![Stepper](../previews/navigation-layout-stepper.webp)
659
659
 
660
- Clickable horizontal or vertical steps with status and progress; compact on mobile.
660
+ Clickable horizontal or vertical steps with status and progress. The look is
661
+ expressive: the current step is a cookie, a completed one a filled circle with
662
+ a check, an upcoming one a muted outlined circle and a failed one a cookie in
663
+ the error container, joined by thick pill tracks that fill with the spatial
664
+ spring (no motion under `prefers-reduced-motion`). Below `sm` a header card
665
+ replaces the steps.
661
666
 
662
667
  ```tsx
663
668
  import { CreditCard, Truck, User } from 'lucide-react';
664
- import { Stepper, TooltipProvider } from '@nxgt/material/components';
669
+ import { Stepper } from '@nxgt/material/components';
670
+
671
+ <Stepper
672
+ active={active}
673
+ onChange={setActive}
674
+ steps={[
675
+ { icon: <User />, title: 'Account', description: 'Your profile details.' },
676
+ { icon: <Truck />, title: 'Shipping', status: 'completed' },
677
+ { icon: <CreditCard />, title: 'Payment' },
678
+ ]}
679
+ />
680
+ ```
665
681
 
666
- <TooltipProvider>
667
- <Stepper
668
- active={active}
669
- onChange={setActive}
670
- steps={[
671
- { icon: <User />, title: 'Account', description: 'Your profile details.' },
672
- { icon: <Truck />, title: 'Shipping', status: 'completed' },
673
- { icon: <CreditCard />, title: 'Payment' },
674
- ]}
675
- />
676
- </TooltipProvider>
682
+ An optional step with a card under it, and Back / Next in the phone header
683
+ (they call `onChange(active ± 1)`):
684
+
685
+ ```tsx
686
+ <Stepper
687
+ active={active}
688
+ onChange={setActive}
689
+ steps={[
690
+ { title: 'Account' },
691
+ { title: 'Address', optional: true, content: <p>Skip this if you pick up in store.</p> },
692
+ { title: 'Payment' },
693
+ ]}
694
+ />
677
695
  ```
678
696
 
679
697
  Parts: `Stepper`, `StepperIndicator` (the progress bar alone: `current`,
@@ -681,10 +699,10 @@ Parts: `Stepper`, `StepperIndicator` (the progress bar alone: `current`,
681
699
 
682
700
  | Prop | Type | Default | Effect |
683
701
  | --- | --- | --- | --- |
684
- | `steps` | `{ icon?; title; description?; status? }[]` | — | `status`: `'completed' \| 'error' \| 'upcoming'`, else derived from `active` |
685
- | `active` | `number` | `0` | current step; its `description` shows under the bar |
702
+ | `steps` | `{ icon?; title; description?; status?; optional?; content? }[]` | — | `status`: `'completed' \| 'error' \| 'upcoming'`, else derived from `active`. `optional` adds "· Optional" to the label; `content` is a tinted card shown under the current step |
703
+ | `active` | `number` | `0` | current step; its `description` shows under the steps (in the header card on a phone) |
686
704
  | `onChange` | `(index: number) => void` | — | makes steps clickable |
687
- | `orientation` | `'horizontal' \| 'vertical' \| 'responsive'` | `'horizontal'` | `'vertical'`: a column from `md`, phones get the compact bar. `'responsive'`: every step in a column below `sm` (640px), a row from `sm` |
705
+ | `orientation` | `'horizontal' \| 'vertical' \| 'responsive'` | `'horizontal'` | `'vertical'`: a column from `sm` (640px), phones get the header card. `'responsive'`: every step in a column below `sm` (640px), a row from `sm` |
688
706
  | `renderStepLabel` | `(step, index) => ReactNode` | — | replaces the "Step n" caption |
689
707
  | `name` | `string` | — | the stepper's `data-pw` (`stepper` without it), and the prefix of each step's: `<name>-step-N` instead of `step-N` |
690
708
 
@@ -692,12 +710,37 @@ Each step is an `<li>` with `data-pw="step-N"` (`<name>-step-N` with `name`),
692
710
  N counted from 0, for tests to select; the current step's button carries
693
711
  `aria-current="step"`, and a completed or failed step is announced as such.
694
712
 
695
- A server renders the desktop layout (every step), as does a hydrating client;
696
- a phone switches to the compact bar right after hydrating (with
697
- `orientation="responsive"`, to the column of every step). A page rendered
698
- only in the browser starts on the real layout.
699
-
700
- **Storybook:** `Navigation & layout/Stepper` · **Vue:** `Stepper`
713
+ Below `sm` a header card shows "Step 3 of 5", the title, the description and a
714
+ segmented progress bar (one segment per step, toned by state); with `onChange`
715
+ it also has Back and Next buttons, which stay focusable but inactive (`aria-disabled`) at the ends. Titles clamp to two
716
+ lines. The info tooltip on the current step is gone: read the description
717
+ under the steps.
718
+
719
+ **Server rendering.** The layout is chosen by CSS breakpoints, not by the
720
+ screen width in JavaScript, so the server and the client render the same
721
+ markup and nothing switches after hydration. Both layouts are in the markup
722
+ and the unused one is hidden; ids stay unique.
723
+
724
+ **Accessibility.** The steps are a list, the current step's button carries
725
+ `aria-current="step"`, a completed or failed step is announced as such, and
726
+ steps are focusable when `onChange` is given. The header's progress bar is
727
+ decorative: the "Step n of N" text carries the meaning. The live header
728
+ announces a failed current step and the number of failed steps; Back and Next
729
+ use `aria-disabled` and stay focusable at the ends.
730
+
731
+ **Migrating.** The new look is the default and the props are unchanged;
732
+ `optional` and `content` are additive. Update any test that selected the old
733
+ info button on the current step. New `material.stepper.*` keys: `step-of`,
734
+ `back`, `next`, `optional`, `attention`, `failed`. The `attention` text ("Needs attention") is shown under the title of a failed step, as a visual cue only: the announced status stays "Error".
735
+
736
+ Migration notes:
737
+
738
+ - `data-orientation` now repeats the `orientation` prop; a `responsive` Stepper no longer reports `vertical` or `horizontal` by width.
739
+ - Below 640px the horizontal step list is hidden and the header card replaces it.
740
+ - `horizontal` and `vertical` switch at `sm` (640px), not `md` (768px).
741
+ - `Stepper` no longer renders `StepperIndicator` (still exported).
742
+
743
+ **Storybook:** `Navigation & layout/Stepper` · **Vue:** `Stepper`, with the earlier look: the redesign, `optional` and `content` are React only for now (#1810)
701
744
 
702
745
  ---
703
746
 
@@ -139,7 +139,9 @@ utilities: a class you pass wins. The flat look of 2.x before it is
139
139
  `className="rounded-lg border bg-background shadow-lg"` on `DialogContent`
140
140
  (and `className="bg-black/50"` on a `DialogOverlay` of your own).
141
141
  `DialogFooter` stacks its actions full width on a phone, the main one at the
142
- bottom. With `prefers-reduced-motion`, nothing moves: it appears and goes.
142
+ bottom. `stackOnPhone={false}` keeps one row instead, the first
143
+ action on the left and the last on the right, for two short actions such as
144
+ Cancel and Confirm. With `prefers-reduced-motion`, nothing moves: it appears and goes.
143
145
 
144
146
  `DialogContent` and `AlertDialogContent` are capped at `100dvh - 2rem` (`100vh`
145
147
  where `dvh` is unsupported) and scroll inside themselves, so a long body never