@nxgt/material 2.15.0 → 2.17.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 (113) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +9 -4
  3. package/dist/{_Uint8Array-QCcRMhXd.js → _Uint8Array-C7apDrb7.js} +1 -1
  4. package/dist/{_overRest-C21ZmKyH.js → _overRest-CCAbV-eT.js} +1 -1
  5. package/dist/async-slice-Cuekts5f.js +459 -0
  6. package/dist/{blockDiagram-BEXU5L5S-BozTHxCb.js → blockDiagram-BEXU5L5S-K2MjXCm9.js} +2 -2
  7. package/dist/{chunk-3FUC2YCW-zWkkqKwC.js → chunk-3FUC2YCW-6KyD6CAZ.js} +2 -2
  8. package/dist/{chunk-7M6MHVWA-CGHIsno5.js → chunk-7M6MHVWA-CNWcBB_6.js} +1 -1
  9. package/dist/{chunk-GNY47TPC-BWB3R7nr.js → chunk-GNY47TPC-DdqgSBxM.js} +4 -4
  10. package/dist/{chunk-NETBCI7D-DwmNwFxb.js → chunk-NETBCI7D-ZnunhYtw.js} +1 -1
  11. package/dist/{chunk-Z7XXMR3K-0DGn7Rxi.js → chunk-Z7XXMR3K-C8MhuVIi.js} +1 -1
  12. package/dist/{classDiagram-v2-NBCMYWYE-BERam5ND.js → classDiagram-v2-NBCMYWYE-ByUAGkfa.js} +2 -2
  13. package/dist/components/components.css +1 -0
  14. package/dist/components/print-only.css +44 -0
  15. package/dist/{components-B-xPQ6XX.js → components-CIYQrI45.js} +59481 -58962
  16. package/dist/components.js +6 -6
  17. package/dist/{dagre-6A5THRUB-CnIZQoNt.js → dagre-6A5THRUB-C2vz5UY9.js} +64 -64
  18. package/dist/{diagram-22UHCM2B-DN4-okl5.js → diagram-22UHCM2B-DREk9ku4.js} +2 -2
  19. package/dist/{diagram-3UASUU5V-DcrNf7TY.js → diagram-3UASUU5V-CYkPaos5.js} +1 -1
  20. package/dist/dnd.js +1 -1
  21. package/dist/{elk-276RUBZZ-B7yctkbs.js → elk-276RUBZZ-CK4KoCtK.js} +3 -3
  22. package/dist/{erDiagram-OPXOYQCR-BVrTNrkn.js → erDiagram-OPXOYQCR-CTfn8QUl.js} +2 -2
  23. package/dist/{field-blur-DDfQkuSJ.js → field-blur-3RW0mJb2.js} +2 -2
  24. package/dist/{flowDiagram-KWPJA3E3-BKxvqo0x.js → flowDiagram-KWPJA3E3-CDzuzre6.js} +3 -3
  25. package/dist/{ganttDiagram-FUAMR5RP-Cv9W4LOS.js → ganttDiagram-FUAMR5RP-Ci-9ZJz5.js} +1 -1
  26. package/dist/{graphlib-CbcZi5bx.js → graphlib-DxjKaXeR.js} +42 -42
  27. package/dist/hooks.js +38 -26
  28. package/dist/{i18n-provider-D5hkauu6.js → i18n-provider-Dv9ZSmgO.js} +1 -1
  29. package/dist/i18n.js +6 -6
  30. package/dist/{isArrayLikeObject-COiPdhFP.js → isArrayLikeObject-H8k6W51O.js} +3 -3
  31. package/dist/{isEmpty-B7UlCLih.js → isEmpty-B0Bavg-e.js} +9 -9
  32. package/dist/{journeyDiagram-ZHPQQLJL-BwSv9L1j.js → journeyDiagram-ZHPQQLJL-CwFYd51O.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/chip/chip.d.ts +18 -3
  35. package/dist/lib/components/ui/display/chip/chip.variants.d.ts +10 -0
  36. package/dist/lib/components/ui/display/chip/index.d.ts +1 -0
  37. package/dist/lib/components/ui/display/chip/partials/chip-dismiss-button.d.ts +3 -1
  38. package/dist/lib/components/ui/display/enrolment-secret/enrolment-secret.d.ts +30 -0
  39. package/dist/lib/components/ui/display/enrolment-secret/index.d.ts +2 -0
  40. package/dist/lib/components/ui/display/enrolment-secret/use-enrolment-secret-labels.d.ts +11 -0
  41. package/dist/lib/components/ui/display/index.d.ts +2 -0
  42. package/dist/lib/components/ui/display/recovery-codes/index.d.ts +4 -0
  43. package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-actions.d.ts +14 -0
  44. package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-confirm.d.ts +13 -0
  45. package/dist/lib/components/ui/display/recovery-codes/partials/recovery-codes-list.d.ts +5 -0
  46. package/dist/lib/components/ui/display/recovery-codes/recovery-codes.d.ts +8 -0
  47. package/dist/lib/components/ui/display/recovery-codes/types.d.ts +64 -0
  48. package/dist/lib/components/ui/display/recovery-codes/use-recovery-codes-labels.d.ts +7 -0
  49. package/dist/lib/components/ui/fields/form-section/form-section-classes.d.ts +30 -0
  50. package/dist/lib/components/ui/fields/form-section/form-section.d.ts +25 -0
  51. package/dist/lib/components/ui/fields/form-section/index.d.ts +1 -0
  52. package/dist/lib/components/ui/fields/index.d.ts +1 -0
  53. package/dist/lib/components/ui/forms/index.d.ts +1 -0
  54. package/dist/lib/components/ui/forms/use-code-resolver.d.ts +42 -0
  55. package/dist/lib/components/ui/layout/auth-card/auth-card-notice.d.ts +18 -0
  56. package/dist/lib/components/ui/layout/auth-card/auth-card.d.ts +35 -0
  57. package/dist/lib/components/ui/layout/auth-card/index.d.ts +1 -0
  58. package/dist/lib/components/ui/layout/index.d.ts +1 -0
  59. package/dist/lib/components/ui/selection/select-field/select-field.d.ts +5 -2
  60. package/dist/lib/hooks/index.d.ts +2 -0
  61. package/dist/lib/hooks/use-focus-on-mount.d.ts +24 -0
  62. package/dist/lib/hooks/use-submit-once.d.ts +32 -0
  63. package/dist/lib/i18n/resources/en/en.d.ts +21 -0
  64. package/dist/lib/i18n/resources/en/material.json.d.ts +22 -1
  65. package/dist/lib/i18n/resources/fr/fr.d.ts +21 -0
  66. package/dist/lib/i18n/resources/fr/material.json.d.ts +22 -1
  67. package/dist/lib/i18n/resources/resources.d.ts +42 -0
  68. package/dist/lib/i18n/translator.d.ts +1 -1
  69. package/dist/lib/lib/index.d.ts +1 -0
  70. package/dist/lib.js +58 -102
  71. package/dist/main.js +16 -16
  72. package/dist/map.js +22 -22
  73. package/dist/material.css +1 -1
  74. package/dist/{merge-KEf_u3Zy.js → merge-Js8537dg.js} +4 -4
  75. package/dist/{mermaid.core-D_eXACPr.js → mermaid.core-DW7UlwJu.js} +21 -21
  76. package/dist/{mindmap-definition-NLK3R4M7-GMAPXP2T.js → mindmap-definition-NLK3R4M7-CsLyIyEF.js} +2 -2
  77. package/dist/{pieDiagram-5QR66LMP--qZHRFqG.js → pieDiagram-5QR66LMP-C-dfffj5.js} +1 -1
  78. package/dist/print.js +2 -2
  79. package/dist/{quadrantDiagram-O4NWA36T-BxFGymBB.js → quadrantDiagram-O4NWA36T-DZ0TivLD.js} +1 -1
  80. package/dist/{requirementDiagram-PLB6GJNP-D4aL7HiM.js → requirementDiagram-PLB6GJNP-3brF-h1L.js} +2 -2
  81. package/dist/{sankeyDiagram-IPEJSGJF-zX1FL2aL.js → sankeyDiagram-IPEJSGJF-BY-LLyiZ.js} +1 -1
  82. package/dist/{sortable-provider-C8mr3XP2.js → sortable-provider-CUT5Wfdl.js} +1 -1
  83. package/dist/{stateDiagram-v2-GCMORJYK-CDm1mDv2.js → stateDiagram-v2-GCMORJYK-BGavr7Nm.js} +2 -2
  84. package/dist/{stored-language-g121Yv0w.js → stored-language-BKoohv2_.js} +44 -2
  85. package/dist/{swimlanes-2SLR337P-DNwIhCCu.js → swimlanes-2SLR337P-B6Fkr7-U.js} +3 -3
  86. package/dist/{swimlanesDiagram-TC7HE7FX-BaR9zAzq.js → swimlanesDiagram-TC7HE7FX-Dt6Tqj4h.js} +3 -3
  87. package/dist/{timeline-definition-EJHVYXUP-B6Xd8Spe.js → timeline-definition-EJHVYXUP-CE-3KwYd.js} +1 -1
  88. package/dist/{translate-material-D3Hp_AI5.js → translate-material-CCJg3yfc.js} +2 -2
  89. package/dist/{translator-BMFC0lBV.js → translator-DEde7BKz.js} +1 -1
  90. package/dist/{use-heading-spy-wlOv87q3.js → use-heading-spy-XkSExwoT.js} +67 -59
  91. package/dist/{use-material-translation-BGFp1yXp.js → use-material-translation-Cndoq_iK.js} +2 -2
  92. package/dist/{usecaseDiagram-POWQR4AR-XW5of8Kj.js → usecaseDiagram-POWQR4AR-CPOCe5We.js} +2 -2
  93. package/dist/{xychartDiagram-PMCCYNJV-DTV6AzhO.js → xychartDiagram-PMCCYNJV-Cleaihn8.js} +1 -1
  94. package/docs/components/README.md +3 -3
  95. package/docs/components/data-display.md +111 -2
  96. package/docs/components/data-table-filter-and-lists.md +5 -2
  97. package/docs/components/form-fields.md +78 -0
  98. package/docs/components/navigation-and-layout.md +51 -0
  99. package/docs/components/selection-and-choice.md +2 -1
  100. package/docs/guides/forms.md +111 -0
  101. package/docs/guides/helpers.md +81 -0
  102. package/docs/guides/i18n.md +17 -0
  103. package/docs/guides/printing.md +27 -0
  104. package/docs/previews/data-display-enrolmentsecret.webp +0 -0
  105. package/docs/previews/data-display-recoverycodes.webp +0 -0
  106. package/docs/previews/form-fields-formsection.webp +0 -0
  107. package/docs/previews/navigation-layout-authcard.webp +0 -0
  108. package/docs/roadmap.md +48 -15
  109. package/docs/troubleshooting.md +112 -0
  110. package/lib/components/components.css +1 -0
  111. package/lib/components/print-only.css +44 -0
  112. package/package.json +2 -2
  113. package/dist/async-slice-TBZ6Taw2.js +0 -411
@@ -136,11 +136,12 @@ export function Framework() {
136
136
  | `placeholder` | `ReactNode` | `label` | the trigger's text while nothing is selected |
137
137
  | `required` | `boolean` | — | an asterisk after the label, and Radix Select's `required`; the trigger is named by the label |
138
138
  | `label`, `helperText`, `error` | | — | the field's text and state |
139
+ | `aria-labelledby` | `string` | — | the id of an element that names the trigger when `label` is absent; `label` wins when both are given |
139
140
 
140
141
  **Accessibility:** the trigger is named by `label`, described by the help or
141
142
  error text (`aria-describedby`) and marked `aria-invalid` on error, so
142
143
  `getByRole('combobox', { name: 'Framework' })` finds it and
143
- `toHaveAccessibleDescription(…)` / `toBeInvalid()` match.
144
+ `toHaveAccessibleDescription(…)` / `toBeInvalid()` match. Without a `label`, `aria-labelledby` (the id of another element) names the trigger; when both are given, `label` wins.
144
145
  **Emits:** `string`.
145
146
  **Form wrapper:** `SelectFormField` — `control` and `name`, see
146
147
  [forms](../guides/forms.md#the-pair) and [what each field emits](../guides/forms.md#what-each-field-emits).
@@ -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
@@ -6,21 +6,6 @@ version they shipped in, and planned entries may name the major they target.
6
6
 
7
7
  ## Now
8
8
 
9
- - **A strict Content Security Policy** — a page that sends `script-src` with
10
- a nonce and no `'unsafe-eval'`, and `style-src-elem` with a nonce and no
11
- `'unsafe-inline'`, runs every component with no violation report: a
12
- `NonceProvider` forwards the nonce to each component, the CSS the
13
- dependencies inject moves into the stylesheet, zod becomes external with
14
- schemas built lazily, and the KaTeX fonts ship as files. Done so far:
15
- KaTeX's fonts are files beside the compiled stylesheet instead of 1.4 MB of
16
- `data:` URIs inside it, so `font-src 'self'` needs no `data:`;
17
- `NonceProvider` takes the nonce to `ScrollArea`, `Select`,
18
- `RichTextEditor` and the scroll lock of modals; `Toaster`, `Drawer`,
19
- `InputOTP`, `ImageCropper`, `Spinner` and `PdfReader` inject no `<style>`,
20
- their CSS is in the stylesheet; zod is no longer bundled and no schema is
21
- built at import, so the app's `z.config({ jitless: true })` keeps zod from
22
- probing `new Function`. Today the
23
- [Content Security Policy guide](guides/csp.md) lists what is left to relax.
24
9
  - **Empty and error states that look designed** — `EmptyState` and a new
25
10
  `ErrorState` in three sizes (`default`, `compact`, `inline`), with in-house
26
11
  inline SVG illustrations or a slot for your own. `ErrorState` is a
@@ -32,6 +17,30 @@ version they shipped in, and planned entries may name the major they target.
32
17
 
33
18
  ## Next
34
19
 
20
+ - **One Vite plugin for the whole setup** — `nxgtMaterial()` from
21
+ `@nxgt/material/vite` is the one call that wires the library
22
+ into a Vite app; it is implemented once in `@nxgt/material-core/vite`, and
23
+ `@nxgt/material-vue/vite` is the same plugin.
24
+ Every option is on by default and each can be turned off or refined; the
25
+ helpers that exist today stay exported.
26
+ - `csp`: a nonce placeholder through `html.cspNonce` and
27
+ `applyCspNonce(html, nonce)` to fill it; zod's jitless mode set by an
28
+ inline nonced script in `index.html` (`ZOD_JITLESS_SCRIPT` for a server
29
+ that renders the page itself); and the recommended policy sent by the dev
30
+ server as Report-Only.
31
+ - `fonts`: the KaTeX fonts emitted as files, and `sans: 'inter'` for a
32
+ self-hosted Inter.
33
+ - `styles`: a build that fails with the exact fix when the stylesheet import
34
+ or the `@source` line is missing.
35
+ - `pdf`: pdfkit's standard fonts handled automatically when
36
+ `@react-pdf/renderer` is installed, and `bundle: true` (the Nuxt module's
37
+ `pdf.bundleStandardFonts` today) for a server that has no
38
+ `node_modules`.
39
+ - Nuxt: the Nuxt module takes the same options.
40
+ - On the server, `@nxgt/material-core` gains `createCspNonce()` (Web Crypto)
41
+ and `cspPolicy({ ... })`, which returns `{ directives, header }` as the
42
+ [Content Security Policy guide](guides/csp.md) describes; `NonceProvider`'s
43
+ `nonce` becomes optional on the client. Ships as minors.
35
44
  - **One media player, video and audio** — `MediaPlayer`, `MediaPlayerMini`,
36
45
  `useMediaPlayer` and `AudioPlayer`, built on the native `<video>` / `<audio>`.
37
46
  `StxPlayer` and `VideoPlayer` are removed in `@nxgt/material` 3.0, with a
@@ -118,6 +127,8 @@ version they shipped in, and planned entries may name the major they target.
118
127
 
119
128
  ## Not planned
120
129
 
130
+ - **A Next.js plugin** — the plugin targets Vite only. No supported consumer
131
+ builds with Next.js, and `withNxgtPrint` stays the config helper for it.
121
132
  - **Shipping the Font Awesome Pro sprite sheets** — the Pro licence covers use
122
133
  in your own products, not redistribution inside a library. Serve the sheets
123
134
  from your app; the package's `files` excludes them so a build cannot leak
@@ -146,6 +157,28 @@ version they shipped in, and planned entries may name the major they target.
146
157
 
147
158
  ## Shipped
148
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.
175
+ - **A strict Content Security Policy** — a page that sends `script-src` with a
176
+ nonce and no `'unsafe-eval'`, and `style-src-elem` with a nonce and no
177
+ `'unsafe-inline'`, runs every component with no violation report:
178
+ `NonceProvider` forwards the nonce to each component, the CSS the
179
+ dependencies inject is in the stylesheet, zod is external and its schemas are
180
+ built on first use, and the KaTeX fonts are files. The
181
+ [Content Security Policy guide](guides/csp.md) has the policy. 2.14.0.
149
182
  - **An `Autocomplete` that searches a server** — `onSearch`, `debounceMs`, `minQueryLength`, `filter`, `loading`, `searchError`: a
150
183
  debounced search with an `AbortSignal` whose stale responses are dropped,
151
184
  a polite "Loading…" / "N results" announcement, and local matching that
@@ -62,6 +62,7 @@ words in your error.
62
62
 
63
63
  **Printing**
64
64
 
65
+ - [`RecoveryCodes`' Print button prints the whole page](#recoverycodes-print-button-prints-the-whole-page)
65
66
  - [`Cannot find module '#standard-fonts/Helvetica'`](#cannot-find-module-standard-fontshelvetica)
66
67
  - [`still references '#standard-fonts/' after the transform`](#still-references-standard-fonts-after-the-transform)
67
68
  - [`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 +106,8 @@ words in your error.
105
106
  - [A test asserting the old Zod string length text fails](#a-test-asserting-the-old-zod-string-length-text-fails)
106
107
  - [A calendar's weekday row is in English on a French page](#a-calendars-weekday-row-is-in-english-on-a-french-page)
107
108
  - [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)
109
+ - [Errors keep their old words after a language switch](#errors-keep-their-old-words-after-a-language-switch)
110
+ - [`useCodeResolver` re-validates or resets the form on every render](#usecoderesolver-re-validates-or-resets-the-form-on-every-render)
108
111
 
109
112
  **Runtime**
110
113
 
@@ -132,6 +135,8 @@ words in your error.
132
135
  - [An end-to-end test no longer finds a required field by a name ending in `*`](#an-end-to-end-test-no-longer-finds-a-required-field-by-a-name-ending-in-)
133
136
  - [A test no longer finds a chip's remove button by the exact name `Remove`](#a-test-no-longer-finds-a-chips-remove-button-by-the-exact-name-remove)
134
137
  - [A test no longer finds a pagination, breadcrumb or other built-in label by its English name](#a-test-no-longer-finds-a-pagination-breadcrumb-or-other-built-in-label-by-its-english-name)
138
+ - [The selection count reads in the wrong order after overriding `material.table.pagination.rows-selected`](#the-selection-count-reads-in-the-wrong-order-after-overriding-materialtablepaginationrows-selected)
139
+ - [A test clicks a `FilterChips` chip to remove a filter and nothing happens](#a-test-clicks-a-filterchips-chip-to-remove-a-filter-and-nothing-happens)
135
140
  - [A test no longer finds an icon badge by role `button`, or an `Icon` named "Icon {name}" inside a button](#a-test-no-longer-finds-an-icon-badge-by-role-button-or-an-icon-named-icon-name-inside-a-button)
136
141
  - [A button named "Icon plus Add" is now named "Add"](#a-button-named-icon-plus-add-is-now-named-add)
137
142
  - [`An invalid form control with name='…' is not focusable` (an `UploadField`)](#an-invalid-form-control-with-name-is-not-focusable)
@@ -1103,6 +1108,29 @@ dependencies: `--external @nxgt/material --external @nxgt/material-core`.
1103
1108
 
1104
1109
  ## Printing
1105
1110
 
1111
+ ### `RecoveryCodes`' Print button prints the whole page
1112
+
1113
+ **When:** "Print" on `RecoveryCodes` prints the rest of the page with the
1114
+ codes, or the codes away from the top of the sheet.
1115
+ **Why:** the button only marks the page (`<html data-nxgt-print>`) and the
1116
+ card (`data-nxgt-print-target`); hiding the rest is a `@media print` rule in
1117
+ the package's stylesheet (`print-only.css`). A page that does not load
1118
+ `@nxgt/material/styles.css` (or `dist/material.css`) has no such rule, and an app's own
1119
+ print rule that is `!important` (or a `position` on an ancestor set with
1120
+ `!important`) wins over it.
1121
+ **Fix:** load the stylesheet the setup guide names, and keep your print
1122
+ rules off the marked page:
1123
+
1124
+ ```css
1125
+ /* your app's print rules, everywhere but while one element is printed */
1126
+ @media print {
1127
+ html:not([data-nxgt-print]) .page-header { position: static !important; }
1128
+ }
1129
+ ```
1130
+
1131
+ To print an element of your own the same way, call `printOnly(element)`
1132
+ from `@nxgt/material/lib`; the same rules apply to it.
1133
+
1106
1134
  ### `Cannot find module '#standard-fonts/Helvetica'`
1107
1135
 
1108
1136
  **When:** a built SSR server answers nothing on every route, `/health`
@@ -1837,6 +1865,52 @@ react-day-picker hides the weekday row from assistive tech
1837
1865
  (`<thead aria-hidden>`); in a test, find a day by its full date instead
1838
1866
  (`getByRole('gridcell', { name: 'jeudi 1 octobre 2026' })`).
1839
1867
 
1868
+ ### Errors keep their old words after a language switch
1869
+
1870
+ **When:** a form validated with `useCodeResolver` shows an error, the user
1871
+ switches the language, and the error stays in the old one (a code's words
1872
+ or a zod message alike).
1873
+ **Why:** the resolver follows the new language, but react-hook-form keeps
1874
+ the message it computed until the field is validated again.
1875
+ **Fix:** validate again when the language changes, once the form was
1876
+ submitted:
1877
+
1878
+ ```tsx
1879
+ const resolver = useCodeResolver(schema, { translate });
1880
+ const { trigger, formState } = useForm({ resolver });
1881
+ const { isSubmitted } = formState;
1882
+ useEffect(() => {
1883
+ if (isSubmitted) trigger();
1884
+ }, [resolver, isSubmitted, trigger]); // a new resolver is a new language
1885
+ ```
1886
+
1887
+ Storybook: `Guides/Forms/useCodeResolver`. See
1888
+ [forms](guides/forms.md#wording-errors-by-code).
1889
+
1890
+ ### `useCodeResolver` re-validates or resets the form on every render
1891
+
1892
+ **When:** `translate` or `codeOf` is written inline in the component
1893
+ (`useCodeResolver(schema, { codeOf: (issue) => … })`), or the schema is built
1894
+ in the component body.
1895
+ **Why:** the resolver is memoised on the schema, the language, `translate`
1896
+ and `codeOf`: a new function or schema each render makes a new resolver
1897
+ each render.
1898
+ **Fix:** define the schema, the translator and `codeOf` at module scope (or
1899
+ with `useMemo` / `useCallback` when they depend on props):
1900
+
1901
+ ```tsx
1902
+ import { type CodeIssue, defaultCodeOf, isEmptyRequired, useCodeResolver } from '@nxgt/material/components';
1903
+
1904
+ const schema = z.object({ username: z.string().min(3) });
1905
+ const codeOf = (issue: CodeIssue) =>
1906
+ isEmptyRequired(issue) ? 'errors.required' : defaultCodeOf(issue);
1907
+
1908
+ export function SignUpForm() {
1909
+ const resolver = useCodeResolver(schema, { translate, codeOf });
1910
+ // …
1911
+ }
1912
+ ```
1913
+
1840
1914
  ## Runtime
1841
1915
 
1842
1916
  ### A test asserting the old Zod string length text fails
@@ -2489,6 +2563,44 @@ await expect(page.getByRole('navigation', { name: /^(pagination|Pagination)$/ })
2489
2563
  <Pagination aria-label="Results pages">…</Pagination>
2490
2564
  ```
2491
2565
 
2566
+ ### The selection count reads in the wrong order after overriding `material.table.pagination.rows-selected`
2567
+
2568
+ **When:** after upgrading, the data table's footer reads "of 128 3 rows
2569
+ selected" (or the French "sur 128 3 lignes sélectionnées") because your
2570
+ `I18nProvider` overrides `material.table.pagination.rows-selected` with the old
2571
+ text.
2572
+ **Why:** the message now leads with the count and ends with the total, so
2573
+ "3 rows selected of 128" and "No rows selected of 128" read as a sentence, and
2574
+ the plural covers zero. The old form, `{count} of {total} {count, plural, …}`,
2575
+ put the count and the total first. An override keeps its own text, so it keeps
2576
+ the old order. The same release hides "Page n of m" when the table has no rows.
2577
+ **Fix:** adopt the new ICU form in your override, or delete the override:
2578
+
2579
+ ```json
2580
+ {
2581
+ "material": {
2582
+ "table": {
2583
+ "pagination": {
2584
+ "rows-selected": "{count, plural, =0 {No rows selected} one {# row selected} other {# rows selected}} of {total}"
2585
+ }
2586
+ }
2587
+ }
2588
+ }
2589
+ ```
2590
+
2591
+ ### A test clicks a `FilterChips` chip to remove a filter and nothing happens
2592
+
2593
+ **When:** after upgrading, `getByText('Paris').click()` (or a click on the
2594
+ chip's body) no longer removes the filter.
2595
+ **Why:** the chip was a clickable surface with no role. It is now a label with
2596
+ a real × button, named "Remove <label>" ("Retirer <label>" in French, from
2597
+ `material.chip.dismiss-label`), so a keyboard and a screen reader reach it.
2598
+ **Fix:** click the button:
2599
+
2600
+ ```ts
2601
+ await page.getByRole('button', { name: 'Remove Paris' }).click();
2602
+ ```
2603
+
2492
2604
  ### A test no longer finds an icon badge by role `button`, or an `Icon` named "Icon {name}" inside a button
2493
2605
 
2494
2606
  **When:** after upgrading to 2.2.0, one of these finds nothing:
@@ -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.15.0",
3
+ "version": "2.17.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.0",
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",