@nxgt/material 2.16.0 → 2.18.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 (112) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +9 -4
  3. package/dist/{_Uint8Array-CjLUgWgf.js → _Uint8Array-C7apDrb7.js} +1 -1
  4. package/dist/{_overRest-HfSwfmH1.js → _overRest-CCAbV-eT.js} +1 -1
  5. package/dist/async-slice-Cuekts5f.js +459 -0
  6. package/dist/{blockDiagram-BEXU5L5S-DSNWJcbS.js → blockDiagram-BEXU5L5S-B63zbmDg.js} +2 -2
  7. package/dist/{chunk-3FUC2YCW-CcZvlyvW.js → chunk-3FUC2YCW-CE9T5wr6.js} +2 -2
  8. package/dist/{chunk-7M6MHVWA-21OdxoBq.js → chunk-7M6MHVWA-BjaezAbj.js} +1 -1
  9. package/dist/{chunk-GNY47TPC-BZdgq2LF.js → chunk-GNY47TPC-DjnciAsL.js} +4 -4
  10. package/dist/{chunk-NETBCI7D-CAgvlb0w.js → chunk-NETBCI7D-CxQxB74H.js} +1 -1
  11. package/dist/{chunk-Z7XXMR3K-BSXPgLtN.js → chunk-Z7XXMR3K-BBrCUBwp.js} +1 -1
  12. package/dist/{classDiagram-v2-NBCMYWYE-D_BHO1iT.js → classDiagram-v2-NBCMYWYE-FeyVcYCs.js} +2 -2
  13. package/dist/components/components.css +1 -0
  14. package/dist/components/print-only.css +44 -0
  15. package/dist/{components-Bkdm6jn6.js → components-CsPEBzNF.js} +59383 -58881
  16. package/dist/components.js +6 -6
  17. package/dist/{dagre-6A5THRUB-CsnftAPx.js → dagre-6A5THRUB-BjO_Ytfq.js} +64 -64
  18. package/dist/{diagram-22UHCM2B-D0yPa8vo.js → diagram-22UHCM2B-BBKawlhX.js} +2 -2
  19. package/dist/{diagram-3UASUU5V-f8LIX2sj.js → diagram-3UASUU5V-CKj_37_k.js} +1 -1
  20. package/dist/dnd.js +1 -1
  21. package/dist/{elk-276RUBZZ-DI8bDQAL.js → elk-276RUBZZ-DmkPwZLS.js} +3 -3
  22. package/dist/{erDiagram-OPXOYQCR-BGHFtxmB.js → erDiagram-OPXOYQCR-Dja943M3.js} +2 -2
  23. package/dist/{field-blur-C-5K6U1F.js → field-blur-DNHXOY0J.js} +3 -3
  24. package/dist/{flowDiagram-KWPJA3E3-CnFLTW-3.js → flowDiagram-KWPJA3E3-jmbjSQZS.js} +3 -3
  25. package/dist/{ganttDiagram-FUAMR5RP-B5uyVKE4.js → ganttDiagram-FUAMR5RP-BONNpqJt.js} +1 -1
  26. package/dist/{graphlib-BqO9Go2u.js → graphlib-DxjKaXeR.js} +42 -42
  27. package/dist/hooks.js +38 -26
  28. package/dist/{i18n-provider-C3SlA4J0.js → i18n-provider-Dv9ZSmgO.js} +1 -1
  29. package/dist/i18n.js +6 -6
  30. package/dist/{isArrayLikeObject-83P1fvEs.js → isArrayLikeObject-H8k6W51O.js} +3 -3
  31. package/dist/{isEmpty-C2z3Hypl.js → isEmpty-B0Bavg-e.js} +9 -9
  32. package/dist/{journeyDiagram-ZHPQQLJL-CKtbGcVT.js → journeyDiagram-ZHPQQLJL-C_omtnEz.js} +1 -1
  33. package/dist/lib/components/ui/actions/copy-button/use-copy-status.d.ts +13 -0
  34. package/dist/lib/components/ui/display/enrolment-secret/enrolment-secret.d.ts +30 -0
  35. package/dist/lib/components/ui/display/enrolment-secret/index.d.ts +2 -0
  36. package/dist/lib/components/ui/display/enrolment-secret/use-enrolment-secret-labels.d.ts +11 -0
  37. package/dist/lib/components/ui/display/index.d.ts +2 -0
  38. package/dist/lib/components/ui/display/recovery-codes/index.d.ts +4 -0
  39. package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-actions.d.ts +14 -0
  40. package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-confirm.d.ts +13 -0
  41. package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-list.d.ts +5 -0
  42. package/dist/lib/components/ui/display/recovery-codes/recovery-codes.d.ts +8 -0
  43. package/dist/lib/components/ui/display/recovery-codes/types.d.ts +64 -0
  44. package/dist/lib/components/ui/display/recovery-codes/use-recovery-codes-labels.d.ts +7 -0
  45. package/dist/lib/components/ui/fields/form-section/form-section-classes.d.ts +30 -0
  46. package/dist/lib/components/ui/fields/form-section/form-section.d.ts +25 -0
  47. package/dist/lib/components/ui/fields/form-section/index.d.ts +1 -0
  48. package/dist/lib/components/ui/fields/index.d.ts +1 -0
  49. package/dist/lib/components/ui/forms/index.d.ts +1 -0
  50. package/dist/lib/components/ui/forms/use-code-resolver.d.ts +42 -0
  51. package/dist/lib/components/ui/layout/auth-card/auth-card-notice.d.ts +18 -0
  52. package/dist/lib/components/ui/layout/auth-card/auth-card.d.ts +35 -0
  53. package/dist/lib/components/ui/layout/auth-card/index.d.ts +1 -0
  54. package/dist/lib/components/ui/layout/index.d.ts +1 -0
  55. package/dist/lib/components/ui/selection/autocomplete/types.d.ts +9 -1
  56. package/dist/lib/components/ui/selection/autocomplete/use-autocomplete-status.d.ts +2 -2
  57. package/dist/lib/components/ui/selection/autocomplete/use-autocomplete.d.ts +2 -2
  58. package/dist/lib/hooks/index.d.ts +2 -0
  59. package/dist/lib/hooks/use-focus-on-mount.d.ts +24 -0
  60. package/dist/lib/hooks/use-submit-once.d.ts +32 -0
  61. package/dist/lib/i18n/resources/en/en.d.ts +21 -0
  62. package/dist/lib/i18n/resources/en/material.json.d.ts +21 -0
  63. package/dist/lib/i18n/resources/fr/fr.d.ts +21 -0
  64. package/dist/lib/i18n/resources/fr/material.json.d.ts +21 -0
  65. package/dist/lib/i18n/resources/resources.d.ts +42 -0
  66. package/dist/lib/i18n/translator.d.ts +1 -1
  67. package/dist/lib/lib/index.d.ts +1 -0
  68. package/dist/lib.js +58 -102
  69. package/dist/main.js +16 -16
  70. package/dist/map.js +22 -22
  71. package/dist/material.css +1 -1
  72. package/dist/{merge-CZIEHTTe.js → merge-Js8537dg.js} +4 -4
  73. package/dist/{mermaid.core-Cp75fWA9.js → mermaid.core-9dpDfc6Y.js} +21 -21
  74. package/dist/{mindmap-definition-NLK3R4M7-CFYIlEfW.js → mindmap-definition-NLK3R4M7-CjYayluH.js} +2 -2
  75. package/dist/{pieDiagram-5QR66LMP-CjvtBYDr.js → pieDiagram-5QR66LMP-BfFcf84S.js} +1 -1
  76. package/dist/print.js +2 -2
  77. package/dist/{quadrantDiagram-O4NWA36T-DPj8ndqA.js → quadrantDiagram-O4NWA36T-DdvYjvR0.js} +1 -1
  78. package/dist/{requirementDiagram-PLB6GJNP-DjVFwRJg.js → requirementDiagram-PLB6GJNP-CGG6Y3SN.js} +2 -2
  79. package/dist/{sankeyDiagram-IPEJSGJF-63wsgHUY.js → sankeyDiagram-IPEJSGJF-DJufZkAf.js} +1 -1
  80. package/dist/{sortable-provider-Chbn1x1i.js → sortable-provider-CUT5Wfdl.js} +1 -1
  81. package/dist/{stateDiagram-v2-GCMORJYK-DNc8m371.js → stateDiagram-v2-GCMORJYK-vRvpKK9I.js} +2 -2
  82. package/dist/{stored-language-DPqzxRmf.js → stored-language-BKoohv2_.js} +42 -0
  83. package/dist/{swimlanes-2SLR337P-B7_gOJxx.js → swimlanes-2SLR337P-Czy9f6gp.js} +3 -3
  84. package/dist/{swimlanesDiagram-TC7HE7FX-tqlBrGuu.js → swimlanesDiagram-TC7HE7FX-C6RYBQ-A.js} +3 -3
  85. package/dist/{timeline-definition-EJHVYXUP-DIA9CBZK.js → timeline-definition-EJHVYXUP-CS9RiAiW.js} +1 -1
  86. package/dist/{translate-material-CqB-j9WD.js → translate-material-CCJg3yfc.js} +2 -2
  87. package/dist/{translator-DTl9h__E.js → translator-DEde7BKz.js} +1 -1
  88. package/dist/{use-heading-spy-C1KA3PrF.js → use-heading-spy-XkSExwoT.js} +67 -59
  89. package/dist/{use-material-translation-DMa4Xrnd.js → use-material-translation-Cndoq_iK.js} +2 -2
  90. package/dist/{usecaseDiagram-POWQR4AR-FMjCkoxs.js → usecaseDiagram-POWQR4AR-CW-8YHgN.js} +2 -2
  91. package/dist/{xychartDiagram-PMCCYNJV-B76HOkCh.js → xychartDiagram-PMCCYNJV-G4PopeeF.js} +1 -1
  92. package/docs/components/README.md +3 -3
  93. package/docs/components/data-display.md +101 -0
  94. package/docs/components/feedback-and-status.md +2 -0
  95. package/docs/components/form-fields.md +78 -0
  96. package/docs/components/navigation-and-layout.md +51 -0
  97. package/docs/components/overlays-and-menus.md +18 -0
  98. package/docs/components/selection-and-choice.md +11 -3
  99. package/docs/guides/forms.md +111 -0
  100. package/docs/guides/helpers.md +81 -0
  101. package/docs/guides/i18n.md +17 -0
  102. package/docs/guides/printing.md +27 -0
  103. package/docs/previews/data-display-enrolmentsecret.webp +0 -0
  104. package/docs/previews/data-display-recoverycodes.webp +0 -0
  105. package/docs/previews/form-fields-formsection.webp +0 -0
  106. package/docs/previews/navigation-layout-authcard.webp +0 -0
  107. package/docs/roadmap.md +15 -0
  108. package/docs/troubleshooting.md +141 -0
  109. package/lib/components/components.css +1 -0
  110. package/lib/components/print-only.css +44 -0
  111. package/package.json +2 -2
  112. package/dist/async-slice-CsE3hkCD.js +0 -411
@@ -287,7 +287,7 @@ Parts: `Autocomplete`, `AutocompleteChips` (the chips row it uses in
287
287
  | `mode` | `'single' \| 'multiple'` | — | required; decides the `value` type |
288
288
  | `value` | `string` / `string[]` | — | required, per `mode` |
289
289
  | `onValueChange` | `(value?: string \| null) => void` / `(value?: string[] \| null) => void` | — | per `mode` |
290
- | `options` | `T[]` (`{ value, label }` by default) | `[]` | the items; with `onSearch`, the list before a search and the selected options' labels |
290
+ | `options` | `T[]` (`{ value, label, disabled? }` by default) | `[]` | the items (`disabled` on one: listed, announced as disabled, never picked); with `onSearch`, the list before a search and the selected options' labels |
291
291
  | `getOptionValue`, `getOptionLabel` | `(option: T) => string` | `.value`, `.label` | for other option shapes |
292
292
  | `filter` | `'accent-insensitive' \| 'none' \| (option: T, query: string) => boolean` | `'accent-insensitive'`, `'none'` with `onSearch` | how the list is filtered as the user types: label or value, ignoring case and accents (`e` finds "École") in the provider's language; `'none'` shows `options` as given; a function gets the trimmed query, never an empty one |
293
293
  | `freeSolo` | `boolean` | — | accept a typed value that is not an option |
@@ -297,7 +297,7 @@ Parts: `Autocomplete`, `AutocompleteChips` (the chips row it uses in
297
297
  | `debounceMs` | `number` | `250` | the pause before `onSearch` and `onQueryChange` run |
298
298
  | `onQueryChange` | `(query: string) => void` | — | the query once typing pauses — fetch `options` yourself here |
299
299
  | `loading` | `boolean` | — | with `onQueryChange`: "Loading…" in the list, `aria-busy` on the listbox, announced |
300
- | `searchError` | `ReactNode` | — | with `onQueryChange`: shown and announced in place of the options |
300
+ | `searchError` | `ReactNode` | — | shown and announced in place of the options; with `onSearch` it replaces the generic "The search failed. Try again." |
301
301
  | `empty` | `ReactNode` | "No option found." | text when nothing matches |
302
302
  | `actions` | `ReactNode \| ((query: string) => ReactNode)` | — | buttons at the end of the list ("Create …") |
303
303
  | `required` | `boolean` | — | the asterisk, and `aria-required` on the `combobox` input, which the label names |
@@ -346,7 +346,10 @@ export function PersonField() {
346
346
  With `onSearch`, `minQueryLength` defaults to 1, `debounceMs` to 250 and `filter` to `'none'`
347
347
  (the server filters). A rejected search shows and announces "The search
348
348
  failed. Try again." by itself and hands the error to `onSearchError`; an abort is
349
- never an error. Type your search with `AutocompleteSearch<T>` (its second
349
+ never an error. To say why instead (the API's own refusal, "the service is
350
+ unreachable"), keep the reason in your state from `onSearchError` and pass it as
351
+ `searchError`: it takes precedence over the generic text, in the list and in the
352
+ live region. Type your search with `AutocompleteSearch<T>` (its second
350
353
  argument is `AutocompleteSearchContext`, `{ signal }`); `AutocompleteFilter<T>`
351
354
  and `AutocompleteSearchProps<T>` are exported from `@nxgt/material/components` too.
352
355
 
@@ -354,6 +357,11 @@ To fetch it yourself instead, keep `options` and `loading` in your state,
354
357
  update them from `onQueryChange`, and pass `filter="none"` when the server
355
358
  already filtered.
356
359
 
360
+ A disabled option (`disabled: true` on it, in `options` or in what `onSearch`
361
+ returns) is greyed out, carries `aria-disabled`, and is never picked, by click
362
+ or by Enter, in `single` and `multiple` mode. As in the combobox pattern, the
363
+ arrow keys still reach it so that it is announced; Enter on it does nothing.
364
+
357
365
  **Accessibility.** A polite live region (`role="status"`) says "Loading…",
358
366
  then "3 results" or "No results" (or the failure) once the search answers or
359
367
  typing pauses — never on every keystroke. The listbox is `aria-busy` while
@@ -238,6 +238,38 @@ export function SubscribeForm({
238
238
  - Default values must have the type the field emits. See
239
239
  [What each field emits](#what-each-field-emits).
240
240
 
241
+ ## A long form in sections
242
+
243
+ A long form reads better in titled sections. Lay it out in the form grid
244
+ (`.form-grid-layout`, its fields in `[data-slot="form-fields"]`, its buttons
245
+ in `[data-slot="form-actions"]`) and put each group of fields in a
246
+ [`FormSection`](../components/form-fields.md#formsection):
247
+
248
+ ```tsx
249
+ <form className="form-grid-layout" onSubmit={form.handleSubmit(onSave)}>
250
+ <div data-slot="form-fields">
251
+ <FormSection title="Profile">
252
+ <TextFormField control={form.control} name="firstName" label="First name" />
253
+ <TextFormField control={form.control} name="lastName" label="Last name" />
254
+ </FormSection>
255
+ <FormSection title="Notifications" description="You can change these at any time.">
256
+ <SwitchFormField control={form.control} name="digest" label="Digest" placeholder="Email me a weekly digest" />
257
+ </FormSection>
258
+ </div>
259
+ <div data-slot="form-actions">
260
+ <Button type="submit" loading={form.formState.isSubmitting}>Save</Button>
261
+ </div>
262
+ </form>
263
+ ```
264
+
265
+ - Each section's title is an `h2` (pass `titleAs="h3"` when the form sits
266
+ under an `h2` of your own), so a screen reader moves from section to
267
+ section, and each section is a group named by its title.
268
+ - The sections take a whole row of the grid and lay their fields out in the
269
+ grid's columns, one column on a phone; switches, textareas, chips and file
270
+ fields still take a row of their own. A rule separates each section from the
271
+ one before it (`separated={false}` drops it).
272
+
241
273
  ## Validation messages
242
274
 
243
275
  `zodLocaleError()` from `@nxgt/material/lib` is a Zod v4 error map that
@@ -302,6 +334,85 @@ How it picks the language, and what that implies:
302
334
  `This value must be a number.`); a type with no string of its own says
303
335
  `This value has the wrong type.`
304
336
 
337
+ ### Wording errors by code
338
+
339
+ `zodLocaleError` words zod's own issues; a rule of your own — a name already
340
+ taken, a code the API sends back — has words only your app knows.
341
+ `useCodeResolver(schema, options?)` (`@nxgt/material/components`) is a
342
+ react-hook-form resolver that words both in the page's language: a
343
+ refinement carrying a code in `params.code` reads as your translator's words
344
+ for that code, every other issue as `zodLocaleError`'s. The language is
345
+ `I18nProvider`'s (or the `language` option); the resolver is made once per
346
+ schema and language.
347
+
348
+ ```tsx
349
+ // i18n.ts — the app's translator, over the package's bundle
350
+ import { createTranslator, mergeResources, resources } from '@nxgt/material/i18n';
351
+
352
+ export const appResources = mergeResources(resources, {
353
+ en: { errors: { taken: 'This name is already taken.', tooShort: 'At least {min} characters.' } },
354
+ fr: { errors: { taken: 'Ce nom est déjà pris.', tooShort: 'Au moins {min} caractères.' } },
355
+ });
356
+ export const translate = createTranslator<string>(appResources);
357
+ ```
358
+
359
+ ```tsx
360
+ // sign-up-form.tsx
361
+ import { useForm } from 'react-hook-form';
362
+ import { z } from 'zod';
363
+ import { Button, TextFormField, useCodeResolver } from '@nxgt/material/components';
364
+ import { translate } from './i18n';
365
+
366
+ const TAKEN = ['admin', 'root'];
367
+
368
+ const schema = z.object({
369
+ // Worded by material's zod messages: "Invalid email address."
370
+ email: z.email(),
371
+ username: z
372
+ .string()
373
+ .refine((name) => name.length >= 3, { params: { code: 'errors.tooShort', min: 3 } })
374
+ .refine((name) => !TAKEN.includes(name), { params: { code: 'errors.taken' } }),
375
+ });
376
+
377
+ export function SignUpForm({ save }: { save: (values: z.output<typeof schema>) => Promise<void> }) {
378
+ const resolver = useCodeResolver(schema, { translate });
379
+ const { control, handleSubmit } = useForm({ resolver, defaultValues: { email: '', username: '' } });
380
+ return (
381
+ <form noValidate onSubmit={handleSubmit(save)}>
382
+ <TextFormField control={control} name="email" label="Email" />
383
+ <TextFormField control={control} name="username" label="Username" />
384
+ <Button type="submit">Sign up</Button>
385
+ </form>
386
+ );
387
+ }
388
+ ```
389
+
390
+ - **The codes.** `translate(code, params, language)`: `params` is the
391
+ refinement's whole `params`, so `{ code, min: 3 }` fills `{min}`. A key
392
+ your translator returns verbatim (what `createTranslator` does with a key
393
+ it lacks) has no words: the issue falls back to `zodLocaleError`.
394
+ - **The rest.** The same `translate` is handed to `zodLocaleError`, so your
395
+ `material.zod.*` overrides apply. Without `translate`, every issue is
396
+ worded by the package's own messages.
397
+ - **Another code.** `codeOf` picks the code an issue carries. Its default is
398
+ `defaultCodeOf` (a custom issue's `params.code`); to word every empty
399
+ required field by one code of yours, use `isEmptyRequired` (both, and
400
+ the type `CodeIssue`, are exported beside `useCodeResolver` from
401
+ `@nxgt/material/components`, and from `@nxgt/material-core/code-errors`):
402
+ `codeOf: (issue) => (isEmptyRequired(issue) ? 'errors.required' : defaultCodeOf(issue))`.
403
+ - **Stable options.** Pass `translate` and `codeOf` from module scope: a new
404
+ function each render makes a new resolver.
405
+ - **After a language switch** an error already on screen keeps its words
406
+ until the field is validated again: call `trigger()` when the language
407
+ changes and the form was submitted (see
408
+ [troubleshooting](../troubleshooting.md#errors-keep-their-old-words-after-a-language-switch)).
409
+ - Outside a hook, `createCodeErrorMap({ translate, codeOf?, fallback })` (also
410
+ exported beside `useCodeResolver`) is the error map itself, for
411
+ `schema.safeParse(values, { error })`.
412
+
413
+ Storybook: `Guides/Forms/useCodeResolver`. Vue: the same name in
414
+ `@nxgt/material-vue/components`, returning a `validationSchema` for vee-validate.
415
+
305
416
  ## What each field emits
306
417
 
307
418
  The value a wrapper writes into the form, read from each field's `onChange`
@@ -15,6 +15,7 @@ differences are listed under each table.
15
15
  | `cn(...classes)` | `clsx` + `tailwind-merge`: joins conditional classes, last Tailwind utility wins. |
16
16
  | `clientOnly(Component)` | Wraps a component so it renders nothing on the server and mounts only on the client. See [SSR](ssr.md). |
17
17
  | `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). |
18
+ | `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). |
18
19
  | `renderSlot(slot, context)` | Resolves a `ContentSlot`: a `ReactNode` or a `(context) => ReactNode`. |
19
20
  | `DATE_UTILS` | `format(iso, 'PP', locale?)` (`'-'` when empty), `parseFromTimestring('HH:mm')`, `parseFromISOString`, `formatDateRangeFilter`. |
20
21
  | `formatDateRangeFilter(range)` | Turns a day-picker range into `{ from, to }` ISO strings at start / end of day; `undefined` when empty. |
@@ -131,6 +132,9 @@ Vue: the Redux helpers are Pinia store factories: `defineLocalStore`
131
132
  | `useSearchParam(name)` | One query-string value from the router (`react-router`). |
132
133
  | `useNavigationHandler(to, options?)`, `useNavigationHandlers(routes)` | Stable click handlers that navigate (`react-router`). |
133
134
  | `useAllowedTransitions(status, transitions, configs)` | Status-machine actions (icon, colour, label, dialog copy) allowed from the current status. |
135
+ | `useFocusOnMount<T>(focus = true)` | A ref whose element takes focus once mounted, and again whenever `focus` turns true: the heading of what replaced the content the user acted on. See [below](#usefocusonmount). |
136
+ | `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` back to false). See [below](#usesubmitonce). |
137
+ | `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. |
134
138
 
135
139
  ```tsx
136
140
  import { useState } from 'react';
@@ -168,6 +172,83 @@ the component, or with `useCallback`) when the list is long.
168
172
 
169
173
  Vue: `@nxgt/material-vue/composables`, same names.
170
174
 
175
+ ### `useFocusOnMount`
176
+
177
+ When what the user acted on is replaced — a wizard's next step, an end state
178
+ such as "Message sent" — the button that had focus is gone, and focus falls
179
+ back to the top of the page. Put the ref on the new content's heading: it
180
+ takes focus once mounted, a screen reader reads it, and Tab goes on from
181
+ there. A heading is not focusable by itself: give it `tabIndex={-1}` (a
182
+ button, a link or a field needs nothing).
183
+
184
+ ```tsx
185
+ import { useFocusOnMount } from '@nxgt/material/hooks';
186
+
187
+ export function Sent({ email }: { email: string }) {
188
+ const title = useFocusOnMount<HTMLHeadingElement>();
189
+ return (
190
+ <section aria-labelledby="sent-title">
191
+ <h2 id="sent-title" ref={title} tabIndex={-1}>
192
+ Message sent
193
+ </h2>
194
+ <p>We replied to {email}.</p>
195
+ </section>
196
+ );
197
+ }
198
+ ```
199
+
200
+ `useFocusOnMount(open)` focuses again whenever `open` turns true, for content
201
+ that stays mounted and is only shown. Storybook: `Guides/Hooks/useFocusOnMount`.
202
+
203
+ ### `useSubmitOnce`
204
+
205
+ A disabled or `loading` button only stops clicks once it has rendered, and
206
+ never stops Enter in a field: a fast double click, or Enter twice, posts the
207
+ form twice — and a second request can abort the first mid-call. The hook
208
+ returns a wrapper that lets one call through until the request ends; a
209
+ dropped call's event has its default prevented, so it never falls back to
210
+ the browser's own submission.
211
+
212
+ ```tsx
213
+ import { useForm } from 'react-hook-form';
214
+ import { Button, TextFormField } from '@nxgt/material/components';
215
+ import { useSubmitOnce } from '@nxgt/material/hooks';
216
+
217
+ type Values = { email: string };
218
+
219
+ export function NewsletterForm({ save }: { save: (values: Values) => Promise<void> }) {
220
+ const { control, handleSubmit, formState } = useForm<Values>({ defaultValues: { email: '' } });
221
+ const submitOnce = useSubmitOnce();
222
+ return (
223
+ // handleSubmit returns a promise: the guard opens again when `save` ends.
224
+ <form onSubmit={submitOnce(handleSubmit(save))}>
225
+ <TextFormField control={control} name="email" label="Email" />
226
+ <Button type="submit" loading={formState.isSubmitting}>
227
+ Subscribe
228
+ </Button>
229
+ </form>
230
+ );
231
+ }
232
+ ```
233
+
234
+ **Return the request's promise, or pass `busy`.** The guard opens again:
235
+
236
+ - when the handler's promise ends, fulfilled or rejected (a rejection still
237
+ reaches the caller);
238
+ - with `busy` passed (a router's form submission, followed by its navigation
239
+ state: `useSubmitOnce(navigation.state !== 'idle')`), once it was true and
240
+ is false again. It settles the submit that was on its way when `busy`
241
+ rose, never a newer one, and a handler that returns no promise waits for
242
+ it;
243
+ - without `busy`, as soon as a handler returns something that is not a
244
+ promise: one that returned early (`if (!valid) return;`) or did its work
245
+ synchronously does not lock the form.
246
+
247
+ Outside React, `createSubmitGuard()` from `@nxgt/material-core/submit-guard`
248
+ (also re-exported here) is the same guard: `once(run)` wraps a handler, `settle()` opens it by hand, and
249
+ `busySettler(guard)` returns the function to call with each value of a busy
250
+ flag. Storybook: `Guides/Hooks/useSubmitOnce`.
251
+
171
252
  ## `@nxgt/material/i18n`
172
253
 
173
254
  | Export | What it does |
@@ -299,6 +299,19 @@ fichier.") when it cannot show it, `material.file-preview.download`
299
299
  "Seuls les premiers {size} de ce fichier sont affichés.") under a text cut
300
300
  by the size cap; the text or Markdown it shows scrolls in a region named by
301
301
  `material.file-preview.content` ("Contents of {name}", "Contenu de {name}").
302
+ `EnrolmentSecret` reads `material.enrolment-secret.qr-label` ("QR code to
303
+ scan with your authenticator app"), `open-app` ("Open in your authenticator
304
+ app", "Ouvrir dans votre application d'authentification"), `manual` ("Or
305
+ type this key in the app:"), `secret-label` ("Setup key", "Clé de
306
+ configuration"), `copy` ("Copy the key") and `copied` ("Key copied", "Clé
307
+ copiée"); each has a prop that overrides it. `RecoveryCodes` reads
308
+ `material.recovery-codes.title` and `list` ("Recovery codes", "Codes de
309
+ récupération"), `warning` ("Each code works once. Keep them somewhere safe:
310
+ they are shown only now."), `copy`, `copied` and `copy-failed` ("Copy codes",
311
+ "Codes copied", "Copy failed"), `download`, `print`, `saved` ("I have saved
312
+ these codes", "J'ai enregistré ces codes"), `saved-required` ("Tick the box
313
+ once your codes are safe") and `done`; `title`, `warning` and `labels`
314
+ override them. `AuthCard` has no copy of its own.
302
315
  `material.carousel.roledescription` /
303
316
  `slide` and `material.attributes.buttons.add-option` are there for
304
317
  `@nxgt/material-vue`'s carousel and attribute options, so the bundles hold
@@ -407,6 +420,10 @@ z.config({
407
420
 
408
421
  Examples and per-field overrides are in [forms](forms.md#validation-messages).
409
422
 
423
+ Your own rules' messages — a refinement carrying an error code in
424
+ `params.code` — are worded by your translator, in the provider's language,
425
+ with `useCodeResolver`: see [forms](forms.md#wording-errors-by-code).
426
+
410
427
  ## Server rendering
411
428
 
412
429
  On the server there is no `localStorage`, so without help the server renders
@@ -1397,3 +1397,30 @@ Under pnpm the glob is
1397
1397
  `pdfkitStandardFonts()` is a Vite plugin and does not apply to Next.js.
1398
1398
 
1399
1399
  More on what a server bundle evaluates in [SSR](ssr.md).
1400
+
1401
+ ## Printing one element of the page
1402
+
1403
+ `RecoveryCodes`' "Print" button prints its card and nothing else, with
1404
+ `window.print()`, no inline style and no new window. The same helper prints
1405
+ any element of yours: `printOnly(element, name?)` from
1406
+ `@nxgt/material/lib` marks `<html data-nxgt-print="<name>">` and
1407
+ the element (`data-nxgt-print-target`), opens the print dialog, and removes
1408
+ both once printing is over (on `afterprint`, or as soon as `print()` returns
1409
+ where it blocks). The rules that hide the rest of the page are in the
1410
+ package's `styles.css` (`print-only.css`), under `@media print`: everything but the element,
1411
+ what holds it and what it holds is taken out of the flow, and the element
1412
+ starts at the top left of the first sheet.
1413
+
1414
+ ```tsx
1415
+ import { printOnly } from '@nxgt/material/lib';
1416
+
1417
+ const stop = printOnly(document.getElementById('ticket') as HTMLElement, 'ticket');
1418
+ // When the element unmounts before the dialog closed (a browser whose
1419
+ // print() returns at once and never fires afterprint):
1420
+ stop();
1421
+ ```
1422
+
1423
+ Hide the controls inside the element with Tailwind's `print:hidden`. If the
1424
+ rest of the page still prints, an `!important` print rule of the app's wins
1425
+ over the package's: see
1426
+ [troubleshooting](../troubleshooting.md#recoverycodes-print-button-prints-the-whole-page).
package/docs/roadmap.md CHANGED
@@ -157,6 +157,21 @@ version they shipped in, and planned entries may name the major they target.
157
157
 
158
158
  ## Shipped
159
159
 
160
+ - **Signed-out pages and two-step verification** — `AuthCard`, the frame of a
161
+ sign-in page (one `main`, the card's title its `h1`, a status or alert
162
+ notice, safe-area padding); `EnrolmentSecret`, a TOTP enrolment's QR code,
163
+ app link and key grouped by four; `RecoveryCodes`, one-time codes to copy,
164
+ download or print alone, with an "I have saved these codes" confirmation
165
+ (`saved` / `onSavedChange`) an app's router guard can follow. 2.17.0.
166
+ - **Focus after a change, one submit at a time, errors worded by code** —
167
+ `useFocusOnMount` focuses the heading of what replaced the content the user
168
+ acted on; `useSubmitOnce` drops a double click or a second Enter until the
169
+ request ends; `useCodeResolver` words a zod schema's errors in the page's
170
+ language, a refinement's `params.code` in the app's own words. 2.17.0.
171
+ - **Long forms in titled sections** — `FormSection`: a heading (an `h2`, or
172
+ the level `titleAs` names), a description and actions, its fields in the form
173
+ grid's columns and one column on a phone, a rule between sections, and a
174
+ group a screen reader announces by its title. 2.17.0.
160
175
  - **A strict Content Security Policy** — a page that sends `script-src` with a
161
176
  nonce and no `'unsafe-eval'`, and `style-src-elem` with a nonce and no
162
177
  `'unsafe-inline'`, runs every component with no violation report:
@@ -27,6 +27,7 @@ words in your error.
27
27
  - [Dialogs, toasts or buttons no longer animate](#dialogs-toasts-or-buttons-no-longer-animate)
28
28
  - [A Content-Security-Policy reports a `data:` image on every form with a required field](#a-content-security-policy-reports-a-data-image-on-every-form-with-a-required-field)
29
29
  - [A Button's label overflows a card on a phone](#a-buttons-label-overflows-a-card-on-a-phone)
30
+ - [A dialog or sheet scrolls, or clips a child, after upgrading](#a-dialog-or-sheet-scrolls-or-clips-a-child-after-upgrading)
30
31
 
31
32
  **Feedback**
32
33
 
@@ -62,6 +63,7 @@ words in your error.
62
63
 
63
64
  **Printing**
64
65
 
66
+ - [`RecoveryCodes`' Print button prints the whole page](#recoverycodes-print-button-prints-the-whole-page)
65
67
  - [`Cannot find module '#standard-fonts/Helvetica'`](#cannot-find-module-standard-fontshelvetica)
66
68
  - [`still references '#standard-fonts/' after the transform`](#still-references-standard-fonts-after-the-transform)
67
69
  - [`TypeError: (0 , t.createContext) is not a function` in a Next.js route handler](#typeerror-0--tcreatecontext-is-not-a-function-in-a-nextjs-route-handler)
@@ -105,6 +107,8 @@ words in your error.
105
107
  - [A test asserting the old Zod string length text fails](#a-test-asserting-the-old-zod-string-length-text-fails)
106
108
  - [A calendar's weekday row is in English on a French page](#a-calendars-weekday-row-is-in-english-on-a-french-page)
107
109
  - [An accessibility audit reports low contrast on a calendar's weekday row or its days](#an-accessibility-audit-reports-low-contrast-on-a-calendars-weekday-row-or-its-days)
110
+ - [Errors keep their old words after a language switch](#errors-keep-their-old-words-after-a-language-switch)
111
+ - [`useCodeResolver` re-validates or resets the form on every render](#usecoderesolver-re-validates-or-resets-the-form-on-every-render)
108
112
 
109
113
  **Runtime**
110
114
 
@@ -148,6 +152,8 @@ words in your error.
148
152
  - [An `Autocomplete` lists more options than before, or "ecole" finds "École"](#an-autocomplete-lists-more-options-than-before-or-ecole-finds-école)
149
153
  - [`onQueryChange` runs more often after upgrading](#onquerychange-runs-more-often-after-upgrading)
150
154
  - [An older search's results replace the newer ones in an `Autocomplete`](#an-older-searchs-results-replace-the-newer-ones-in-an-autocomplete)
155
+ - [An `Autocomplete` option does nothing on click or Enter](#an-autocomplete-option-does-nothing-on-click-or-enter)
156
+ - ["The search failed. Try again." no longer shows in an `Autocomplete`](#the-search-failed-try-again-no-longer-shows-in-an-autocomplete)
151
157
  - [A test no longer finds `aria-invalid` on an `OtpField` slot](#a-test-no-longer-finds-aria-invalid-on-an-otpfield-slot)
152
158
  - [A test no longer finds the breadcrumb's home button](#a-test-no-longer-finds-the-breadcrumbs-home-button)
153
159
  - [A test no longer finds a sidebar entry by the `button` role](#a-test-no-longer-finds-a-sidebar-entry-by-the-button-role)
@@ -613,6 +619,27 @@ your own through `className`. With `asChild`, put the label in a `<span>`:
613
619
  </Button>
614
620
  ```
615
621
 
622
+ ### A dialog or sheet scrolls, or clips a child, after upgrading
623
+
624
+ **When:** after upgrading, a tall `DialogContent`, `AlertDialogContent` or
625
+ `SheetContent` shows a scrollbar, or a child that stuck out of it (a popover, a
626
+ focus ring, a wide table) is clipped.
627
+ **Why:** these contents are now capped to the viewport (`max-h-[calc(100dvh-2rem)]`,
628
+ `100vh` where `dvh` is unsupported; a top or bottom sheet is capped at `100dvh`)
629
+ and carry `overflow-y-auto`, so a long body scrolls inside them instead of
630
+ leaving the title off-screen. Setting `overflow-y` makes `overflow-x` clip or
631
+ scroll too.
632
+ **Fix:** to keep the old behaviour on one dialog, remove the cap and the scroll
633
+ with `className="max-h-none overflow-visible"`; to change the cap, pass your own
634
+ `max-h-*` (it wins on browsers with `dvh` support):
635
+
636
+ ```tsx
637
+ <DialogContent className="max-h-none overflow-visible">
638
+ ```
639
+
640
+ For a dialog that should grow taller than the screen and scroll inside the
641
+ overlay instead of capping, use `DialogScrollContent`.
642
+
616
643
  ## Feedback
617
644
 
618
645
  ### A toast with an action never goes away
@@ -1105,6 +1132,29 @@ dependencies: `--external @nxgt/material --external @nxgt/material-core`.
1105
1132
 
1106
1133
  ## Printing
1107
1134
 
1135
+ ### `RecoveryCodes`' Print button prints the whole page
1136
+
1137
+ **When:** "Print" on `RecoveryCodes` prints the rest of the page with the
1138
+ codes, or the codes away from the top of the sheet.
1139
+ **Why:** the button only marks the page (`<html data-nxgt-print>`) and the
1140
+ card (`data-nxgt-print-target`); hiding the rest is a `@media print` rule in
1141
+ the package's stylesheet (`print-only.css`). A page that does not load
1142
+ `@nxgt/material/styles.css` (or `dist/material.css`) has no such rule, and an app's own
1143
+ print rule that is `!important` (or a `position` on an ancestor set with
1144
+ `!important`) wins over it.
1145
+ **Fix:** load the stylesheet the setup guide names, and keep your print
1146
+ rules off the marked page:
1147
+
1148
+ ```css
1149
+ /* your app's print rules, everywhere but while one element is printed */
1150
+ @media print {
1151
+ html:not([data-nxgt-print]) .page-header { position: static !important; }
1152
+ }
1153
+ ```
1154
+
1155
+ To print an element of your own the same way, call `printOnly(element)`
1156
+ from `@nxgt/material/lib`; the same rules apply to it.
1157
+
1108
1158
  ### `Cannot find module '#standard-fonts/Helvetica'`
1109
1159
 
1110
1160
  **When:** a built SSR server answers nothing on every route, `/health`
@@ -1839,6 +1889,52 @@ react-day-picker hides the weekday row from assistive tech
1839
1889
  (`<thead aria-hidden>`); in a test, find a day by its full date instead
1840
1890
  (`getByRole('gridcell', { name: 'jeudi 1 octobre 2026' })`).
1841
1891
 
1892
+ ### Errors keep their old words after a language switch
1893
+
1894
+ **When:** a form validated with `useCodeResolver` shows an error, the user
1895
+ switches the language, and the error stays in the old one (a code's words
1896
+ or a zod message alike).
1897
+ **Why:** the resolver follows the new language, but react-hook-form keeps
1898
+ the message it computed until the field is validated again.
1899
+ **Fix:** validate again when the language changes, once the form was
1900
+ submitted:
1901
+
1902
+ ```tsx
1903
+ const resolver = useCodeResolver(schema, { translate });
1904
+ const { trigger, formState } = useForm({ resolver });
1905
+ const { isSubmitted } = formState;
1906
+ useEffect(() => {
1907
+ if (isSubmitted) trigger();
1908
+ }, [resolver, isSubmitted, trigger]); // a new resolver is a new language
1909
+ ```
1910
+
1911
+ Storybook: `Guides/Forms/useCodeResolver`. See
1912
+ [forms](guides/forms.md#wording-errors-by-code).
1913
+
1914
+ ### `useCodeResolver` re-validates or resets the form on every render
1915
+
1916
+ **When:** `translate` or `codeOf` is written inline in the component
1917
+ (`useCodeResolver(schema, { codeOf: (issue) => … })`), or the schema is built
1918
+ in the component body.
1919
+ **Why:** the resolver is memoised on the schema, the language, `translate`
1920
+ and `codeOf`: a new function or schema each render makes a new resolver
1921
+ each render.
1922
+ **Fix:** define the schema, the translator and `codeOf` at module scope (or
1923
+ with `useMemo` / `useCallback` when they depend on props):
1924
+
1925
+ ```tsx
1926
+ import { type CodeIssue, defaultCodeOf, isEmptyRequired, useCodeResolver } from '@nxgt/material/components';
1927
+
1928
+ const schema = z.object({ username: z.string().min(3) });
1929
+ const codeOf = (issue: CodeIssue) =>
1930
+ isEmptyRequired(issue) ? 'errors.required' : defaultCodeOf(issue);
1931
+
1932
+ export function SignUpForm() {
1933
+ const resolver = useCodeResolver(schema, { translate, codeOf });
1934
+ // …
1935
+ }
1936
+ ```
1937
+
1842
1938
  ## Runtime
1843
1939
 
1844
1940
  ### A test asserting the old Zod string length text fails
@@ -2837,6 +2933,51 @@ an older query, even from an API that ignores the signal:
2837
2933
  />
2838
2934
  ```
2839
2935
 
2936
+ ### An `Autocomplete` option does nothing on click or Enter
2937
+
2938
+ **When:** one option of an `Autocomplete` is greyed out and clicking it, or
2939
+ pressing Enter on it, changes nothing, with no error.
2940
+ **Why:** that option has `disabled: true` (in the local `options` or in what
2941
+ `onSearch` returns). It is listed and announced with `aria-disabled`, and never
2942
+ picked, in `single` and `multiple` mode; the arrow keys still reach a disabled option so that it is announced, but Enter does not pick it.
2943
+ **Fix:** drop `disabled` from the option, or build it only for the options that
2944
+ may be chosen:
2945
+
2946
+ ```tsx
2947
+ <Autocomplete
2948
+ mode="single"
2949
+ value={value}
2950
+ onValueChange={setValue}
2951
+ options={[
2952
+ { value: 'a', label: 'Alpha', disabled: true }, // listed, never picked
2953
+ { value: 'b', label: 'Beta' },
2954
+ ]}
2955
+ />
2956
+ ```
2957
+
2958
+ ### "The search failed. Try again." no longer shows in an `Autocomplete`
2959
+
2960
+ **When:** after upgrading, an `Autocomplete` whose `onSearch` rejects shows
2961
+ your own text, or nothing generic, where it used to say "The search failed. Try
2962
+ again."
2963
+ **Why:** `searchError` now takes precedence over the generic text with `onSearch`
2964
+ too, in the list and in the live announcement. The generic text only shows
2965
+ while `searchError` is unset.
2966
+ **Fix:** nothing, if the reason is what you want. To get the generic text back,
2967
+ leave `searchError` unset or empty; to show a reason, keep it from `onSearchError` and pass it
2968
+ back:
2969
+
2970
+ ```tsx
2971
+ <Autocomplete
2972
+ mode="single"
2973
+ value={value}
2974
+ onValueChange={setValue}
2975
+ onSearch={search}
2976
+ onSearchError={() => setReason('Directory unreachable')}
2977
+ searchError={reason}
2978
+ />
2979
+ ```
2980
+
2840
2981
  ### A test no longer finds `aria-invalid` on an `OtpField` slot
2841
2982
 
2842
2983
  **When:** `[data-slot="input-otp-slot"][aria-invalid]` (or
@@ -29,6 +29,7 @@
29
29
  @import "./ui/layout/app-bar/bottom-app-bar.css";
30
30
  @import "./ui/primitives/label/label.css";
31
31
  @import "./ui/feedback/toast/toast.css";
32
+ @import "./print-only.css";
32
33
  @import "./ui/fields/contacts/contact-item.css";
33
34
  @import "./ui/fields/opening-hours/opening-hours-item.css";
34
35
  @import "./ui/fields/attributes/attribute-form/attribute-form.css";
@@ -0,0 +1,44 @@
1
+ /* Printing one element of the page (`printOnly`, from `/lib` or
2
+ * @nxgt/material-core/print-only; RecoveryCodes' Print button): while
3
+ * <html data-nxgt-print> is set, print media shows the element marked
4
+ * data-nxgt-print-target and nothing else. No inline style: only these rules,
5
+ * the same in @nxgt/material and @nxgt/material-vue.
6
+ *
7
+ * Everything that neither is, holds nor sits inside the target is taken out
8
+ * of the flow, so the page prints no blank sheets after it; where :has() is
9
+ * not supported, that rule is dropped and `visibility` alone hides the rest.
10
+ * The target's ancestors stop positioning it, so it starts at the top left of
11
+ * the first page. Unlayered, these rules beat the components' Tailwind
12
+ * utilities (layered) with no `!important`. */
13
+ @media print {
14
+ html[data-nxgt-print]
15
+ body
16
+ *:not(
17
+ [data-nxgt-print-target],
18
+ [data-nxgt-print-target] *,
19
+ :has([data-nxgt-print-target])
20
+ ) {
21
+ display: none;
22
+ }
23
+
24
+ html[data-nxgt-print] body * {
25
+ visibility: hidden;
26
+ }
27
+
28
+ html[data-nxgt-print] :has([data-nxgt-print-target]) {
29
+ position: static;
30
+ transform: none;
31
+ }
32
+
33
+ html[data-nxgt-print] [data-nxgt-print-target],
34
+ html[data-nxgt-print] [data-nxgt-print-target] * {
35
+ visibility: visible;
36
+ }
37
+
38
+ html[data-nxgt-print] [data-nxgt-print-target] {
39
+ position: absolute;
40
+ inset: 0 0 auto 0;
41
+ margin: 0;
42
+ box-shadow: none;
43
+ }
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/material",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "license": "MIT",
5
5
  "bugs": {
6
6
  "email": "stevegires@gmail.com"
@@ -119,7 +119,7 @@
119
119
  "docs:previews": "vitest run --project previews"
120
120
  },
121
121
  "dependencies": {
122
- "@nxgt/material-core": "^0.9.1",
122
+ "@nxgt/material-core": "^0.10.0",
123
123
  "@base-ui/react": "^1.6.0",
124
124
  "@dnd-kit/core": "^6.3.1",
125
125
  "@dnd-kit/sortable": "^10.0.0",