@nxgt/material-vue 0.34.0 → 0.36.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.
- package/CHANGELOG.md +102 -0
- package/README.md +11 -5
- package/dist/{Button-DEA5W6O3.js → Button-CurFF4Zt.js} +1 -1
- package/dist/{HoverCardTrigger-CjSteFgk.js → HoverCardTrigger-C3r0oc5d.js} +2 -2
- package/dist/{camera-C52IuvMH.js → camera-CaQlF4JJ.js} +4 -4
- package/dist/components/components.css +1 -0
- package/dist/components/print-only.css +44 -0
- package/dist/{components-DFEmacXJ.js → components-D56FEfTa.js} +23597 -23041
- package/dist/components.js +8 -8
- package/dist/composables.js +35 -25
- package/dist/dnd.js +1 -1
- package/dist/{form-scope-CaYs-o2w.js → form-scope-DOL9MJL3.js} +1 -1
- package/dist/{i18n-provider-CiVi05fs.js → i18n-provider-B2q6vp6H.js} +1 -1
- package/dist/i18n.js +4 -4
- package/dist/{icon-button-CWcOcNHr.js → icon-button-CIUc1bEp.js} +1 -1
- package/dist/lib/components/index.d.ts +1 -0
- package/dist/lib/components/ui/actions/button/Button.vue.d.ts +1 -1
- package/dist/lib/components/ui/actions/button/button.variants.d.ts +1 -1
- package/dist/lib/components/ui/actions/copy-button/copy-button.vue.d.ts +1 -1
- package/dist/lib/components/ui/actions/copy-button/use-copy-status.d.ts +14 -0
- package/dist/lib/components/ui/actions/icon-button/icon-button.variants.d.ts +1 -1
- package/dist/lib/components/ui/actions/icon-button/icon-button.vue.d.ts +1 -1
- package/dist/lib/components/ui/actions/scroll-to-top/scroll-to-top.vue.d.ts +1 -1
- package/dist/lib/components/ui/display/badge/index.d.ts +1 -1
- package/dist/lib/components/ui/display/cards/cards.gallery.d.ts +3 -3
- package/dist/lib/components/ui/display/chip/chip.utils.d.ts +9 -0
- package/dist/lib/components/ui/display/chip/chip.vue.d.ts +29 -16
- package/dist/lib/components/ui/display/chip/index.d.ts +1 -0
- package/dist/lib/components/ui/display/count-badge/count-badge.vue.d.ts +1 -1
- package/dist/lib/components/ui/display/enrolment-secret/enrolment-secret.vue.d.ts +30 -0
- package/dist/lib/components/ui/display/enrolment-secret/index.d.ts +3 -0
- package/dist/lib/components/ui/display/index.d.ts +2 -0
- package/dist/lib/components/ui/display/recovery-codes/index.d.ts +5 -0
- package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-actions.vue.d.ts +11 -0
- package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-confirm.vue.d.ts +15 -0
- package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-list.vue.d.ts +7 -0
- package/dist/lib/components/ui/display/recovery-codes/recovery-codes.vue.d.ts +56 -0
- package/dist/lib/components/ui/display/recovery-codes/types.d.ts +23 -0
- package/dist/lib/components/ui/display/recovery-codes/use-recovery-codes-labels.d.ts +18 -0
- package/dist/lib/components/ui/feedback/alert/alert.vue.d.ts +1 -1
- package/dist/lib/components/ui/feedback/circular-progress/circular-progress.vue.d.ts +2 -2
- package/dist/lib/components/ui/feedback/spinner/spinner.variants.d.ts +1 -1
- package/dist/lib/components/ui/feedback/spinner/spinner.vue.d.ts +1 -1
- package/dist/lib/components/ui/fields/contacts/consts.d.ts +1 -1
- package/dist/lib/components/ui/fields/form-section/form-section-classes.d.ts +30 -0
- package/dist/lib/components/ui/fields/form-section/form-section.vue.d.ts +37 -0
- package/dist/lib/components/ui/fields/form-section/index.d.ts +3 -0
- package/dist/lib/components/ui/fields/index.d.ts +1 -0
- package/dist/lib/components/ui/fields/rating-field/rating-field.variants.d.ts +1 -1
- package/dist/lib/components/ui/fields/rating-field/rating-field.vue.d.ts +1 -1
- package/dist/lib/components/ui/forms/form-scope-schema.d.ts +8 -2
- package/dist/lib/components/ui/forms/index.d.ts +1 -0
- package/dist/lib/components/ui/forms/use-code-resolver.d.ts +45 -0
- package/dist/lib/components/ui/layout/app-shell/activity-content.vue.d.ts +1 -1
- package/dist/lib/components/ui/layout/auth-card/auth-card-notice.vue.d.ts +18 -0
- package/dist/lib/components/ui/layout/auth-card/auth-card.vue.d.ts +45 -0
- package/dist/lib/components/ui/layout/auth-card/index.d.ts +3 -0
- package/dist/lib/components/ui/layout/auth-card/types.d.ts +2 -0
- package/dist/lib/components/ui/layout/index.d.ts +1 -0
- package/dist/lib/components/ui/selection/select-field/select-field.vue.d.ts +2 -0
- package/dist/lib/composables/index.d.ts +2 -0
- package/dist/lib/composables/use-focus-on-mount.d.ts +23 -0
- package/dist/lib/composables/use-submit-once.d.ts +33 -0
- package/dist/lib/i18n/resources/en/en.d.ts +21 -0
- package/dist/lib/i18n/resources/en/material.json.d.ts +22 -1
- package/dist/lib/i18n/resources/fr/fr.d.ts +21 -0
- package/dist/lib/i18n/resources/fr/material.json.d.ts +22 -1
- package/dist/lib/i18n/resources/resources.d.ts +42 -0
- package/dist/lib/i18n/translator.d.ts +1 -1
- package/dist/lib/lib/index.d.ts +1 -0
- package/dist/lib/nuxt/auto-imports.d.ts +1 -1
- package/dist/lib/nuxt/component-names.d.ts +1 -1
- package/dist/lib.js +59 -104
- package/dist/main.js +17 -17
- package/dist/map.js +4 -4
- package/dist/material-vue.css +1 -1
- package/dist/{pdf-outline-sidebar-C_iJ8gqD.js → pdf-outline-sidebar-Gmdd3RNG.js} +2 -2
- package/dist/{pdf-page-DOiJlWQn.js → pdf-page-C3XhLcrp.js} +1 -1
- package/dist/{pdf-page-list-BTmUd7Ga.js → pdf-page-list-BTIb5Y-Y.js} +2 -2
- package/dist/{pdf-reader-CEMMLJnB.js → pdf-reader-CX2owreo.js} +8 -8
- package/dist/{pdf-sidebar-KLd76E5S.js → pdf-sidebar-BmAJ8Q_m.js} +3 -3
- package/dist/{pdf-thumbnail-sidebar-B4JkT_LV.js → pdf-thumbnail-sidebar-C04OeZ9M.js} +3 -3
- package/dist/{pdf-toolbar-Da6UMt1O.js → pdf-toolbar-zI0_eNXV.js} +4 -4
- package/dist/pdf.js +2 -2
- package/dist/print-format.utils-keJqahvw.js +187 -0
- package/dist/{sortable-provider-Cvpqk8tp.js → sortable-provider-DMu8DL2z.js} +2 -2
- package/dist/{stored-language-GDRdeYD8.js → stored-language-dtksFoGO.js} +44 -2
- package/dist/{stx-player-B6-PSgzP.js → stx-player-BUo2Vs0j.js} +5 -5
- package/dist/{translator-BDFEmlcL.js → translator-DqiGrSH_.js} +1 -1
- package/dist/{use-heading-spy-ClhUg50z.js → use-heading-spy-BZ7ctGZ5.js} +58 -48
- package/dist/{use-material-translation-CaHS6oZz.js → use-material-translation-DRB6oPdF.js} +3 -3
- package/docs/components/README.md +3 -3
- package/docs/components/data-display.md +126 -2
- package/docs/components/data-table-filter-and-lists.md +2 -1
- package/docs/components/form-fields.md +81 -0
- package/docs/components/navigation-and-layout.md +64 -0
- package/docs/components/selection-and-choice.md +2 -1
- package/docs/guides/forms.md +120 -0
- package/docs/guides/helpers.md +85 -0
- package/docs/guides/i18n.md +17 -0
- package/docs/guides/printing.md +27 -0
- package/docs/previews/data-display-enrolmentsecret.webp +0 -0
- package/docs/previews/data-display-recoverycodes.webp +0 -0
- package/docs/previews/form-fields-formsection.webp +0 -0
- package/docs/previews/navigation-layout-authcard.webp +0 -0
- package/docs/roadmap.md +48 -15
- package/docs/troubleshooting.md +92 -0
- package/lib/components/components.css +1 -0
- package/lib/components/print-only.css +44 -0
- package/lib/nuxt/auto-imports.ts +2 -0
- package/lib/nuxt/component-names.ts +4 -0
- package/package.json +2 -2
- package/dist/print-format.utils-DpBtUJvL.js +0 -138
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import "./stored-language-
|
|
2
|
-
import { a as e } from "./i18n-provider-
|
|
3
|
-
import { i as t, r as n } from "./translator-
|
|
1
|
+
import "./stored-language-dtksFoGO.js";
|
|
2
|
+
import { a as e } from "./i18n-provider-B2q6vp6H.js";
|
|
3
|
+
import { i as t, r as n } from "./translator-DqiGrSH_.js";
|
|
4
4
|
//#region lib/i18n/translate-material.ts
|
|
5
5
|
function r(e, r, i, a) {
|
|
6
6
|
if (e) for (let t of /* @__PURE__ */ new Set([e, e.split("-")[0]])) {
|
|
@@ -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).
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -171,8 +173,12 @@ Pill with leading, avatar and trailing slots, an active state and a dismiss butt
|
|
|
171
173
|
|
|
172
174
|
```vue
|
|
173
175
|
<script setup lang="ts">
|
|
176
|
+
import { ref } from 'vue';
|
|
174
177
|
import { Avatar, AvatarFallback, Chip } from '@nxgt/material-vue/components';
|
|
175
178
|
|
|
179
|
+
const chip = ref<InstanceType<typeof Chip>>();
|
|
180
|
+
// chip.value?.focus()
|
|
181
|
+
|
|
176
182
|
function remove(id: string) {
|
|
177
183
|
console.log('remove', id);
|
|
178
184
|
}
|
|
@@ -185,6 +191,9 @@ function remove(id: string) {
|
|
|
185
191
|
</template>
|
|
186
192
|
</Chip>
|
|
187
193
|
<Chip label="Shipped" color="success" active />
|
|
194
|
+
<Chip label="Frontend" :dismiss-label="(label) => `Clear ${label} filter`" @dismiss="remove('fe')" />
|
|
195
|
+
<Chip label="+ Field" variant="dashed" :active="added" @click="add" />
|
|
196
|
+
<Chip ref="chip" label="Compact" size="sm" />
|
|
188
197
|
</template>
|
|
189
198
|
```
|
|
190
199
|
|
|
@@ -192,12 +201,17 @@ function remove(id: string) {
|
|
|
192
201
|
| --- | --- | --- | --- |
|
|
193
202
|
| `label` | `string` | — | the text; the default slot replaces it |
|
|
194
203
|
| `active` | `boolean` | — | filled appearance |
|
|
195
|
-
| `variant` | Button `variant` | `'outlined'` | appearance when not active |
|
|
204
|
+
| `variant` | Button `variant`, or `'dashed'` | `'outlined'` | appearance when not active; `dashed` is a muted dashed pill (a "+ Field" pill) that turns solid and normal once `active` |
|
|
205
|
+
| `size` | `'default' \| 'sm'` | `'default'` | `sm` is a compact chip; the ✕ and every target stay at least 24px |
|
|
196
206
|
| `color` | Button `color` | — | tone |
|
|
197
207
|
| `disabled` | `boolean` | — | disables the chip and its ✕ |
|
|
198
|
-
| `@dismiss` | `() => void` | — | a listener adds the ✕ button, named "Remove {label}" (`material.chip.dismiss-label`), `{label}` being the text the chip shows: the default slot's when given, else `label`; "Remove" when it shows none |
|
|
208
|
+
| `@dismiss` | `() => void` | — | a listener adds the ✕ button, named "Remove {label}" (`material.chip.dismiss-label`), `{label}` being the text the chip shows: the default slot's when given, else `label`; "Remove" when it shows none; the ✕ is a 24px target |
|
|
209
|
+
| `dismissLabel` | `string \| ((label: string) => string)` | — | names the ✕ instead of "Remove {label}": a string as is, or a function of the text the chip shows |
|
|
199
210
|
| `@click` | `(event: MouseEvent) => void` | — | a listener makes the chip (or its body, when dismissible) a button |
|
|
200
211
|
| `#leading`, `#avatar`, `#trailing` | slots | — | `#avatar` is shrunk to fit and replaces `#leading` |
|
|
212
|
+
| exposed `el`, `focus(options?)` | `HTMLElement \| null`, `(options?: FocusOptions) => void` | — | the chip's body (the Button, the inner button, or the label span with `tabindex="-1"`) and a method focusing it: `chip.value?.focus()` |
|
|
213
|
+
|
|
214
|
+
The dashed look's muted text meets 4.5:1 on the `background` and `card` surfaces, but not on `accent`.
|
|
201
215
|
|
|
202
216
|
**Storybook:** `Data display/Chip` · **React:** `Chip`
|
|
203
217
|
|
|
@@ -923,3 +937,113 @@ The other props (`lineColor`, `background`, `margin`, `fontSize`, …) are
|
|
|
923
937
|
JsBarcode's options.
|
|
924
938
|
|
|
925
939
|
**Storybook:** `Data display/Barecode` · **React:** `Barecode`
|
|
940
|
+
|
|
941
|
+
---
|
|
942
|
+
|
|
943
|
+
## EnrolmentSecret
|
|
944
|
+
|
|
945
|
+

|
|
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
|
+

|
|
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)
|
|
@@ -356,7 +356,8 @@ async function archive() {
|
|
|
356
356
|
in the URL; `manual` turns client filtering off.
|
|
357
357
|
- **Selection** is `v-model:row-selection`, keyed by `getRowId`, so it
|
|
358
358
|
survives a page change: ids of other pages stay in it, and the footer
|
|
359
|
-
counts them all ("3 rows selected", `material.table.pagination.selected
|
|
359
|
+
counts them all ("3 rows selected of 128", `material.table.pagination.rows-selected`; "Page n of m"
|
|
360
|
+
is hidden when the table has no rows).
|
|
360
361
|
`#actions="{ table }"` reads it too (`table.store.state.rowSelection`). To
|
|
361
362
|
keep it across a reload, put it in the URL or `sessionStorage`. Unbound,
|
|
362
363
|
both models are held inside, as before. Bind both halves (`v-model`, or
|
|
@@ -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
|
+

|
|
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
|

|
|
@@ -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
|
+

|
|
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
|

|
|
@@ -144,6 +144,7 @@ const framework = ref('nuxt');
|
|
|
144
144
|
| `placeholder` | `string` | `label` | the trigger's text while nothing is selected |
|
|
145
145
|
| `required` | `boolean` | — | an asterisk after the label, and the select's `required`; the trigger is named by the label |
|
|
146
146
|
| `label`, `helper-text`, `error`, `disabled` | | — | the field's text and state |
|
|
147
|
+
| `ariaLabelledby` (`aria-labelledby` in a template) | `string` | — | the id of an element that names the trigger when `label` is absent; `label` wins when both are given |
|
|
147
148
|
|
|
148
149
|
`#option` renders each option's content in the list (React's
|
|
149
150
|
`renderOption`); it receives `{ option, index, selected }`. The trigger shows
|
|
@@ -166,7 +167,7 @@ the picked option's text.
|
|
|
166
167
|
**Accessibility:** the trigger is named by `label`, described by the help or
|
|
167
168
|
error text (`aria-describedby`) and marked `aria-invalid` on error, so
|
|
168
169
|
`getByRole('combobox', { name: 'Framework' })` finds it and
|
|
169
|
-
`toHaveAccessibleDescription(…)` / `toBeInvalid()` match.
|
|
170
|
+
`toHaveAccessibleDescription(…)` / `toBeInvalid()` match. Without a `label`, `aria-labelledby` (`ariaLabelledby` in the script; the id of another element) names the trigger; when both are given, `label` wins.
|
|
170
171
|
**Emits:** `update:modelValue` with `string`.
|
|
171
172
|
**Form wrapper:** `SelectFormField` — `name` inside a vee-validate form, see
|
|
172
173
|
[forms](../guides/forms.md#the-pair) and [what each field emits](../guides/forms.md#what-each-field-emits).
|
package/docs/guides/forms.md
CHANGED
|
@@ -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
|
package/docs/guides/helpers.md
CHANGED
|
@@ -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`
|