@nxgt/material-vue 0.35.0 → 0.37.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 (114) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +11 -5
  3. package/dist/{Button-C6GBoxwB.js → Button-CurFF4Zt.js} +1 -1
  4. package/dist/{HoverCardTrigger-BRajrP9V.js → HoverCardTrigger-jCXl6TrX.js} +3 -3
  5. package/dist/{camera-BIK3JMQq.js → camera-DDnR8aYK.js} +4 -4
  6. package/dist/components/components.css +1 -0
  7. package/dist/components/print-only.css +44 -0
  8. package/dist/{components-B3e9Djen.js → components-CCzdGGAe.js} +42239 -41714
  9. package/dist/components.js +8 -8
  10. package/dist/composables.js +35 -25
  11. package/dist/dnd.js +1 -1
  12. package/dist/{form-scope-DH96Nu9y.js → form-scope-Csf9H-Pg.js} +1 -1
  13. package/dist/{i18n-provider-D0wc5ysD.js → i18n-provider-B2q6vp6H.js} +1 -1
  14. package/dist/i18n.js +4 -4
  15. package/dist/{icon-button-Cep-hCMB.js → icon-button-CIUc1bEp.js} +1 -1
  16. package/dist/lib/components/index.d.ts +1 -0
  17. package/dist/lib/components/ui/actions/button/Button.vue.d.ts +1 -1
  18. package/dist/lib/components/ui/actions/button/button.variants.d.ts +1 -1
  19. package/dist/lib/components/ui/actions/copy-button/copy-button.vue.d.ts +1 -1
  20. package/dist/lib/components/ui/actions/copy-button/use-copy-status.d.ts +14 -0
  21. package/dist/lib/components/ui/actions/icon-button/icon-button.variants.d.ts +1 -1
  22. package/dist/lib/components/ui/actions/icon-button/icon-button.vue.d.ts +1 -1
  23. package/dist/lib/components/ui/actions/scroll-to-top/scroll-to-top.vue.d.ts +1 -1
  24. package/dist/lib/components/ui/display/badge/index.d.ts +1 -1
  25. package/dist/lib/components/ui/display/cards/cards.gallery.d.ts +3 -3
  26. package/dist/lib/components/ui/display/count-badge/count-badge.vue.d.ts +1 -1
  27. package/dist/lib/components/ui/display/enrolment-secret/enrolment-secret.vue.d.ts +30 -0
  28. package/dist/lib/components/ui/display/enrolment-secret/index.d.ts +3 -0
  29. package/dist/lib/components/ui/display/index.d.ts +2 -0
  30. package/dist/lib/components/ui/display/recovery-codes/index.d.ts +5 -0
  31. package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-actions.vue.d.ts +11 -0
  32. package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-confirm.vue.d.ts +15 -0
  33. package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-list.vue.d.ts +7 -0
  34. package/dist/lib/components/ui/display/recovery-codes/recovery-codes.vue.d.ts +56 -0
  35. package/dist/lib/components/ui/display/recovery-codes/types.d.ts +23 -0
  36. package/dist/lib/components/ui/display/recovery-codes/use-recovery-codes-labels.d.ts +18 -0
  37. package/dist/lib/components/ui/feedback/alert/alert.vue.d.ts +1 -1
  38. package/dist/lib/components/ui/feedback/circular-progress/circular-progress.vue.d.ts +2 -2
  39. package/dist/lib/components/ui/feedback/spinner/spinner.variants.d.ts +1 -1
  40. package/dist/lib/components/ui/feedback/spinner/spinner.vue.d.ts +1 -1
  41. package/dist/lib/components/ui/fields/contacts/consts.d.ts +1 -1
  42. package/dist/lib/components/ui/fields/form-section/form-section-classes.d.ts +30 -0
  43. package/dist/lib/components/ui/fields/form-section/form-section.vue.d.ts +37 -0
  44. package/dist/lib/components/ui/fields/form-section/index.d.ts +3 -0
  45. package/dist/lib/components/ui/fields/index.d.ts +1 -0
  46. package/dist/lib/components/ui/fields/rating-field/rating-field.variants.d.ts +1 -1
  47. package/dist/lib/components/ui/fields/rating-field/rating-field.vue.d.ts +1 -1
  48. package/dist/lib/components/ui/forms/form-scope-schema.d.ts +8 -2
  49. package/dist/lib/components/ui/forms/index.d.ts +1 -0
  50. package/dist/lib/components/ui/forms/use-code-resolver.d.ts +45 -0
  51. package/dist/lib/components/ui/layout/app-shell/activity-content.vue.d.ts +1 -1
  52. package/dist/lib/components/ui/layout/auth-card/auth-card-notice.vue.d.ts +18 -0
  53. package/dist/lib/components/ui/layout/auth-card/auth-card.vue.d.ts +45 -0
  54. package/dist/lib/components/ui/layout/auth-card/index.d.ts +3 -0
  55. package/dist/lib/components/ui/layout/auth-card/types.d.ts +2 -0
  56. package/dist/lib/components/ui/layout/index.d.ts +1 -0
  57. package/dist/lib/components/ui/selection/autocomplete/autocomplete-items.d.ts +2 -0
  58. package/dist/lib/components/ui/selection/autocomplete/autocomplete-keyboard.d.ts +1 -0
  59. package/dist/lib/components/ui/selection/autocomplete/autocomplete.vue.d.ts +2 -0
  60. package/dist/lib/components/ui/selection/autocomplete/search-types.d.ts +4 -1
  61. package/dist/lib/composables/index.d.ts +2 -0
  62. package/dist/lib/composables/use-focus-on-mount.d.ts +23 -0
  63. package/dist/lib/composables/use-submit-once.d.ts +33 -0
  64. package/dist/lib/i18n/resources/en/en.d.ts +21 -0
  65. package/dist/lib/i18n/resources/en/material.json.d.ts +21 -0
  66. package/dist/lib/i18n/resources/fr/fr.d.ts +21 -0
  67. package/dist/lib/i18n/resources/fr/material.json.d.ts +21 -0
  68. package/dist/lib/i18n/resources/resources.d.ts +42 -0
  69. package/dist/lib/i18n/translator.d.ts +1 -1
  70. package/dist/lib/lib/index.d.ts +1 -0
  71. package/dist/lib/nuxt/auto-imports.d.ts +1 -1
  72. package/dist/lib/nuxt/component-names.d.ts +1 -1
  73. package/dist/lib.js +59 -104
  74. package/dist/main.js +17 -17
  75. package/dist/map.js +4 -4
  76. package/dist/material-vue.css +1 -1
  77. package/dist/{pdf-outline-sidebar-Bjz8jMaZ.js → pdf-outline-sidebar-DTb_hLjX.js} +2 -2
  78. package/dist/{pdf-page-BRtEfDpx.js → pdf-page-CCZtaTRB.js} +1 -1
  79. package/dist/{pdf-page-list-DUSFOt6b.js → pdf-page-list-D7ImAK7v.js} +2 -2
  80. package/dist/{pdf-reader-Dhuuu3ud.js → pdf-reader-CsLVHCV6.js} +8 -8
  81. package/dist/{pdf-sidebar-BOsuIcvK.js → pdf-sidebar-DlU0RgZL.js} +3 -3
  82. package/dist/{pdf-thumbnail-sidebar-CEinOvk3.js → pdf-thumbnail-sidebar-CNB7q0Mq.js} +3 -3
  83. package/dist/{pdf-toolbar-ZYITLhtl.js → pdf-toolbar-BtXcyEpd.js} +4 -4
  84. package/dist/pdf.js +2 -2
  85. package/dist/print-format.utils-keJqahvw.js +187 -0
  86. package/dist/{sortable-provider-BCawiL69.js → sortable-provider-DMu8DL2z.js} +2 -2
  87. package/dist/{stored-language-CGMzf-kR.js → stored-language-dtksFoGO.js} +42 -0
  88. package/dist/{stx-player-DAhHmRHW.js → stx-player-CqsfQlLp.js} +5 -5
  89. package/dist/{translator-DhrnT0YX.js → translator-DqiGrSH_.js} +1 -1
  90. package/dist/{use-heading-spy-BF4hsZLZ.js → use-heading-spy-BZ7ctGZ5.js} +58 -48
  91. package/dist/{use-material-translation-D30V5LyT.js → use-material-translation-DRB6oPdF.js} +3 -3
  92. package/docs/components/README.md +3 -3
  93. package/docs/components/data-display.md +112 -0
  94. package/docs/components/feedback-and-status.md +2 -0
  95. package/docs/components/form-fields.md +81 -0
  96. package/docs/components/navigation-and-layout.md +64 -0
  97. package/docs/components/overlays-and-menus.md +18 -0
  98. package/docs/components/selection-and-choice.md +12 -4
  99. package/docs/guides/forms.md +120 -0
  100. package/docs/guides/helpers.md +85 -0
  101. package/docs/guides/i18n.md +17 -0
  102. package/docs/guides/printing.md +27 -0
  103. package/docs/previews/data-display-enrolmentsecret.webp +0 -0
  104. package/docs/previews/data-display-recoverycodes.webp +0 -0
  105. package/docs/previews/form-fields-formsection.webp +0 -0
  106. package/docs/previews/navigation-layout-authcard.webp +0 -0
  107. package/docs/roadmap.md +15 -0
  108. package/docs/troubleshooting.md +131 -0
  109. package/lib/components/components.css +1 -0
  110. package/lib/components/print-only.css +44 -0
  111. package/lib/nuxt/auto-imports.ts +2 -0
  112. package/lib/nuxt/component-names.ts +4 -0
  113. package/package.json +2 -2
  114. package/dist/print-format.utils-DpBtUJvL.js +0 -138
@@ -26,7 +26,7 @@ Anything you press that is not a field: buttons, link buttons, copy, load more,
26
26
 
27
27
  ## [Form fields](form-fields.md)
28
28
 
29
- Labelled inputs with a value, each with its form-library wrapper, and the composite values (attributes, contacts, address, opening hours). 24 entries.
29
+ Labelled inputs with a value, each with its form-library wrapper, and the composite values (attributes, contacts, address, opening hours), and the titled sections a long form is laid out in. 25 entries.
30
30
 
31
31
  [<img src="../previews/form-fields-textfield.webp" height="72" alt="TextField">](form-fields.md#textfield)
32
32
  [<img src="../previews/form-fields-phonefield.webp" height="72" alt="PhoneField">](form-fields.md#phonefield)
@@ -53,7 +53,7 @@ Picking among options: select, combobox, autocomplete, checkbox, radio, card and
53
53
 
54
54
  ## [Data display](data-display.md)
55
55
 
56
- Read-only presentation: avatar, badge, chip, stat and metric cards, card presets, list tile, timeline, code, markdown, terminal, API docs blocks, QR and barcode. 24 entries.
56
+ Read-only presentation: avatar, badge, chip, stat and metric cards, card presets, list tile, timeline, code, markdown, terminal, API docs blocks, QR and barcode, two-step verification (enrolment secret, recovery codes). 26 entries.
57
57
 
58
58
  [<img src="../previews/data-display-avatar.webp" height="72" alt="Avatar">](data-display.md#avatar)
59
59
  [<img src="../previews/data-display-statcard.webp" height="72" alt="StatCard">](data-display.md#statcard)
@@ -71,7 +71,7 @@ Collections you query: data table, filter, pagination, tree, drag and drop. 6 en
71
71
 
72
72
  ## [Navigation & layout](navigation-and-layout.md)
73
73
 
74
- Where a page sits and how you move: app shell, sidebar, app bars, breadcrumb, page layouts, grid, resizable, skip link, pager, TOC, steppers, hero, doc site shell. 17 entries.
74
+ Where a page sits and how you move: app shell, sidebar, app bars, breadcrumb, page layouts, grid, resizable, skip link, pager, TOC, steppers, hero, doc site shell, signed-out page frame. 18 entries.
75
75
 
76
76
  [<img src="../previews/navigation-layout-appshell.webp" height="72" alt="ActivityLayout">](navigation-and-layout.md#activitylayout)
77
77
  [<img src="../previews/navigation-layout-sidebar.webp" height="72" alt="Sidebar">](navigation-and-layout.md#sidebar)
@@ -30,6 +30,8 @@ from `@nxgt/material-vue/components`.
30
30
  | <img src="../previews/data-display-paramtable.webp" width="96" alt=""> | [ParamTable](#paramtable) | Name / type / default / description table |
31
31
  | <img src="../previews/data-display-qrcode.webp" width="96" alt=""> | [QRCode](#qrcode) | QR code on a padded tile |
32
32
  | <img src="../previews/data-display-barecode.webp" width="96" alt=""> | [Barecode](#barecode) | 1D barcode |
33
+ | <img src="../previews/data-display-enrolmentsecret.webp" width="96" alt=""> | [EnrolmentSecret](#enrolmentsecret) | A TOTP enrolment: QR code, app link, the key grouped by four |
34
+ | <img src="../previews/data-display-recoverycodes.webp" width="96" alt=""> | [RecoveryCodes](#recoverycodes) | One-time recovery codes: copy, download, print, confirm |
33
35
 
34
36
  ## Avatar
35
37
 
@@ -935,3 +937,113 @@ The other props (`lineColor`, `background`, `margin`, `fontSize`, …) are
935
937
  JsBarcode's options.
936
938
 
937
939
  **Storybook:** `Data display/Barecode` · **React:** `Barecode`
940
+
941
+ ---
942
+
943
+ ## EnrolmentSecret
944
+
945
+ ![EnrolmentSecret](../previews/data-display-enrolmentsecret.webp)
946
+
947
+ A TOTP enrolment (two-step verification): the QR code of the `otpauth://` URI, the URI as a link — a phone cannot scan its own screen, and an authenticator app on it opens the link — and the secret in groups of four, to type or copy.
948
+
949
+ ```vue
950
+ <script setup lang="ts">
951
+ import { EnrolmentSecret } from '@nxgt/material-vue/components';
952
+ </script>
953
+
954
+ <template>
955
+ <EnrolmentSecret
956
+ uri="otpauth://totp/Acme:ada%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme"
957
+ secret="JBSWY3DPEHPK3PXP"
958
+ />
959
+ </template>
960
+ ```
961
+
962
+ | Prop | Type | Default | Effect |
963
+ | --- | --- | --- | --- |
964
+ | `uri` | `string` | — | the QR code's content and the link's `href`; the link renders only when it starts with `otpauth:` |
965
+ | `secret` | `string` | — | shown as `JBSW Y3DP EHPK 3PXP`; the copy button copies it without spaces |
966
+ | `showQrCode` | `boolean` | `true` | `false`: the link and the key alone |
967
+ | `qrSize` | `number` | `192` | the QR code's size in pixels (it shrinks to fit a narrower box) |
968
+ | `qrLabel`, `openAppLabel`, `manualLabel`, `secretLabel`, `copyLabel`, `copiedLabel` | `string` | translated | the QR code's name, the link, the line before the key, the key's group name, the copy button's name, what it announces |
969
+
970
+ The QR code sits beside the rest once the component is 28rem wide (a
971
+ container query), above it on a phone. The key is a `<code>` in a
972
+ `fieldset` named "Setup key" (a group may be named, a `code` element may
973
+ not) beside a `CopyButton`, which announces "Key copied" in a polite status
974
+ region; it wraps at the group boundaries, never past the card's edge.
975
+ `groupSecret(secret, size?)` (from `@nxgt/material-core/two-factor`) is
976
+ re-exported for a layout of your own.
977
+
978
+ **Storybook:** `Data display/EnrolmentSecret` · **React:** `EnrolmentSecret`
979
+
980
+ ---
981
+
982
+ ## RecoveryCodes
983
+
984
+ ![RecoveryCodes](../previews/data-display-recoverycodes.webp)
985
+
986
+ One-time recovery codes, shown once: read as a list, copied, downloaded as a text file, printed (the card alone), and an "I have saved these codes" confirmation before "Done".
987
+
988
+ ```vue
989
+ <script setup lang="ts">
990
+ import { ref } from 'vue';
991
+ import { useRouter } from 'vue-router';
992
+ import { RecoveryCodes } from '@nxgt/material-vue/components';
993
+
994
+ const props = defineProps<{ codes: string[]; email: string }>();
995
+ const router = useRouter();
996
+ const saved = ref(false);
997
+ </script>
998
+
999
+ <template>
1000
+ <RecoveryCodes
1001
+ v-model:saved="saved"
1002
+ :codes="props.codes"
1003
+ description="Use one of them to sign in if you lose your phone."
1004
+ :file-heading="`Recovery codes for ${props.email}`"
1005
+ @done="router.push('/settings/security')"
1006
+ />
1007
+ </template>
1008
+ ```
1009
+
1010
+ | Prop / slot | Type | Default | Effect |
1011
+ | --- | --- | --- | --- |
1012
+ | `codes` | `readonly string[]` | — | an ordered list named "Recovery codes" |
1013
+ | `title` / `#title`, `titleAs` | `string`, `'h2' \| 'h3' \| 'h4'` | "Recovery codes", `'h2'` | the card's heading |
1014
+ | `description` / `#description` | `string` / slot | — | a line under it |
1015
+ | `warning` / `#warning` | `string \| false` / slot | "Each code works once…" | an `Alert` above the codes, not printed; `false` hides it |
1016
+ | `focusTitle` | `boolean` | `true` | the heading takes the focus once shown: the codes replaced what the user acted on |
1017
+ | `fileName`, `fileHeading`, `fileNote` | `string` | `"recovery-codes.txt"`, the title, the warning | the downloaded file and its first two lines |
1018
+ | `onDone` (`@done`) | `() => void` | — | shows "Done", which calls it once the confirmation is ticked |
1019
+ | `v-model:saved` | `boolean` | uncontrolled | the confirmation |
1020
+ | `labels` | `Partial<RecoveryCodesLabels>` | translated | `list`, `copy`, `copied`, `copyFailed`, `download`, `print`, `saved`, `savedRequired`, `done` |
1021
+
1022
+ - **Copy** puts the codes on the clipboard one per line; "Codes copied" (or
1023
+ "Copy failed") is announced by the component's own polite status region,
1024
+ so it needs no `Toaster`.
1025
+ - **Download** saves `recoveryCodesText(codes, { heading, note })` with
1026
+ `downloadText` (both re-exported from `@nxgt/material-core/two-factor`).
1027
+ - **Print** prints the card and nothing else, with no inline style and no new
1028
+ window: `printOnly` (`@nxgt/material-vue/lib`) marks `<html data-nxgt-print>` and the card
1029
+ while the dialog is open, and the rules in `styles.css` hide the rest of the page in
1030
+ print. The buttons, the confirmation and the warning are not printed.
1031
+ - **Done** before the box is ticked moves the focus to the box, marks it
1032
+ `aria-invalid` and says "Tick the box once your codes are safe" under it.
1033
+ "Done" is shown only when `@done` (the `onDone` prop) is bound.
1034
+
1035
+ Leaving the page before the codes are saved is the app's to guard, with its
1036
+ router: refuse the navigation while `saved` is false, and ask the browser
1037
+ too:
1038
+
1039
+ ```ts
1040
+ import { onBeforeRouteLeave } from 'vue-router';
1041
+ import { useEventListener } from '@vueuse/core';
1042
+
1043
+ onBeforeRouteLeave(() => saved.value || window.confirm('Leave without saving your codes?'));
1044
+ useEventListener(window, 'beforeunload', (event) => {
1045
+ if (!saved.value) event.preventDefault();
1046
+ });
1047
+ ```
1048
+
1049
+ **Storybook:** `Data display/RecoveryCodes` · **React:** `RecoveryCodes` (`saved` / `onSavedChange`, `onDone`; props for the slots)
@@ -173,6 +173,8 @@ keeps focus off the Cancel, on the content: the page behind an alert dialog is
173
173
  task: focus it from a `setTimeout`, not in the handler.
174
174
 
175
175
  **Emits:** `update:open` on `AlertDialog` — `boolean`.
176
+ `AlertDialogContent` is capped to the viewport height and scrolls inside itself, like `DialogContent` ([Dialog](overlays-and-menus.md#dialog)).
177
+
176
178
  **Storybook:** `Feedback & status/AlertDialog` · **React:** `AlertDialog` (same parts)
177
179
 
178
180
  ---
@@ -11,6 +11,7 @@ value type each one writes. Everything is imported from
11
11
  | | Component | What it does |
12
12
  | --- | --- | --- |
13
13
  | <img src="../previews/form-fields-field.webp" width="96" alt=""> | [Field](#field) | Renders the field matching a config `type` |
14
+ | <img src="../previews/form-fields-formsection.webp" width="96" alt=""> | [FormSection](#formsection) | Titled section of a form, one column on a phone |
14
15
  | <img src="../previews/form-fields-textfield.webp" width="96" alt=""> | [TextField](#textfield) | Labelled input with adornments and helper text |
15
16
  | <img src="../previews/form-fields-textareafield.webp" width="96" alt=""> | [TextareaField](#textareafield) | Labelled textarea with row sizing and a counter |
16
17
  | <img src="../previews/form-fields-emailfield.webp" width="96" alt=""> | [EmailField](#emailfield) | Validates on blur, `mailto:` button |
@@ -90,6 +91,86 @@ with `useField` as above (the error message is then yours to pass as
90
91
 
91
92
  ---
92
93
 
94
+ ## FormSection
95
+
96
+ ![FormSection](../previews/form-fields-formsection.webp)
97
+
98
+ A titled section of a form: a heading (an `h2` by default), an optional
99
+ description and actions, then its fields in the form grid's columns; one
100
+ column on a phone.
101
+
102
+ ```vue
103
+ <script setup lang="ts">
104
+ import { useForm } from 'vee-validate';
105
+ import {
106
+ Button,
107
+ EmailFormField,
108
+ FormSection,
109
+ SwitchFormField,
110
+ TextFormField,
111
+ } from '@nxgt/material-vue/components';
112
+
113
+ const { handleSubmit } = useForm({
114
+ initialValues: { firstName: '', email: '', contact: '', digest: false },
115
+ });
116
+ const onSubmit = handleSubmit((values) => console.log(values));
117
+ </script>
118
+
119
+ <template>
120
+ <form class="form-grid-layout" @submit="onSubmit">
121
+ <div data-slot="form-fields">
122
+ <FormSection title="Profile">
123
+ <TextFormField name="firstName" label="First name" />
124
+ <EmailFormField name="email" label="Email" />
125
+ </FormSection>
126
+ <FormSection
127
+ title="Emergency contact"
128
+ description="Who we call when you cannot be reached."
129
+ >
130
+ <template #actions>
131
+ <Button variant="outlined" size="sm">Add another</Button>
132
+ </template>
133
+ <TextFormField name="contact" label="Name" />
134
+ </FormSection>
135
+ <FormSection title="Notifications">
136
+ <SwitchFormField name="digest" label="Digest" placeholder="Email me a weekly digest" />
137
+ </FormSection>
138
+ </div>
139
+ <div data-slot="form-actions">
140
+ <Button type="submit">Save</Button>
141
+ </div>
142
+ </form>
143
+ </template>
144
+ ```
145
+
146
+ | Prop / slot | Type | Default | Effect |
147
+ | --- | --- | --- | --- |
148
+ | `title` / `#title` | `string` / slot | — | the section's heading, which names the group; the slot wins over the prop |
149
+ | `title-as` | `'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6'` | `'h2'` | the heading level: `'h3'` when the form already sits under an `h2` |
150
+ | `description` / `#description` | `string` / slot | — | a muted line under the title; the group's accessible description |
151
+ | `#actions` | slot | — | controls beside the title, wrapping under it on a phone |
152
+ | `separated` | `boolean` | `true` | a rule above the section when it follows another `FormSection` |
153
+ | default slot | slot | — | the section's fields |
154
+
155
+ The section is a `role="group"` named by its heading (`aria-labelledby`) and
156
+ described by its description (`aria-describedby`), so a screen reader
157
+ announces "Profile, group" on entering it, and a test finds it with
158
+ `getByRole('group', { name: 'Profile' })`. It is not a `<fieldset>`: a
159
+ fieldset would be one cell of the form's grid, and its fields would not take
160
+ the grid's columns. Inside `.form-grid-layout`'s `[data-slot="form-fields"]`
161
+ it spans the whole row and lays its fields out in the same columns the form
162
+ grid uses, by the section's own width (it is a container query container, so
163
+ it works outside a form grid too). The form grid's wide-field rules still
164
+ apply inside it: a switch, a textarea, chips, files and the rich text editor
165
+ take a row of their own. The rule between sections is a top border drawn on
166
+ each section that follows another; the first has none. The heading is small,
167
+ semibold, uppercase and muted, set apart from the fields' labels, and reads
168
+ 4.5:1 in both themes.
169
+
170
+ **Storybook:** `Form fields/FormSection` · **React:** `FormSection`
171
+
172
+ ---
173
+
93
174
  ## TextField
94
175
 
95
176
  ![TextField](../previews/form-fields-textfield.webp)
@@ -8,6 +8,7 @@ documentation-site shell. Everything is imported from
8
8
  | | Component | What it does |
9
9
  | --- | --- | --- |
10
10
  | <img src="../previews/navigation-layout-appshell.webp" width="96" alt=""> | [ActivityLayout](#activitylayout) | App shell: sidebar, header, breadcrumbs, titled page |
11
+ | <img src="../previews/navigation-layout-authcard.webp" width="96" alt=""> | [AuthCard](#authcard) | Signed-out page frame: one `main`, a centred card, its title the `h1` |
11
12
  | <img src="../previews/navigation-layout-sidebar.webp" width="96" alt=""> | [Sidebar](#sidebar) | Collapsible navigation sidebar |
12
13
  | <img src="../previews/navigation-layout-topappbar.webp" width="96" alt=""> | [TopAppBar, BottomAppBar](#topappbar) | Top and bottom bars |
13
14
  | <img src="../previews/navigation-layout-breadcrumb.webp" width="96" alt=""> | [Breadcrumb](#breadcrumb) | Breadcrumb primitives |
@@ -98,6 +99,69 @@ pass, collapsed or expanded.
98
99
 
99
100
  ---
100
101
 
102
+ ## AuthCard
103
+
104
+ ![AuthCard](../previews/navigation-layout-authcard.webp)
105
+
106
+ The frame of a signed-out page (sign in, sign up, a reset link): one `main`, a centred card whose title is the page's `h1`, a row above it for the brand and a toolbar, padded clear of a phone's notch and home bar.
107
+
108
+ ```vue
109
+ <script setup lang="ts">
110
+ import {
111
+ AuthCard,
112
+ Button,
113
+ EmailField,
114
+ LanguageSwitcher,
115
+ PasswordField,
116
+ } from '@nxgt/material-vue/components';
117
+
118
+ defineProps<{ expired?: boolean }>();
119
+ const signIn = () => {};
120
+ </script>
121
+
122
+ <template>
123
+ <AuthCard
124
+ title="Sign in"
125
+ description="Welcome back."
126
+ :notice="expired ? 'This link has expired.' : undefined"
127
+ notice-tone="error"
128
+ >
129
+ <template #brand><img src="/logo.svg" alt="Acme" height="24" /></template>
130
+ <template #toolbar><LanguageSwitcher /></template>
131
+ <template #footer><a href="/terms">Terms</a></template>
132
+ <form class="flex flex-col gap-4" @submit.prevent="signIn">
133
+ <EmailField label="Email" name="email" autocomplete="email" />
134
+ <PasswordField label="Password" name="password" autocomplete="current-password" />
135
+ <Button type="submit">Sign in</Button>
136
+ </form>
137
+ </AuthCard>
138
+ </template>
139
+ ```
140
+
141
+ | Prop / slot | Type | Default | Effect |
142
+ | --- | --- | --- | --- |
143
+ | `title` / `#title` | `string` / slot | — | the page's `h1`, in the card's title |
144
+ | `description` / `#description` | `string` / slot | — | a line under the title |
145
+ | `notice` / `#notice` | `string` / slot | — | an `Alert` above the form: an expired link, a signed-out message |
146
+ | `noticeTone` | `'info' \| 'success' \| 'warning' \| 'error'` | `'info'` | its colour; `'error'` is `role="alert"`, the others `role="status"` |
147
+ | `focusTitle` | `boolean` | `false` | the `h1` takes the focus once shown (`useFocusOnMount`): for an end state that replaced the form ("Check your inbox") |
148
+ | `#brand`, `#toolbar` | slots | — | the row above the card, left and right; it renders only when one is given |
149
+ | `#banner` | slot | — | above that row (an update notice) |
150
+ | `#footer` | slot | — | under the card |
151
+
152
+ A non-error notice goes into a `role="status"` region that is always
153
+ rendered (zero height while empty), so a screen reader announces a notice
154
+ that appears later; an error is a `role="alert"` inserted when it shows.
155
+
156
+ The `main` has `tabindex="-1"`: never reached by Tab, but what replaced a
157
+ control the user acted on (a dismissed banner) can hand it the focus. Its
158
+ padding is `max(1rem, env(safe-area-inset-*))` on each side, as classes: no
159
+ inline style. Other attributes reach the `main`.
160
+
161
+ **Storybook:** `Navigation & layout/AuthCard` · **React:** `AuthCard` (props `brand`, `toolbar`, `banner`, `footer` for the slots)
162
+
163
+ ---
164
+
101
165
  ## Sidebar
102
166
 
103
167
  ![Sidebar](../previews/navigation-layout-sidebar.webp)
@@ -96,6 +96,21 @@ overlay, for a dialog taller than the screen).
96
96
  Unlike React, `DialogContent` renders the X by itself (turn it off with
97
97
  `:show-close-button="false"`), and `DialogTrigger` is not `as-child` by default.
98
98
 
99
+ `DialogContent` and `AlertDialogContent` are capped at `100dvh - 2rem` (`100vh`
100
+ where `dvh` is unsupported) and scroll inside themselves, so a long body never
101
+ pushes the title off-screen on a phone. The built-in X scrolls with the content. On
102
+ browsers with `dvh` support, your own `max-h-*` through `class` wins over the
103
+ cap. To remove the cap and the scroll, pass `class="max-h-none overflow-visible"`:
104
+
105
+ ```vue
106
+ <DialogContent class="max-h-none overflow-visible">
107
+ ```
108
+
109
+ Content wider than the dialog now scrolls horizontally or is clipped
110
+ (`overflow-visible` restores the old behaviour). `DialogScrollContent` is for a
111
+ dialog that should grow taller than the screen and scroll inside the overlay
112
+ instead of capping; it does not pin the header or footer.
113
+
99
114
  On open, `DialogContent` moves focus into the dialog: to its first control
100
115
  (the built-in X when there is nothing before it), or to the content itself
101
116
  when it has none. An `@open-auto-focus` that calls `event.preventDefault()`
@@ -165,6 +180,9 @@ Parts: `Sheet`, `SheetTrigger`, `SheetContent`, `SheetHeader`, `SheetTitle`,
165
180
  | `side` (`SheetContent`) | `'top' \| 'right' \| 'bottom' \| 'left'` | `'right'` | the edge it slides from; left / right are ¾ wide, capped at 384 px |
166
181
  | `default-open` / `v-model:open`, `modal` (`Sheet`) | as `Dialog` | — | a sheet is a Reka dialog |
167
182
 
183
+ `SheetContent` scrolls inside itself, and a top or bottom sheet is capped at
184
+ `100dvh`. Override the cap with `max-h-*` through `class`.
185
+
168
186
  `SheetContent` always renders a top-right X, named "Close"
169
187
  (`material.buttons.close`, translated); there is no `withCloseButton` prop in
170
188
  Vue.
@@ -263,19 +263,19 @@ const picked = ref<string[]>(['nuxt']);
263
263
  | --- | --- | --- | --- |
264
264
  | `mode` | `'single' \| 'multiple'` | `'single'` | decides the `v-model` type |
265
265
  | `v-model` | `string \| string[] \| null` | `null` | per `mode` |
266
- | `options` | `T[]`, `T extends { value: string; label: string }` | `[]` | the items, typed by your own option type; with `on-search`, the list before a search and the selected options' labels |
266
+ | `options` | `T[]`, `T extends { value: string; label: string; disabled?: boolean }` | `[]` | the items (`disabled` on one: listed, announced as disabled, never picked), typed by your own option type; with `on-search`, the list before a search and the selected options' labels |
267
267
  | `get-option-value` | `(option: T) => string` | `option.value` | the value an option stands for |
268
268
  | `get-option-label` | `(option: T) => string` | `option.label` | the text it shows and is searched by |
269
269
  | `filter` | `'accent-insensitive' \| 'none' \| (option: T, query: string) => boolean` | `'accent-insensitive'`, `'none'` with `on-search` | how the list is filtered as the user types: label or value, ignoring case and accents (`e` finds "École") in the provider's language; `'none'` shows `options` as given; a function gets the trimmed query, never an empty one |
270
270
  | `free-solo` | `boolean` | `false` | accept a typed value that is not an option ("Add …") |
271
271
  | `free-solo-label` | `(query: string) => string` | `Add "{query}"`, translated | the free-solo button's text |
272
272
  | `@search` (prop `on-search`) | `(query: string, { signal }: { signal: AbortSignal }) => Promise<T[]>` | — | server search: called once typing pauses; its results become the list. Each search aborts the previous one, and a response that lands after a newer one is dropped |
273
- | `@search-error` | `(error: unknown) => void` | — | `on-search` rejected (an abort is not a rejection) |
273
+ | `@search-error` | `(error: unknown) => void` | — | `on-search` rejected (an abort is not a rejection); pass the reason back as `search-error` |
274
274
  | `min-query-length` | `number` | `1` | `on-search` waits for this many characters; below it the list says "Type at least N characters to search." |
275
275
  | `debounce-ms` | `number` | `250` | the pause before `on-search` and `@query-change` run |
276
276
  | `@query-change` | `(query: string) => void` | — | the typed text once typing pauses — fetch `options` yourself here |
277
277
  | `loading` | `boolean` | — | with `@query-change`: "Loading…" in the list, `aria-busy` on it, announced |
278
- | `search-error` | `string` | — | with `@query-change`: shown and announced in place of the options |
278
+ | `search-error` | `string` | — | shown and announced in place of the options; with `on-search` it replaces the generic "The search failed. Try again." |
279
279
  | `empty` | `string` | "No option found." | text when nothing matches |
280
280
  | `required` | `boolean` | — | the asterisk, and `aria-required` on the input, which the label names |
281
281
  | `error` | `boolean` | — | red border on the box, error helper text, `aria-invalid` on the input |
@@ -321,7 +321,10 @@ const person = ref<string | null>(null);
321
321
  With `on-search`, `min-query-length` defaults to 1, `debounce-ms` to 250 and `filter` to `'none'`
322
322
  (the server filters). A rejected search shows and announces "The search
323
323
  failed. Try again." by itself and hands the error to `@search-error`; an abort is
324
- never an error. Type your search with `AutocompleteSearch<T>` (its second
324
+ never an error. To say why instead (the API's own refusal, "the service is
325
+ unreachable"), keep the reason in your state from `@search-error` and pass it as
326
+ `search-error`: it takes precedence over the generic text, in the list and in the
327
+ live region. Type your search with `AutocompleteSearch<T>` (its second
325
328
  argument is `AutocompleteSearchContext`, `{ signal }`); `AutocompleteFilter<T>`
326
329
  and `AutocompleteSearchProps<T>` are exported from `@nxgt/material-vue/components` too.
327
330
  `filterAutocompleteOptions(options, query, locale?)`, exported beside the
@@ -332,6 +335,11 @@ To fetch it yourself instead, keep `options` and `loading` in your state,
332
335
  update them on `@query-change`, and pass `filter="none"` when the server
333
336
  already filtered.
334
337
 
338
+ A disabled option (`disabled: true` on it, in `options` or in what `on-search`
339
+ returns) is greyed out, carries `aria-disabled` and a native `disabled`, so Tab
340
+ skips it, and is never picked, by click or by Enter (which picks the first
341
+ enabled option), in `single` and `multiple` mode.
342
+
335
343
  **Accessibility.** A polite live region (`role="status"`) says "Loading…",
336
344
  then "3 results" or "No results" (or the failure) once the search answers or
337
345
  typing pauses — never on every keystroke. The list is `aria-busy` while
@@ -259,6 +259,40 @@ values from `z.input` and the submitted values from `z.output`. The package's
259
259
  own built-in forms (the attribute editor, for one) use the same shape
260
260
  internally.
261
261
 
262
+ ## A long form in sections
263
+
264
+ A long form reads better in titled sections. Lay it out in the form grid
265
+ (`.form-grid-layout`, its fields in `[data-slot="form-fields"]`, its buttons
266
+ in `[data-slot="form-actions"]`) and put each group of fields in a
267
+ [`FormSection`](../components/form-fields.md#formsection):
268
+
269
+ ```vue
270
+ <template>
271
+ <form class="form-grid-layout" @submit="onSubmit">
272
+ <div data-slot="form-fields">
273
+ <FormSection title="Profile">
274
+ <TextFormField name="firstName" label="First name" />
275
+ <TextFormField name="lastName" label="Last name" />
276
+ </FormSection>
277
+ <FormSection title="Notifications" description="You can change these at any time.">
278
+ <SwitchFormField name="digest" label="Digest" placeholder="Email me a weekly digest" />
279
+ </FormSection>
280
+ </div>
281
+ <div data-slot="form-actions">
282
+ <Button type="submit" :loading="isSubmitting">Save</Button>
283
+ </div>
284
+ </form>
285
+ </template>
286
+ ```
287
+
288
+ - Each section's title is an `h2` (pass `title-as="h3"` when the form sits
289
+ under an `h2` of your own), so a screen reader moves from section to
290
+ section, and each section is a group named by its title.
291
+ - The sections take a whole row of the grid and lay their fields out in the
292
+ grid's columns, one column on a phone; switches, textareas, chips and file
293
+ fields still take a row of their own. A rule separates each section from the
294
+ one before it (`:separated="false"` drops it).
295
+
262
296
  ## Validation messages
263
297
 
264
298
  `zodLocaleError()` from `@nxgt/material-vue/lib` is a Zod v4 error map that
@@ -337,6 +371,92 @@ How it picks the language, and what that implies:
337
371
  with no string of its own (a map, a set) gets
338
372
  `This value has the wrong type.`
339
373
 
374
+ ### Wording errors by code
375
+
376
+ `zodLocaleError` words zod's own issues; a rule of your own — a name already
377
+ taken, a code the API sends back — has words only your app knows.
378
+ `useCodeResolver(schema, options?)` (`@nxgt/material-vue/components`)
379
+ returns a vee-validate typed schema that words both in the page's language:
380
+ a refinement carrying a code in `params.code` reads as your translator's
381
+ words for that code, every other issue as `zodLocaleError`'s. The language
382
+ is `I18nProvider`'s (or the `language` option: a value, ref or getter).
383
+
384
+ It is a `ComputedRef<TypedSchema>`: pass it as it is to
385
+ `useForm({ validationSchema })`, which accepts a ref and, when it changes,
386
+ re-validates the fields already validated — so a language switch re-words
387
+ the errors on screen.
388
+
389
+ ```ts
390
+ // i18n.ts — the app's translator, over the package's bundle
391
+ import { createTranslator, mergeResources, resources } from '@nxgt/material-vue/i18n';
392
+
393
+ export const appResources = mergeResources(resources, {
394
+ en: { errors: { taken: 'This name is already taken.', tooShort: 'At least {min} characters.' } },
395
+ fr: { errors: { taken: 'Ce nom est déjà pris.', tooShort: 'Au moins {min} caractères.' } },
396
+ });
397
+ export const translate = createTranslator<string>(appResources);
398
+ ```
399
+
400
+ ```vue
401
+ <!-- sign-up-form.vue -->
402
+ <script setup lang="ts">
403
+ import { useForm } from 'vee-validate';
404
+ import { z } from 'zod';
405
+ import { Button, TextFormField, useCodeResolver } from '@nxgt/material-vue/components';
406
+ import { translate } from './i18n';
407
+
408
+ const props = defineProps<{ save: (values: z.output<typeof schema>) => Promise<void> }>();
409
+
410
+ const TAKEN = ['admin', 'root'];
411
+
412
+ const schema = z.object({
413
+ // Worded by material's zod messages: "Invalid email address."
414
+ email: z.email(),
415
+ username: z
416
+ .string()
417
+ .refine((name) => name.length >= 3, { params: { code: 'errors.tooShort', min: 3 } })
418
+ .refine((name) => !TAKEN.includes(name), { params: { code: 'errors.taken' } }),
419
+ });
420
+
421
+ const validationSchema = useCodeResolver(schema, { translate });
422
+ const { handleSubmit } = useForm({
423
+ validationSchema,
424
+ initialValues: { email: '', username: '' },
425
+ });
426
+ const onSubmit = handleSubmit((values) => props.save(values));
427
+ </script>
428
+
429
+ <template>
430
+ <form novalidate @submit="onSubmit">
431
+ <TextFormField name="email" label="Email" />
432
+ <TextFormField name="username" label="Username" />
433
+ <Button type="submit">Sign up</Button>
434
+ </form>
435
+ </template>
436
+ ```
437
+
438
+ - **The codes.** `translate(code, params, language)`: `params` is the
439
+ refinement's whole `params`, so `{ code, min: 3 }` fills `{min}`. A key
440
+ your translator returns verbatim (what `createTranslator` does with a key
441
+ it lacks) has no words: the issue falls back to `zodLocaleError`.
442
+ - **The rest.** The same `translate` is handed to `zodLocaleError`, so your
443
+ `material.zod.*` overrides apply. Without `translate`, every issue is
444
+ worded by the package's own messages.
445
+ - **Another code.** `codeOf` picks the code an issue carries. Its default is
446
+ `defaultCodeOf` (a custom issue's `params.code`); to word every empty
447
+ required field by one code of yours, use `isEmptyRequired` (both, and
448
+ the type `CodeIssue`, are exported beside `useCodeResolver` from
449
+ `@nxgt/material-vue/components`, and from `@nxgt/material-core/code-errors`):
450
+ `codeOf: (issue) => (isEmptyRequired(issue) ? 'errors.required' : defaultCodeOf(issue))`.
451
+ - Issues map to vee-validate's errors by field path as `FormScope` maps a
452
+ Standard Schema's (`contacts[0].value`).
453
+ - Outside a composable, `createCodeErrorMap({ translate, codeOf?, fallback })`
454
+ (also exported beside `useCodeResolver`) is the error map itself, for
455
+ `schema.safeParse(values, { error })`.
456
+
457
+ Storybook: `Guides/Forms/useCodeResolver`. React: the same name in
458
+ `@nxgt/material/components`, returning a react-hook-form resolver.
459
+
340
460
  ## What each field emits
341
461
 
342
462
  The value a wrapper writes into the form, read from each field's
@@ -18,6 +18,7 @@ differences are listed under each table. React's `/hooks` is
18
18
  | --- | --- |
19
19
  | `cn(...classes)` | `clsx` + `tailwind-merge`: joins conditional classes, last Tailwind utility wins. |
20
20
  | `getCspNonce(doc?)`, `setCspNonce(nonce)` | The page's Content Security Policy nonce, from `@nxgt/material-core/csp-nonce`: `getCspNonce` reads what `setCspNonce` kept, else the `csp-nonce` meta tag, a `<script nonce>` or `<style nonce>`, else `__webpack_nonce__`; `undefined` on a server, where `setCspNonce` does nothing. See [CSP](csp.md#nonceprovider). |
21
+ | `printOnly(element, name?)`, `PRINT_ATTRIBUTE`, `PRINT_TARGET_ATTRIBUTE` | Opens the print dialog for one element of the page instead of the whole page (the marks are removed once printing is over), from `@nxgt/material-core/print-only`; the `@media print` rules are in the package's `styles.css`. See [printing](printing.md#printing-one-element-of-the-page). |
21
22
  | `clientOnly(component)` | Wraps a component so it renders nothing on the server and on the first client render, and mounts only after `onMounted`. Pair it with `defineAsyncComponent(() => import(…))`. See [SSR](ssr.md). |
22
23
  | `renderSlot(slot, context)`, `ContentSlot<T>` | Resolves a `ContentSlot`: a `VNode`, a string or number, or a `(context) => VNode \| string \| number`. |
23
24
  | `formatAmount(value, currency?, { locale?, fractionDigits? })`, `formatDate(value, { locale?, timeZone?, dateStyle?, timeStyle? })` | An amount in its currency's own decimals (two without a currency) and a medium date, in the runtime's locale unless you pass one; a date-only `YYYY-MM-DD` keeps its day in every time zone; `'—'` when empty. Also on `/components`. See [printing](printing.md#formatamount-and-formatdate). |
@@ -207,6 +208,9 @@ they are auto-imported.
207
208
  | `useSearchParam(name)` | `ComputedRef<string \| null>` of one query parameter (`vue-router`; the first one when repeated). |
208
209
  | `useNavigationHandler(to, { replace? })`, `useNavigationHandlers(routes)` | Click handlers that push or replace a route (`vue-router`). |
209
210
  | `useAllowedTransitions(status, transitions, configs)` | Status-machine actions (icon, colour, label, dialog copy) allowed from the current status. Plain array, computed once per call. |
211
+ | `useFocusOnMount<T>(focus?)` | A `ShallowRef` to bind with `ref="…"`: its element takes focus once mounted, and again whenever `focus` (a value, ref or getter; `true` by default) turns true. Nothing runs on a server. See [below](#usefocusonmount). |
212
+ | `useSubmitOnce(busy?)` | One submit in flight: wraps a submit handler so a double click or a second Enter is dropped until the request ends (the handler's promise, or `busy` — a ref or getter — back to false). See [below](#usesubmitonce). |
213
+ | `createSubmitGuard()`, `busySettler(guard)` | The framework-free guard behind `useSubmitOnce` (`@nxgt/material-core/submit-guard`): `{ once(run, { settleSync? }), settle(run?), pendingRun() }`, and the function that settles it from a busy flag. |
210
214
 
211
215
  ```vue
212
216
  <script setup lang="ts">
@@ -246,6 +250,87 @@ it follows changes **inside** the array you passed, not a new array. When the
246
250
  list is a prop the parent replaces, write the `computed` over `props.x`
247
251
  yourself, as above.
248
252
 
253
+ ### `useFocusOnMount`
254
+
255
+ When what the user acted on is replaced — a wizard's next step, an end state
256
+ such as "Message sent" — the button that had focus is gone, and focus falls
257
+ back to the top of the page. Bind the ref to the new content's heading: it
258
+ takes focus once mounted, a screen reader reads it, and Tab goes on from
259
+ there. A heading is not focusable by itself: give it `tabindex="-1"` (a
260
+ button, a link or a field needs nothing).
261
+
262
+ ```vue
263
+ <script setup lang="ts">
264
+ import { useFocusOnMount } from '@nxgt/material-vue/composables';
265
+
266
+ defineProps<{ email: string }>();
267
+ const title = useFocusOnMount<HTMLHeadingElement>();
268
+ </script>
269
+
270
+ <template>
271
+ <section aria-labelledby="sent-title">
272
+ <h2 id="sent-title" ref="title" tabindex="-1">Message sent</h2>
273
+ <p>We replied to {{ email }}.</p>
274
+ </section>
275
+ </template>
276
+ ```
277
+
278
+ `useFocusOnMount(() => open.value)` focuses again whenever `open` turns true
279
+ (after the DOM update), for content that stays mounted and is only shown.
280
+ Storybook: `Guides/Hooks/useFocusOnMount`.
281
+
282
+ ### `useSubmitOnce`
283
+
284
+ A disabled or `loading` button only stops clicks once it has rendered, and
285
+ never stops Enter in a field: a fast double click, or Enter twice, posts the
286
+ form twice — and a second request can abort the first mid-call. The
287
+ composable returns a wrapper that lets one call through until the request
288
+ ends; a dropped call's event has its default prevented, so it never falls
289
+ back to the browser's own submission.
290
+
291
+ ```vue
292
+ <script setup lang="ts">
293
+ import { useForm } from 'vee-validate';
294
+ import { Button, TextFormField } from '@nxgt/material-vue/components';
295
+ import { useSubmitOnce } from '@nxgt/material-vue/composables';
296
+
297
+ type Values = { email: string };
298
+ const props = defineProps<{ save: (values: Values) => Promise<void> }>();
299
+
300
+ const { handleSubmit, isSubmitting } = useForm<Values>({ initialValues: { email: '' } });
301
+ const submitOnce = useSubmitOnce();
302
+ // handleSubmit(fn) returns a function whose call returns a promise: the
303
+ // guard opens again when `save` ends.
304
+ const onSubmit = submitOnce(handleSubmit((values) => props.save(values)));
305
+ </script>
306
+
307
+ <template>
308
+ <form @submit="onSubmit">
309
+ <TextFormField name="email" label="Email" />
310
+ <Button type="submit" :loading="isSubmitting">Subscribe</Button>
311
+ </form>
312
+ </template>
313
+ ```
314
+
315
+ **Return the request's promise, or pass `busy`.** The guard opens again:
316
+
317
+ - when the handler's promise ends, fulfilled or rejected (a rejection still
318
+ reaches the caller);
319
+ - with `busy` passed (a ref or a getter: `useSubmitOnce(() => pending.value)`),
320
+ once it was true and is false again. It settles the submit that was on its
321
+ way when `busy` rose, never a newer one, and a handler that returns no
322
+ promise waits for it;
323
+ - without `busy`, as soon as a handler returns something that is not a
324
+ promise: one that returned early (`if (!valid) return;`) or did its work
325
+ synchronously does not lock the form.
326
+
327
+ Outside a component, `createSubmitGuard()` from
328
+ `@nxgt/material-core/submit-guard` (also re-exported here) is the same guard:
329
+ `once(run)` wraps a handler, `settle()` opens it by hand, and
330
+ `busySettler(guard)` returns the function to call with each value of a busy
331
+ flag. Storybook:
332
+ `Guides/Hooks/useSubmitOnce`.
333
+
249
334
  <a id="nxgtmateriali18n"></a>
250
335
 
251
336
  ## `@nxgt/material-vue/i18n`