@nxgt/material-vue 0.26.1 → 0.27.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 (156) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +8 -5
  3. package/dist/Button-DEUkxkrJ.js +728 -0
  4. package/dist/{DropdownMenuTrigger-BHAhXGUO.js → DropdownMenuTrigger-BsuyNVzV.js} +48 -41
  5. package/dist/HoverCardTrigger-TDll55LP.js +510 -0
  6. package/dist/{camera-DvKuqT4L.js → camera-UJXnnFeb.js} +4 -4
  7. package/dist/components/base-content.css +139 -0
  8. package/dist/components/base.css +150 -0
  9. package/dist/components/components.css +2 -0
  10. package/dist/components/reduced-motion.css +56 -0
  11. package/dist/components/theme-tokens.css +116 -0
  12. package/dist/components/ui/actions/button-group/button-group.css +1 -1
  13. package/dist/components/ui/feedback/toast/toast.css +21 -0
  14. package/dist/components/ui/primitives/label/label.css +33 -0
  15. package/dist/{components-CCWod7cJ.js → components-BZ1XAK37.js} +19198 -18568
  16. package/dist/components.js +7 -7
  17. package/dist/composables.js +3 -3
  18. package/dist/dnd.js +1 -1
  19. package/dist/field-name-attribute-Bl7LSVK0.js +4 -0
  20. package/dist/{form-scope-x-7TYn2s.js → form-scope-BFGca0h6.js} +1 -1
  21. package/dist/{i18n-provider-CaaZCmaR.js → i18n-provider-DBusa725.js} +1 -1
  22. package/dist/i18n.js +4 -4
  23. package/dist/icon-button-jHB4MhQ5.js +262 -0
  24. package/dist/lib/components/ui/actions/button/Button.vue.d.ts +22 -4
  25. package/dist/lib/components/ui/actions/button/button-tones.d.ts +36 -0
  26. package/dist/lib/components/ui/actions/button/button.variants.d.ts +1 -1
  27. package/dist/lib/components/ui/actions/button/button.wrap.d.ts +8 -0
  28. package/dist/lib/components/ui/actions/button/label-text.d.ts +9 -0
  29. package/dist/lib/components/ui/actions/button/prepend-to-child.d.ts +18 -0
  30. package/dist/lib/components/ui/actions/button/use-click-lock.d.ts +14 -0
  31. package/dist/lib/components/ui/actions/copy-button/copy-button.vue.d.ts +6 -2
  32. package/dist/lib/components/ui/actions/icon-button/icon-button.compound-variants.d.ts +28 -0
  33. package/dist/lib/components/ui/actions/icon-button/icon-button.vue.d.ts +1 -1
  34. package/dist/lib/components/ui/actions/responsive-button/responsive-button.vue.d.ts +1 -3
  35. package/dist/lib/components/ui/collections/data-table/partials/data-table-empty.vue.d.ts +5 -1
  36. package/dist/lib/components/ui/collections/data-table/partials/data-table-scroll-region.vue.d.ts +28 -0
  37. package/dist/lib/components/ui/collections/data-table/partials/data-table-toolbar.d.ts +60 -0
  38. package/dist/lib/components/ui/collections/data-table/types.d.ts +9 -0
  39. package/dist/lib/components/ui/collections/data-table/use-scroll-edges.d.ts +22 -0
  40. package/dist/lib/components/ui/collections/pagination/PaginationFirst.vue.d.ts +1 -1
  41. package/dist/lib/components/ui/collections/pagination/PaginationItem.vue.d.ts +1 -1
  42. package/dist/lib/components/ui/collections/pagination/PaginationLast.vue.d.ts +1 -1
  43. package/dist/lib/components/ui/collections/pagination/PaginationNext.vue.d.ts +1 -1
  44. package/dist/lib/components/ui/collections/pagination/PaginationPrevious.vue.d.ts +1 -1
  45. package/dist/lib/components/ui/dates/calendar/Calendar.vue.d.ts +4 -4
  46. package/dist/lib/components/ui/dates/calendar/calendar-day-label.d.ts +17 -0
  47. package/dist/lib/components/ui/dates/calendar/calendar-years.d.ts +11 -0
  48. package/dist/lib/components/ui/dates/calendar/partials/calendar-month-select.vue.d.ts +18 -0
  49. package/dist/lib/components/ui/dates/calendar/partials/calendar-year-select.vue.d.ts +19 -0
  50. package/dist/lib/components/ui/dates/calendar/use-calendar-locale.d.ts +7 -0
  51. package/dist/lib/components/ui/dates/calendar/use-calendar-selected-hint.d.ts +17 -0
  52. package/dist/lib/components/ui/dates/date-range-field/partials/date-range-day.vue.d.ts +8 -0
  53. package/dist/lib/components/ui/display/cards/cards.gallery.d.ts +3 -3
  54. package/dist/lib/components/ui/display/list-tile/list-tile.vue.d.ts +10 -6
  55. package/dist/lib/components/ui/feedback/alert-dialog/AlertDialogContent.vue.d.ts +2 -2
  56. package/dist/lib/components/ui/feedback/sonner/index.d.ts +1 -0
  57. package/dist/lib/components/ui/feedback/toast/toast.d.ts +7 -0
  58. package/dist/lib/components/ui/feedback/toast/toast.vue.d.ts +18 -2
  59. package/dist/lib/components/ui/fields/contacts/consts.d.ts +1 -1
  60. package/dist/lib/components/ui/forms/use-field-blur.d.ts +3 -0
  61. package/dist/lib/components/ui/layout/app-shell/activity-content.vue.d.ts +9 -3
  62. package/dist/lib/components/ui/layout/app-shell/activity-layout.vue.d.ts +21 -4
  63. package/dist/lib/components/ui/layout/app-shell/app-header.vue.d.ts +7 -5
  64. package/dist/lib/components/ui/layout/app-shell/app-sidebar.vue.d.ts +4 -1
  65. package/dist/lib/components/ui/layout/app-shell/index.d.ts +2 -2
  66. package/dist/lib/components/ui/layout/app-shell/router-menu-link.d.ts +7 -0
  67. package/dist/lib/components/ui/layout/app-shell/types.d.ts +8 -0
  68. package/dist/lib/components/ui/layout/app-shell/use-content-scroll.d.ts +13 -0
  69. package/dist/lib/components/ui/layout/app-shell/use-navigation-sheet.d.ts +17 -0
  70. package/dist/lib/components/ui/layout/sidebar/sidebar-context.d.ts +15 -0
  71. package/dist/lib/components/ui/layout/sidebar/sidebar-menu-item.vue.d.ts +13 -4
  72. package/dist/lib/components/ui/media/carousel/carousel-next.vue.d.ts +1 -1
  73. package/dist/lib/components/ui/media/carousel/carousel-previous.vue.d.ts +1 -1
  74. package/dist/lib/components/ui/menu-item-variant.d.ts +4 -4
  75. package/dist/lib/components/ui/overlays/context-menu/ContextMenuContent.vue.d.ts +2 -2
  76. package/dist/lib/components/ui/overlays/dialog/DialogContent.vue.d.ts +2 -2
  77. package/dist/lib/components/ui/overlays/dialog/DialogScrollContent.vue.d.ts +2 -2
  78. package/dist/lib/components/ui/overlays/dialog/use-escape-forward.d.ts +17 -0
  79. package/dist/lib/components/ui/overlays/dialog/use-inert-others.d.ts +17 -0
  80. package/dist/lib/components/ui/overlays/dialog/use-modal-content.d.ts +32 -0
  81. package/dist/lib/components/ui/overlays/drawer/DrawerContent.vue.d.ts +2 -2
  82. package/dist/lib/components/ui/overlays/dropdown-menu/DropdownMenu.vue.d.ts +3 -1
  83. package/dist/lib/components/ui/overlays/dropdown-menu/DropdownMenuContent.vue.d.ts +2 -2
  84. package/dist/lib/components/ui/overlays/popover/PopoverContent.vue.d.ts +2 -2
  85. package/dist/lib/components/ui/overlays/sheet/SheetContent.vue.d.ts +2 -2
  86. package/dist/lib/components/ui/primitives/card/CardTitle.vue.d.ts +11 -4
  87. package/dist/lib/components/ui/primitives/card/index.d.ts +1 -0
  88. package/dist/lib/components/ui/primitives/toggle/Toggle.vue.d.ts +1 -1
  89. package/dist/lib/components/ui/primitives/toggle/index.d.ts +1 -1
  90. package/dist/lib/components/ui/selection/autocomplete/partials/autocomplete-box.vue.d.ts +2 -0
  91. package/dist/lib/components/ui/selection/select/SelectContent.vue.d.ts +2 -2
  92. package/dist/lib/components/ui/selection/select-chip-field/select-chip-field.vue.d.ts +1 -0
  93. package/dist/lib/i18n/resources/en/en.d.ts +9 -0
  94. package/dist/lib/i18n/resources/en/material.json.d.ts +13 -4
  95. package/dist/lib/i18n/resources/fr/fr.d.ts +9 -0
  96. package/dist/lib/i18n/resources/fr/material.json.d.ts +13 -4
  97. package/dist/lib/i18n/resources/resources.d.ts +18 -0
  98. package/dist/lib/i18n/translator.d.ts +1 -1
  99. package/dist/lib/lib/field-name-attribute.d.ts +7 -0
  100. package/dist/lib/lib/focus-field.d.ts +17 -0
  101. package/dist/lib/lib/index.d.ts +1 -0
  102. package/dist/lib.js +76 -74
  103. package/dist/main.js +13 -13
  104. package/dist/map.js +4 -4
  105. package/dist/material-vue.css +1 -1
  106. package/dist/{pdf-outline-sidebar-D3vq2qfS.js → pdf-outline-sidebar-ChjAXoUX.js} +2 -2
  107. package/dist/{pdf-page-BrJbyjQj.js → pdf-page-D4OTPcpF.js} +1 -1
  108. package/dist/{pdf-page-list-BCgYEszV.js → pdf-page-list-CFgKYLur.js} +2 -2
  109. package/dist/{pdf-reader-BAzevSJU.js → pdf-reader-B1hw81cG.js} +9 -9
  110. package/dist/{pdf-sidebar-DSh2Mend.js → pdf-sidebar-Do5HzrDq.js} +3 -3
  111. package/dist/{pdf-thumbnail-sidebar-D_EMcbKF.js → pdf-thumbnail-sidebar-CMge0C9-.js} +3 -3
  112. package/dist/{pdf-toolbar-BcL7HlfS.js → pdf-toolbar-BBio9baU.js} +5 -5
  113. package/dist/pdf.js +2 -2
  114. package/dist/{sortable-provider-DMpmM7Ke.js → sortable-provider-DMVbbmUU.js} +2 -2
  115. package/dist/{stored-language-Bxhsx4iB.js → stored-language-AJpwOMsA.js} +26 -8
  116. package/dist/{stx-player-MldK_ZAZ.js → stx-player-gtyStl7e.js} +2 -2
  117. package/dist/styles.css +10 -368
  118. package/dist/{translator-CLI_7Crg.js → translator-D2_swsMz.js} +1 -1
  119. package/dist/{use-heading-spy-VPuFj2Gt.js → use-heading-spy-BbEbAfPK.js} +19 -19
  120. package/dist/use-inert-others-CtGqZKR8.js +22 -0
  121. package/dist/{use-material-translation-CONqD9wJ.js → use-material-translation-BHtgyMz3.js} +3 -3
  122. package/docs/components/buttons-and-actions.md +71 -16
  123. package/docs/components/data-display.md +12 -1
  124. package/docs/components/data-table-filter-and-lists.md +85 -5
  125. package/docs/components/date-and-time.md +25 -1
  126. package/docs/components/feedback-and-status.md +86 -11
  127. package/docs/components/form-fields.md +13 -4
  128. package/docs/components/media-and-files.md +7 -0
  129. package/docs/components/navigation-and-layout.md +19 -1
  130. package/docs/components/overlays-and-menus.md +20 -4
  131. package/docs/components/primitives.md +16 -2
  132. package/docs/components/selection-and-choice.md +12 -1
  133. package/docs/getting-started.md +2 -2
  134. package/docs/guides/app-shell.md +102 -6
  135. package/docs/guides/forms.md +58 -5
  136. package/docs/guides/helpers.md +2 -1
  137. package/docs/guides/i18n.md +5 -2
  138. package/docs/guides/ssr.md +1 -1
  139. package/docs/guides/theming.md +134 -6
  140. package/docs/previews/data-display-description.webp +0 -0
  141. package/docs/previews/feedback-status-toast.webp +0 -0
  142. package/docs/roadmap.md +21 -0
  143. package/docs/troubleshooting.md +854 -12
  144. package/lib/components/base-content.css +139 -0
  145. package/lib/components/base.css +150 -0
  146. package/lib/components/components.css +2 -0
  147. package/lib/components/reduced-motion.css +56 -0
  148. package/lib/components/theme-tokens.css +116 -0
  149. package/lib/components/ui/actions/button-group/button-group.css +1 -1
  150. package/lib/components/ui/feedback/toast/toast.css +21 -0
  151. package/lib/components/ui/primitives/label/label.css +33 -0
  152. package/lib/styles.css +10 -368
  153. package/package.json +2 -2
  154. package/dist/Button-16J7vTpE.js +0 -665
  155. package/dist/HoverCardTrigger-C41002JS.js +0 -455
  156. package/dist/icon-button-CDdNsZuZ.js +0 -227
@@ -1,34 +1,28 @@
1
- import { a as e } from "./i18n-provider-CaaZCmaR.js";
1
+ import { a as e } from "./i18n-provider-DBusa725.js";
2
2
  import { c as t, d as n, f as r, l as i, n as a, u as o } from "./number.utils-Cy1z6qI7.js";
3
3
  import { computed as s, getCurrentInstance as c, onMounted as l, ref as u, toValue as d } from "vue";
4
4
  import { useIntersectionObserver as f, useMediaQuery as p, useSSRWidth as m, useStorage as h } from "@vueuse/core";
5
- //#region lib/composables/use-ssr-media-query.ts
6
- var g = 1024;
7
- function _(e) {
8
- return c() ? p(e, { ssrWidth: m() ?? g }) : p(e);
9
- }
10
- //#endregion
11
- //#region lib/composables/use-mobile.ts
12
- var v = 768;
13
- function y() {
14
- return _(`(max-width: ${v - 1}px)`);
15
- }
16
- //#endregion
17
5
  //#region lib/composables/resolve-theme.ts
18
- function b(e, t) {
6
+ function g(e, t) {
19
7
  let n = e === "light" || e === "dark" ? e : "system";
20
8
  return {
21
9
  theme: n === "system" ? t ? "dark" : "light" : n,
22
10
  preference: n
23
11
  };
24
12
  }
25
- function x(e) {
13
+ function _(e) {
26
14
  typeof document > "u" || (document.documentElement.classList.toggle("dark", e === "dark"), document.documentElement.style.colorScheme = e);
27
15
  }
28
16
  //#endregion
17
+ //#region lib/composables/use-ssr-media-query.ts
18
+ var v = 1024;
19
+ function y(e) {
20
+ return c() ? p(e, { ssrWidth: m() ?? v }) : p(e);
21
+ }
22
+ //#endregion
29
23
  //#region lib/composables/use-theme.ts
30
- function S() {
31
- let e = _("(prefers-color-scheme: dark)"), t = h("theme", null, void 0, { initOnMounted: !0 }), n = s(() => b(t.value, !!e.value));
24
+ function b() {
25
+ let e = y("(prefers-color-scheme: dark)"), t = h("theme", null, void 0, { initOnMounted: !0 }), n = s(() => g(t.value, !!e.value));
32
26
  return {
33
27
  get theme() {
34
28
  return n.value.theme;
@@ -37,11 +31,17 @@ function S() {
37
31
  return n.value.preference;
38
32
  },
39
33
  updateTheme: (n) => {
40
- n === "system" ? t.value = null : t.value = n, x(b(n === "system" ? void 0 : n, !!e.value).theme);
34
+ n === "system" ? t.value = null : t.value = n, _(g(n === "system" ? void 0 : n, !!e.value).theme);
41
35
  }
42
36
  };
43
37
  }
44
38
  //#endregion
39
+ //#region lib/composables/use-mobile.ts
40
+ var x = 768;
41
+ function S() {
42
+ return y(`(max-width: ${x - 1}px)`);
43
+ }
44
+ //#endregion
45
45
  //#region ../../node_modules/date-fns/locale/fr/_lib/formatDistance.js
46
46
  var C = {
47
47
  lessThanXSeconds: {
@@ -477,4 +477,4 @@ function z(e, t, n) {
477
477
  });
478
478
  }
479
479
  //#endregion
480
- export { b as a, x as i, I as n, y as o, S as r, _ as s, z as t };
480
+ export { y as a, b as i, I as n, _ as o, S as r, g as s, z as t };
@@ -0,0 +1,22 @@
1
+ import { nextTick as e, useTemplateRef as t, watch as n } from "vue";
2
+ import { unrefElement as r } from "@vueuse/core";
3
+ import { inertOthers as i } from "@nxgt/material-core/inert-others";
4
+ //#region lib/components/ui/overlays/dialog/use-inert-others.ts
5
+ function a(a, o) {
6
+ let s = typeof a == "string" ? t(a) : a;
7
+ n(o, (t, n, a) => {
8
+ if (!t || typeof document > "u") return;
9
+ let o, c = !1;
10
+ e(() => {
11
+ let e = r(s);
12
+ !c && e instanceof Element && (o = i(e));
13
+ }), a(() => {
14
+ c = !0, o?.();
15
+ });
16
+ }, {
17
+ immediate: !0,
18
+ flush: "post"
19
+ });
20
+ }
21
+ //#endregion
22
+ export { a as t };
@@ -1,6 +1,6 @@
1
- import "./stored-language-Bxhsx4iB.js";
2
- import { a as e } from "./i18n-provider-CaaZCmaR.js";
3
- import { i as t, r as n } from "./translator-CLI_7Crg.js";
1
+ import "./stored-language-AJpwOMsA.js";
2
+ import { a as e } from "./i18n-provider-DBusa725.js";
3
+ import { i as t, r as n } from "./translator-D2_swsMz.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]])) {
@@ -54,17 +54,71 @@ function save() {
54
54
  | `variant` | `'filled' \| 'tonal' \| 'outlined' \| 'ghost' \| 'link'` | `'filled'` | the style |
55
55
  | `color` | `'primary' \| 'secondary' \| 'success' \| 'info' \| 'warning' \| 'error' \| 'default'` | `'primary'` | the colour; `default` is the foreground colour |
56
56
  | `size` | `'default' \| 'xs' \| 'sm' \| 'lg' \| 'icon' \| 'icon-xs' \| 'icon-sm' \| 'icon-lg'` | — (36 px) | height; the `icon*` sizes are square |
57
- | `loading` | `boolean` | — | shows a spinner, sets `aria-busy` and disables the button |
57
+ | `loading` | `boolean` | — | busy: a spinner, `aria-busy="true"` and `aria-disabled="true"`, and every click cancelled before your `@click` — a submit button submits neither on a click nor on Enter in a field. It stays focusable (no native `disabled`), so focus is not lost |
58
+ | `lock-on-click` | `boolean` | — | a click makes the button busy at once, until `loading` has turned true and back (or for one second when it never does): for a `loading` that turns true only after an `await` |
58
59
  | `spinner-variant` | `SpinnerProps['variant']` | `'2'` | which spinner `loading` shows |
60
+ | `wrap` | `boolean` | — | the label breaks onto as many lines as it needs, centred, instead of being cut with “…”: the button grows taller, never shorter than its `size`. Ignored by the `icon*` sizes |
59
61
  | `tooltip` | `string` | — | wraps the button in a `Tooltip` |
60
- | `as-child` / `as` | `boolean` / tag or component | `false` / `'button'` | styles its only child, or renders another element |
62
+ | `as-child` / `as` | `boolean` / tag or component | `false` / `'button'` | styles its only child, or renders another element. With `as-child` the child keeps its own attributes (`class`, `style` and listeners merge) and its slot content; while `loading`, the spinner goes inside the child, `aria-busy` and `aria-disabled` land on it, and the child stays the same element, keeping its focus, ref and directives. A component child with no slot content shows only the spinner while busy |
61
63
  | `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | not `submit` by default — pass it inside a form |
62
64
 
63
- Unlike React, `loading` disables the button (it is not focusable while
64
- loading). The classes are exported as `buttonVariants` for styling your own
65
- element.
65
+ The classes are exported as `buttonVariants` for styling your own element.
66
66
 
67
- **Storybook:** `Buttons & actions/Button` · **React:** `Button`
67
+ **On a phone.** A button is never wider than its container, and keeps its
68
+ content's width in a column: a label too
69
+ long for it (a card on a phone) is cut with “…” on one line, and its
70
+ accessible name — and a `title` you give it — keep the whole text. Give a
71
+ label that must be read in full `wrap`, or a width of your own (`w-full`).
72
+ The text is put in a `<span data-slot="button-label">` (one per run of
73
+ text) that shrinks and ends in the ellipsis; it needs the text to be the button's own slot content; with
74
+ `as-child`, put the label in a `<span>` inside the child element.
75
+
76
+ ```vue
77
+ <template>
78
+ <Button title="Continue to the payment details">Continue to the payment details</Button>
79
+ <Button wrap>Continue to the payment details and confirm the order</Button>
80
+ </template>
81
+ ```
82
+
83
+ **Submitting once.** Give a submit button `:loading="isSubmitting"` from
84
+ `useForm`: vee-validate sets it as `handleSubmit` starts, so the button is
85
+ busy from the first click and a double click, or Enter pressed twice,
86
+ submits once. When `loading` comes from something that turns true later — a
87
+ mutation's pending flag after an `await` — the second click comes first: add
88
+ `lock-on-click`.
89
+
90
+ ```vue
91
+ <script setup lang="ts">
92
+ import { useForm } from 'vee-validate';
93
+ import { Button, EmailFormField } from '@nxgt/material-vue/components';
94
+
95
+ const { handleSubmit, isSubmitting } = useForm();
96
+ const onSubmit = handleSubmit(async (values) => {
97
+ // …
98
+ });
99
+ </script>
100
+
101
+ <template>
102
+ <form @submit="onSubmit">
103
+ <EmailFormField name="email" label="Email" />
104
+ <Button type="submit" :loading="isSubmitting">Sign in</Button>
105
+ </form>
106
+ </template>
107
+ ```
108
+
109
+ **What the guard covers.** Clicks on this button, Space and Enter on it,
110
+ and Enter in a field when it is the form's first submit button (the default
111
+ one, which the browser clicks). `form.requestSubmit()` and `form.submit()`
112
+ fire no click, so a submit your code starts — an OTP step that submits on its
113
+ last digit, a keyboard shortcut — must check `isSubmitting` itself.
114
+ With `lock-on-click`, a `loading` that never turns true releases the lock after
115
+ one second.
116
+
117
+ A test finds a busy button by its state, not by `disabled`:
118
+ `expect(button).toHaveAttribute('aria-busy', 'true')` (`toBeDisabled()` is
119
+ false: it is still focusable).
120
+
121
+ **Storybook:** `Buttons & actions/Button` (`Submits Once`, `Lock On Click`), `Buttons & actions/Button/Phone` · **React:** `Button`
68
122
 
69
123
  ---
70
124
 
@@ -204,7 +258,7 @@ function save(options?: { draft: boolean }) {
204
258
  | --- | --- | --- | --- |
205
259
  | `#menu` slot | — | — | the `DropdownMenu*` items shown by the chevron |
206
260
  | `menu-label` | `string` | `"More actions"` (translated) | the chevron's `aria-label` |
207
- | `variant`, `color`, `loading`, `disabled` | as `Button` | as `Button` | applied to both halves; `loading` also disables the chevron |
261
+ | `variant`, `color`, `loading`, `disabled` | as `Button` | as `Button` | `variant`, `color` and `disabled` apply to both halves; `loading` makes the main half busy (spinner, clicks ignored, still focusable) and disables the chevron (native `disabled`) |
208
262
 
209
263
  Other `Button` props and `@click` go to the main half.
210
264
 
@@ -216,7 +270,7 @@ Other `Button` props and `@click` go to the main half.
216
270
 
217
271
  ![ResponsiveButton](../previews/buttons-actions-responsivebutton.webp)
218
272
 
219
- Shows its label on desktop and collapses to icon-only on mobile.
273
+ Shows its label from 768 px up and collapses to icon-only below. The collapse is a CSS media query, not a JS effect, so the server HTML is the same everywhere and nothing flashes.
220
274
 
221
275
  ```vue
222
276
  <script setup lang="ts">
@@ -235,10 +289,10 @@ import { Plus } from '@lucide/vue';
235
289
  | Prop | Type | Default | Effect |
236
290
  | --- | --- | --- | --- |
237
291
  | `#icon` slot | — | — | always shown |
238
- | `expanded` | `boolean` | not mobile (`useIsMobile`) | forces the label on or off |
292
+ | `expanded` | `boolean` | by width (`max-md` CSS) | forces the label on or off at every width |
239
293
 
240
- Every `Button` prop applies. Collapsed, the label stays in the button as
241
- screen-reader text, so it keeps its accessible name; an `aria-label` would
294
+ Every `Button` prop applies. The label is always in the DOM, so the accessible
295
+ name is the same at every width; collapsed, it is screen-reader text; an `aria-label` would
242
296
  replace that name, so pass one only when it should differ from the label.
243
297
  `expanded` is layout, not disclosure: the button has no `aria-expanded` of its
244
298
  own, so it is not announced as "collapsed". Used as a popover, drawer or menu
@@ -297,7 +351,7 @@ Needs `vue-router` installed on the app (`app.use(router)`).
297
351
 
298
352
  ![CopyButton](../previews/buttons-actions-copybutton.webp)
299
353
 
300
- Copies a value to the clipboard and briefly shows a check mark.
354
+ Copies a value to the clipboard and briefly shows a check mark (or a cross when the clipboard refuses).
301
355
 
302
356
  ```vue
303
357
  <script setup lang="ts">
@@ -315,11 +369,12 @@ import { CopyButton } from '@nxgt/material-vue/components';
315
369
  | Prop | Type | Default | Effect |
316
370
  | --- | --- | --- | --- |
317
371
  | `value` | `string \| (() => string)` | — (required) | what is copied; a function is read at click time |
318
- | `label` / `copied-label` | `string` | `"Copy"` / `"Copied"` (translated) | tooltip and `aria-label`, before and after |
372
+ | `label` | `string` | `"Copy"` (translated) | the button's accessible name; it never changes after a click |
373
+ | `copied-label` / `failed-label` | `string` | `"Copied"` / `"Copy failed"` (translated) | announced in the status region (and shown in the tooltip) once the write succeeded or failed |
319
374
  | `#icon` slot | — | a copy icon | replaces the idle icon |
320
375
  | `variant`, `color` | as `IconButton` | `'ghost'`, `'default'` | the button style |
321
376
 
322
- The check mark stays for 1.5 s. `copyToClipboard(text)` is the same copy
377
+ The write is awaited: "Copied" appears only once the clipboard accepted the text, and a refusal (no permission, an insecure page, no Clipboard API) shows "Copy failed" instead. The outcome is announced through a visually hidden `role="status"` (polite) region rendered next to the button, not by renaming the button: `getByRole('button', { name: 'Copy' })` keeps matching, and a test reads the outcome with `within(button.parentElement!).getByRole('status')` (a bare `getByRole('status')` is ambiguous: every `CopyButton` renders a `status` region, and so do `Banner`, `DataTable` and the skeletons). Its one root is a `span` with `display: contents` (no box) around the button and the status region, so `v-show`, `Transition` and `$el` work, and a class still goes to the button. The result stays for 1.5 s. `copyToClipboard(text)` is the same copy
323
378
  without a button.
324
379
 
325
380
  `CopyValue` shows the value in a monospace chip with the button beside it:
@@ -399,7 +454,7 @@ const emit = defineEmits<{ next: [] }>();
399
454
  | `label` | `string` | `"Load more"` (translated) | tooltip, `aria-label` and expanded label |
400
455
  | `#icon` slot | — | a rotate icon | replaces it |
401
456
  | `variant` | as `Button` | `'tonal'` | the style |
402
- | `loading` | `boolean` | — | spinner, button disabled |
457
+ | `loading` | `boolean` | — | spinner, clicks ignored |
403
458
 
404
459
  Icon-only by default because it sits in a pagination bar next to other icon
405
460
  buttons.
@@ -522,7 +577,7 @@ on load.
522
577
 
523
578
  ![LanguageSwitcher](../previews/buttons-actions-languageswitcher.webp)
524
579
 
525
- Globe button with a menu of the `I18nProvider` languages.
580
+ Globe button with a menu of the `I18nProvider` languages. Its menu is non-modal (see [DropdownMenu](overlays-and-menus.md#dropdownmenu)).
526
581
 
527
582
  ```vue
528
583
  <script setup lang="ts">
@@ -399,7 +399,7 @@ const user = { name: 'Ada Lovelace', phone: '' };
399
399
 
400
400
  `label` and `value` (both `string`) are its only props; the default slot is
401
401
  rendered under the value. A falsy `value` renders nothing, so optional fields
402
- need no `v-if`.
402
+ need no `v-if`. The label is a small muted caption above the value (not a title). `Description` takes one cell of the grid it is in; pass `class="col-start-full"` for a full-width row.
403
403
 
404
404
  **Storybook:** `Data display/Description` · **React:** `Description`
405
405
 
@@ -462,12 +462,23 @@ import { Icon, ListTile } from '@nxgt/material-vue/components';
462
462
  | `title` | `string` | — | first line |
463
463
  | `subtitle` | `string` | — | second, muted line |
464
464
  | `href` | `string` | — | renders an `<a>`; without it the row is a `<button>` |
465
+ | `linkComponent` / `link-component` | `Component` | plain `<a>` | the link element, as in the app shell: a component that takes `href` and lets the other attributes fall through |
465
466
  | `selected` | `boolean` | `false` | highlighted row (`aria-current="page"` on a link) |
466
467
  | `size` | `'sm' \| 'md'` | `'md'` | `sm` is the dense navigation row |
467
468
  | `disabled` | `boolean` | — | disables the link or button |
468
469
  | `@click` | `(event: MouseEvent) => void` | — | fired by both shapes |
469
470
  | `#leading`, `#trailing` | slots | — | icons, avatars, actions |
470
471
 
472
+ With a router, pass `link-component` so a click navigates on the client instead of reloading the page (`RouterLink` takes `to`, so wrap it in a component that takes `href`; `AppLink` below is the one defined in [A `ListTile` link reloads the whole page](../troubleshooting.md#a-listtile-link-reloads-the-whole-page)):
473
+
474
+ ```vue
475
+ <ListTile title="Notifications" href="/settings/notifications" :link-component="AppLink" />
476
+ ```
477
+
478
+ A disabled tile never renders the link component: it is a plain `<a>` without `href`, with `aria-disabled="true"`.
479
+
480
+ The root carries `data-slot="item-detail"` (a tile that is a button too).
481
+
471
482
  **Storybook:** `Data display/ListTile` · **React:** `ListTile`
472
483
 
473
484
  ---
@@ -23,7 +23,7 @@ each other, so either can be used alone.
23
23
 
24
24
  ![DataTable](../previews/data-table-filter-lists-datatable.webp)
25
25
 
26
- TanStack Table v9 with sorting, filtering, selection, expandable rows, pagination or load-more, column visibility, skeleton rows while loading and your own empty state — or one server page at a time, with controlled sorting and selection, cursor Previous / Next links and row links.
26
+ TanStack Table v9 with sorting, filtering, selection, expandable rows, pagination or load-more, column visibility, skeleton rows while loading and your own empty state — or one server page at a time, with controlled sorting and selection, cursor Previous / Next links and row links. On a phone it keeps its header row and scrolls sideways in a keyboard-reachable region; the page does not.
27
27
 
28
28
  ```vue
29
29
  <script setup lang="ts">
@@ -79,7 +79,9 @@ table enables, for typing your own column defs.
79
79
  | `#actions="{ table }"` / `actions` | slot / `(table) => VNode` | — | toolbar content with access to the table (selected rows…) |
80
80
  | `onLoadMore` / `isLoadingMore` | `() => void` / `boolean` | — | adds a "load more" button under the rows |
81
81
  | `options` | `{ showPage?: boolean; showSelected?: boolean }` | both shown | parts of the footer |
82
- | `showHeader` | `boolean` | hidden on mobile | header row |
82
+ | `showHeader` | `boolean` | shown | header row (column names, sorting, "Select all") at every width; `false` hides it |
83
+ | `maxHeight` | `number \| string` | none | caps the table's height (`400`, `'60vh'`); the rows scroll inside it |
84
+ | `scrollLabel` | `string` | "Scrollable table" | the name of the scroll region the table becomes while it overflows |
83
85
  | `loading` | `boolean` | `false` | skeleton rows, or the current rows dimmed; `aria-busy`; controls disabled |
84
86
  | `loadingRows` | `number` | the page size | how many skeleton rows |
85
87
  | `#empty="{ filtered }"` | slot | icon and "No data available" | content of the empty row, spanning every visible column |
@@ -142,6 +144,72 @@ default, or the `#empty` slot, told whether rows exist but none matches the
142
144
  filters (`filtered`); content that renders nothing gets the default.
143
145
  `DataTableEmptyState` types the slot props.
144
146
 
147
+ ### Phones and narrow screens
148
+
149
+ ```vue
150
+ <template>
151
+ <!-- The rows scroll inside 60% of the viewport; the page scrolls otherwise. -->
152
+ <DataTable
153
+ :data="invoices"
154
+ :columns="columns"
155
+ max-height="60vh"
156
+ scroll-label="Invoices"
157
+ />
158
+ </template>
159
+ ```
160
+
161
+ The table is as tall as its rows, and the page scrolls: there is no height
162
+ cap unless you pass `max-height`, so a phone never gets a scroll inside the
163
+ page's. The header row shows at every width (`:show-header="false"` hides
164
+ it). With `max-height`, the rows scroll under the header row, which stays in
165
+ view on an opaque background.
166
+
167
+ When the columns are wider than the table, the table scrolls sideways in
168
+ one box (`[data-slot="table-wrapper"]`), and the page does not. While it
169
+ overflows, that box is a `region` named "Scrollable table"
170
+ (`material.table.labels.scroll-region`, or `scroll-label`) with
171
+ `tabindex="0"`, so a keyboard user can reach it and scroll it with the arrow
172
+ keys, and an edge fades on each side where columns are hidden
173
+ (`data-scroll-start` / `data-scroll-end` on its parent,
174
+ `[data-slot="table-scroll"]`). It is not a region while it fits, nor on the
175
+ server; once focused, it stays a tab stop until focus leaves, even if it
176
+ stops overflowing. The empty state is centred in the part of the table you
177
+ can see (the box's measured width, `--table-visible-width`), not across its
178
+ whole scroll width. The table needs no width from its parent: inside a
179
+ content-sized one (`w-fit`, `inline-flex`, an `auto` grid track, a popover)
180
+ it is as wide as its columns.
181
+
182
+ The `select` column's checkboxes keep their 16px box, with a 24px target
183
+ around it (WCAG 2.5.8). The toolbar renders only when it has content:
184
+ the `actions` prop or the `#actions` slot that render nothing (`null`,
185
+ `false`, whitespace, a `v-if` left out) leave no empty `<fieldset>` behind.
186
+
187
+ Selection actions do not move the rows. With `actions` or the `#actions`
188
+ slot and the `select` column (`predefinedColumns().select`, id `select`),
189
+ the toolbar's height is reserved — an empty, hidden placeholder
190
+ (`[data-slot="header-placeholder"]`) while they render nothing, the same
191
+ minimum height once they do — so the "Delete selected" that appears on the
192
+ first selection does not push the tapped row down. The reserved height is
193
+ one row of default-size buttons (`h-9`); taller or wrapping actions still
194
+ grow the toolbar.
195
+
196
+ ```vue
197
+ <template>
198
+ <DataTable :data="people" :columns="[select, ...columns]">
199
+ <template #actions="{ table }">
200
+ <Button
201
+ v-if="table.getSelectedRowModel().rows.length > 0"
202
+ @click="handleDelete(table.getSelectedRowModel().rows)()"
203
+ >
204
+ Delete selected
205
+ </Button>
206
+ </template>
207
+ </DataTable>
208
+ </template>
209
+ ```
210
+
211
+ Rows stay rows on a phone; a card layout is not built in.
212
+
145
213
  ### Server-side data
146
214
 
147
215
  When the server filters, sorts and pages, hand the table **one page** and
@@ -263,7 +331,16 @@ async function archive() {
263
331
  index of the page's first row) make the range: "41–60 of 133"
264
332
  (`material.table.pagination.range`), "41–60" without a total, "133 rows"
265
333
  without an offset. Cursor paging knows the offset from the trail's length
266
- times the page size; leave it out if pages can be short.
334
+ times the page size; leave it out if pages can be short. The range is a
335
+ polite live region (`role="status"`, `aria-atomic="true"`, named "Rows
336
+ shown" by `material.table.pagination.range-label`) that is in the DOM from
337
+ the first render whenever `pagination` is given, so a screen reader
338
+ announces "41–60 of 133" when the page changes. With neither `offset` nor
339
+ `totalCount` the region is empty, and is kept in the DOM so a later change
340
+ is still announced. The table already has a visually hidden status for
341
+ "Loading data…" (and the page may have a toast region), so a test finds the
342
+ range with `getByRole('status', { name: 'Rows shown' })`, not a bare
343
+ `getByRole('status')`.
267
344
  - **Sorting** is `v-model:sorting`: the headers show it, and
268
345
  `update:sorting` hands you the new `SortingState` to put in the URL.
269
346
  Filters work the same way: render them in `#actions` and keep their values
@@ -288,7 +365,10 @@ async function archive() {
288
365
  navigation (opening a sheet). Alone, it makes the row focusable and Enter
289
366
  calls it; with `getRowHref`, the link stays the keyboard target, and
290
367
  calling `event.preventDefault()` in it stops a mouse click from following
291
- the link. A focusable row has no role of its own: for anything a screen
368
+ the link. The row itself is never a second control: with `getRowHref` it
369
+ has no role, no `tabindex` and no `aria-label`, and the one link is named
370
+ by the cell's text, so `getByRole('link', { name: 'Ada', exact: true })`
371
+ is unique. A focusable row has no role of its own: for anything a screen
292
372
  reader user must reach, prefer `getRowHref` or a button in a cell.
293
373
  - **Your own footer.** The `#footer="{ table }"` slot or the `footer` prop
294
374
  (`(table) => VNode`) replaces the footer — `DataTableServerFooter` is the
@@ -297,7 +377,7 @@ async function archive() {
297
377
  page is fetched, and the current rows stay in place, dimmed.
298
378
 
299
379
  The server footer's strings are `material.table.pagination.*`
300
- (`previous`, `next`, `label`, `range`, `range-open`, `total`, `selected`),
380
+ (`previous`, `next`, `label`, `range`, `range-open`, `range-label`, `total`, `selected`),
301
381
  in English and French.
302
382
 
303
383
  For the TanStack API itself (column helpers, features, row models), see
@@ -58,9 +58,33 @@ under `I18nProvider`), and so are `DateRangeField`'s; an `aria-label` on
58
58
  | `layout` | `'month-and-year' \| 'month-only' \| 'year-only'` | — | month / year selects in the heading instead of plain text |
59
59
  | `year-range` | `DateValue[]` | 100 years back, 10 ahead | the years in the year select |
60
60
  | `default-placeholder` | `DateValue` | today | the month shown first |
61
- | `locale` | `string` | `'en'` | month and weekday names |
61
+ | `locale` | `string` | `I18nProvider` language, else `'en'` | month and weekday names, and the language of the day names |
62
62
 
63
63
  **Emits:** `update:modelValue` with `DateValue | DateValue[] | undefined`.
64
+
65
+ **Day names.** Every day is named by its full date in the calendar's language
66
+ (the `locale` prop, else the active `I18nProvider` language, else English):
67
+ "Thursday, October 1, 2026", "jeudi 1 octobre 2026". The name is on the button
68
+ and, for Testing Library, on the `gridcell` around it. The chosen day has
69
+ `aria-selected="true"` on its cell and its button is described by "Selected"
70
+ (`material.calendar.selected`, translated); today has `aria-current="date"` on
71
+ both. The days of the neighbouring months are named by their own dates, so a
72
+ number alone matches no day. "Selected" comes from the `Calendar` itself, which
73
+ renders the hidden hint; a calendar hand-built from the exported parts names its
74
+ cells and buttons the same way but has no "Selected". `DateRangeField`'s
75
+ calendar does the same:
76
+
77
+ ```ts
78
+ await page.getByRole('gridcell', { name: 'Thursday, October 1, 2026', exact: true }).getByRole('button').click();
79
+ ```
80
+
81
+ **Weekday row.** It follows the same language: "S", "M"… in English, "L",
82
+ "M"… under a French `I18nProvider`, with the week starting on the locale's
83
+ first day (Monday in French). Each header is labelled by its full weekday
84
+ ("lundi"), as in `@nxgt/material`; reka hides the row from assistive tech
85
+ (`<thead aria-hidden>`), since every day's name already carries its weekday.
86
+ It reads at 4.5:1 or more on the popover in the light and the dark theme.
87
+
64
88
  No form wrapper — use `DateFormField`.
65
89
  **Storybook:** `Date & time/Calendar` · **React:** `Calendar`, `CalendarDayButton`
66
90
 
@@ -52,6 +52,8 @@ import { CircleAlert } from '@lucide/vue';
52
52
 
53
53
  The default slot is rendered under the description.
54
54
 
55
+ The root carries `data-slot="alert"`, the title `data-slot="alert-title"` and the description `data-slot="alert-description"`, for tests and selectors.
56
+
55
57
  **Storybook:** `Feedback & status/Alert` · **React:** `Alert`
56
58
 
57
59
  ---
@@ -157,7 +159,8 @@ the content brings both.
157
159
  On open, `AlertDialogContent` focuses `AlertDialogCancel`, the least
158
160
  destructive action (its first control when there is no Cancel, the content
159
161
  when there is none); an `@open-auto-focus` that calls `event.preventDefault()`
160
- keeps focus where it was. After that opt-out the Cancel takes no focus until the next
162
+ keeps focus off the Cancel, on the content: the page behind an alert dialog is
163
+ `inert` while it is open ([Overlays & menus](overlays-and-menus.md)). After that opt-out the Cancel takes no focus until the next
161
164
  task: focus it from a `setTimeout`, not in the handler.
162
165
 
163
166
  **Emits:** `update:open` on `AlertDialog` — `boolean`.
@@ -239,7 +242,7 @@ function archive() {}
239
242
  | Prop (`ConfirmationDialog`) | Type | Default | Effect |
240
243
  | --- | --- | --- | --- |
241
244
  | `#trigger` slot | — | — | becomes the dialog trigger (`as-child`, no extra button): pass one element, such as a `Button` |
242
- | `v-model:open` | `boolean` | uncontrolled | open state; without it the dialog keeps its own |
245
+ | `v-model:open` | `boolean` | uncontrolled | open state: with `v-model:open`, or `:open` together with `@update:open`, the dialog shows exactly what `open` says; otherwise it keeps its own state (`:open` alone only opens it, `@update:open` alone only listens; an `open` that is still `undefined`, as `ref<boolean>()`, counts as not set and the dialog keeps its own state, so it still closes) |
243
246
  | `@confirm` | `(values?) => void` | — | emitted on confirm, with the form's parsed values when there is one; the dialog then closes |
244
247
  | `form-options` | `ConfirmationDialogFormOptions` (`{ schema, defaultValues? }`) | — | a form in the dialog, below the default slot — see below |
245
248
  | `color` | `IconButton` colour | `'warning'` | icon and both buttons |
@@ -247,6 +250,11 @@ function archive() {}
247
250
  | `#icon` slot | — | info icon, in a decorative `aria-hidden` badge | replaces the badge: keep yours decorative (`<IconButton as="span" aria-hidden="true">`), not a button that does nothing |
248
251
  | `confirm-text` / `cancel-text` | `string` | "Confirm" / "Cancel", translated (`material.confirm-dialog.confirm` / `.cancel`) | button text |
249
252
 
253
+ **Several steps, one dialog.** Confirm closes it; to show the next step, change
254
+ its `title` (and `description`) and set `open` back to `true`, at once (from
255
+ `@confirm` or `update:open`) or after an `await`. No `:key` per step is needed,
256
+ and the reopened dialog's buttons take the click.
257
+
250
258
  `form-options` puts a form in the confirmation, as React's `formOptions`:
251
259
  `schema` is a Zod 4 schema (any Standard Schema, or a vee-validate typed
252
260
  schema), `defaultValues` its initial values, and the `#form` slot renders the
@@ -564,16 +572,83 @@ async function upload() {
564
572
  | --- | --- | --- | --- |
565
573
  | `title` / `description` | `string` | preset title for `success`, `error`, `loading` | toast text |
566
574
  | `icon` | a VNode (`toast`) · icon name (`toasts.*`) | preset icon | leading icon |
567
- | `action` | `{ label, onClick? }` or `(dismiss) => VNode` | a translated "Close" for `success`, `info`, `error` | trailing button |
568
- | `options` | vue-sonner `ExternalToast` | `duration: 5000` for presets, `Infinity` for `loading` | passed to Sonner (`id`, `duration`, `position`…) |
575
+ | `action` | `{ label, onClick? }` or `(dismiss) => VNode` | a translated “Close” for `success`, `info`, `error`; a preset keeps the caller's own | trailing button: `onClick`, then the toast closes; or what the function renders, given `dismiss`. Brings the X button and a persistent toast, see below |
576
+ | `dismissLabel` | `string` | the translated “Close” when an `action` is given | accessible name of the X icon button beside the action; `''` hides it |
577
+ | `role` | `'status' \| 'alert'` | none (`toasts.error`: `'alert'`) | live-region role. Toasts are already announced politely by the `Toaster`'s region; `alert` interrupts, and may be read twice by some screen readers |
578
+ | `options` | vue-sonner `ExternalToast` | `duration: 5000` for presets, `Infinity` for `loading` and for any toast with your own `action` (a preset's fallback "Close" still times out at 5000) unless a `duration` is passed | passed to Sonner (`id`, `duration`, `position`…) |
569
579
 
570
580
  The toast id defaults to the title, so two toasts with the same title
571
- replace each other; pass `options: { id }` to show both. `Toaster` is
572
- vue-sonner's and takes its props (`position`, `expand`, `rich-colors`…);
573
- vue-sonner's stylesheet comes with this package's (`styles.css`, or the
574
- compiled stylesheet). `Toast` is the component each toast
575
- renders, and `sonnerToast` is vue-sonner's own `toast`, for anything the
576
- presets do not cover.
581
+ replace each other; pass `options: { id }` to show both. `Toaster` wraps
582
+ vue-sonner's and takes its props (`position`, `expand`, `rich-colors`…), all
583
+ forwarded; mount the one from `@nxgt/material-vue/components`, not
584
+ vue-sonner's own, or neither phone change below applies. vue-sonner's
585
+ stylesheet comes with this package's (`styles.css`, or the compiled
586
+ stylesheet). `Toast` is the component each toast renders, and `sonnerToast`
587
+ is vue-sonner's own `toast`, for anything the presets do not cover.
588
+
589
+ **Types:** `ToasterProps` (vue-sonner's), from `@nxgt/material-vue/components`.
590
+
591
+ **On a phone** (under 600px wide, where vue-sonner lays toasts out across the
592
+ screen), a `Toaster` with no `position` shows its toasts at the top
593
+ (`top-center`): across the bottom they covered what a form keeps there, its
594
+ submit button first. A `position` you pass applies on every screen. The server
595
+ and the client's first render answer for a desktop (`bottom-right`, or for the
596
+ width `provideSSRWidth()` gives), and a phone moves the toasts to the top once
597
+ mounted. The phone offset is vue-sonner's `mobile-offset` (16px by default);
598
+ raise its `top` when a sticky header must stay visible:
599
+ `<Toaster :mobile-offset="{ top: 72 }" />`.
600
+
601
+ ```vue
602
+ <Toaster /> <!-- bottom-right on a desktop, top-center on a phone -->
603
+ <Toaster position="bottom-center" /> <!-- bottom-center everywhere -->
604
+ ```
605
+
606
+ **An action keeps the toast open.** `toast()` itself does it, so a preset and a
607
+ direct call behave alike: any toast with an `action` is persistent by default
608
+ (`options.duration: Infinity`, WCAG 2.2.1: a timed toast can vanish before a
609
+ keyboard or screen-reader user reaches its button) unless you pass
610
+ `options.duration`, and shows a small close (X) icon button beside the action,
611
+ named by `material.toast.buttons.close` ("Close" / "Fermer") unless you pass
612
+ your own `dismissLabel` (`''` hides it). The presets keep the `action` you pass
613
+ (`toasts.info({ description: 'A new version is available', action: { label:
614
+ 'Reload', onClick } })`); without an `action`, the preset's text "Close" button
615
+ is unchanged, on a timed toast. Sonner pauses its timer while a mouse hovers a
616
+ toast, while a finger is down on it and while the page is hidden, but not on
617
+ keyboard focus, which is why the default is persistent. A tap does not hover:
618
+ the mouse a browser emulates after a tap (which never leaves until the next tap
619
+ elsewhere) is ignored, so a tapped toast still goes after its duration. On a
620
+ touch screen a tap no longer fans out a stack of two or more toasts either: pass
621
+ `expand` to keep the stack open. Sonner does not close a toast on Escape (it
622
+ only collapses the stack); this `Toast` does when focus is inside it, with or
623
+ without an action. The close button is reached with Tab. **Focus after a
624
+ dismiss:** the dismissed toast leaves the page with the focused control in it,
625
+ so focus falls back to `document.body`; move it yourself (`onClick`, or after
626
+ `toasts.dismiss()`) when the flow needs it elsewhere.
627
+
628
+ **Announced to screen readers:** `Toaster` renders a polite live region
629
+ (`<section aria-live="polite">`) that is in the page before the first toast,
630
+ so every toast inserted into it is read out. A toast therefore has no `role`
631
+ of its own: one on each toast would nest a second live region. `toasts.error`
632
+ is `role="alert"` (assertive), so a failure interrupts; pass `role` to
633
+ `toast()` or to a preset to choose another (a caller's wins). Mount `Toaster`
634
+ once, early, and not conditionally after the first toast. A `Toast` rendered
635
+ outside a `Toaster` is not in a live region: give it its own.
636
+
637
+ **In tests:** find a toast by its text within the region, and an error toast
638
+ by role. A `status` or `alert` takes no name from its content, so read the
639
+ message with `toHaveTextContent`.
640
+
641
+ ```ts
642
+ const alert = await screen.findByRole('alert'); // toasts.error
643
+ expect(alert).toHaveTextContent('Save failed');
644
+ const saved = await screen.findByText('Item saved'); // toasts.success / info
645
+ expect(saved.closest('section[aria-live="polite"]')).not.toBeNull();
646
+ ```
647
+
648
+ Wait with `findBy*` or `waitFor`, never a fixed delay. A toast stays in the
649
+ DOM until it is dismissed or times out (a toast with an action never times
650
+ out), so queries in one test file can see the previous
651
+ test's toasts: call `toasts.dismiss()` first.
577
652
 
578
653
  **Storybook:** `Feedback & status/Toast` · **React:** `Toaster`, `toast`, `toasts`, `Toast` (`ToastAction` type)
579
654
 
@@ -640,7 +715,7 @@ import { HydrateLoading } from '@nxgt/material-vue/components';
640
715
  | --- | --- | --- | --- |
641
716
  | `#icon` slot | — | `<Spinner class="size-8" />` | replaces the spinner |
642
717
  | default slot | — | — | shown above the spinner |
643
- | `class` | `string` | — | the root fills the viewport (`size-lv`); override to box it |
718
+ | `class` | `string` | — | the root fills the viewport (`w-lvw h-lvh`); override to box it |
644
719
 
645
720
  **Storybook:** `Feedback & status/HydrateLoading` · **React:** `HydrateLoading`
646
721
 
@@ -498,8 +498,8 @@ const code = ref('');
498
498
  | `length` | `number` | `6` | number of slots, split in two groups |
499
499
  | `v-model` | `string` | — | the digits typed so far |
500
500
  | `@complete` | `(value: string) => void` | — | once every slot is filled (from `vue-input-otp`) |
501
- | `error` | `boolean` | — | error colour on the slots |
502
- | `id` | `string` | from `useId()` | the input's id, which the label points to |
501
+ | `error` | `boolean` | — | error colour on the slots (`data-invalid` on each); `aria-invalid` goes on the input only, the one element a screen reader reads |
502
+ | `id` | `string` | from `useId()` | the input's id, which the label points to; it differs per field — `OtpFormField` too (not the field `name`), so two code forms on one page each keep their name |
503
503
 
504
504
  `InputOTP`, `InputOTPGroup`, `InputOTPSlot` and `InputOTPSeparator` are the
505
505
  unlabelled primitives it is built from, for a hand-built layout:
@@ -527,10 +527,19 @@ const pin = ref('');
527
527
  </template>
528
528
  ```
529
529
 
530
+ **On a phone.** The slots are 36 px squares that shrink with a narrow
531
+ field, kept square: six fit a 320 px-wide card, each above the 24 px
532
+ target of WCAG 2.5.8, and the gaps between them narrow under the `sm`
533
+ breakpoint. A slot you build yourself with `InputOTPSlot` does the same.
534
+
535
+ **In a test**, find the code by its input — `getByLabelText('Enter OTP')` —
536
+ and an error by `toHaveAttribute('aria-invalid', 'true')` on it; a slot
537
+ carries `data-invalid`, not `aria-invalid`.
538
+
530
539
  **Emits:** `update:modelValue` with `string` (the digits typed so far).
531
540
  **Form wrapper:** `OtpFormField` — `name` inside a vee-validate form, see
532
541
  [forms](../guides/forms.md#the-pair) and [what each field emits](../guides/forms.md#what-each-field-emits).
533
- **Storybook:** `Form fields/OtpField` · **React:** `OtpField`, `OtpFormField`, `InputOTP` (same parts)
542
+ **Storybook:** `Form fields/OtpField`, `Form fields/OtpField/Phone` · **React:** `OtpField`, `OtpFormField`, `InputOTP` (same parts)
534
543
 
535
544
  ---
536
545
 
@@ -818,7 +827,7 @@ a screen reader says "Notifications, switch, Email me when an order ships". An
818
827
  `aria-label` or `aria-labelledby` you pass wins, and an `aria-describedby` you
819
828
  pass is kept beside the row text. The `helper-text` (the validation message,
820
829
  under `SwitchFormField`) describes the switch too. With `required`, the label's
821
- asterisk is hidden from screen readers, so the name stays "Terms", not
830
+ asterisk is drawn by CSS, so the name and the label text stay "Terms", not
822
831
  "Terms*". Without an `id` one is generated. The row is
823
832
  tinted while the switch is on, from `v-model` or, uncontrolled, from
824
833
  `default-value` and each toggle, and carries `data-checked="true"` /