@nxgt/material 3.67.0 → 3.70.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 (105) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +6 -4
  3. package/dist/{alert-dialog-BgQDxcYh.js → alert-dialog-CBkWG32g.js} +1 -1
  4. package/dist/{async-slice-BCZyWgGr.js → async-slice-CNZ3N55P.js} +1 -1
  5. package/dist/{button-2de_lrix.js → button-D3qzqDY_.js} +1 -1
  6. package/dist/charts.js +3 -3
  7. package/dist/{chip-Cs0uEjpu.js → chip-CMTe4sQd.js} +2 -2
  8. package/dist/{code-editor-BYhBAfcc.js → code-editor-Cs7mz1J-.js} +5 -5
  9. package/dist/code-editor.js +5 -5
  10. package/dist/{code-language-items-xhmbF6wA.js → code-language-items-D_QMgjVy.js} +2 -2
  11. package/dist/{components-BRWaX4Xk.js → components-BbWcozcj.js} +18104 -17600
  12. package/dist/components.js +28 -28
  13. package/dist/{dialog-Co5oksU1.js → dialog-B3BSdRci.js} +4 -4
  14. package/dist/dnd.js +1 -1
  15. package/dist/{dropdown-menu-DdzyvN76.js → dropdown-menu-BaupOWgS.js} +1 -1
  16. package/dist/editor.js +661 -661
  17. package/dist/{empty-state-CQ0CV0kA.js → empty-state-B2Um5UpI.js} +2 -2
  18. package/dist/{field-blur-B2y-bNYi.js → field-blur-1XLyxRPr.js} +82 -82
  19. package/dist/{field-blur-context-BZtwcPUV.js → field-blur-context-XNvCDnMT.js} +1 -1
  20. package/dist/{hooks-DjuYsY-j.js → hooks-BU21Jpaa.js} +2 -2
  21. package/dist/hooks.js +2 -2
  22. package/dist/{hover-card-BeBVy6tB.js → hover-card-Bvm3svX9.js} +1 -1
  23. package/dist/i18n.js +3 -3
  24. package/dist/{icon-badge-DbYsbzwZ.js → icon-badge-wip9bfa2.js} +3 -3
  25. package/dist/{icon-button-Co6ttdv1.js → icon-button-QwIeBTdw.js} +1 -1
  26. package/dist/lib/components/ui/collections/filter/use-filter-field-config.d.ts +3 -3
  27. package/dist/lib/components/ui/fields/choice-list/choice-list-field.d.ts +13 -0
  28. package/dist/lib/components/ui/fields/choice-list/choice-list-form-field.d.ts +19 -0
  29. package/dist/lib/components/ui/fields/choice-list/choice-list-names.d.ts +19 -0
  30. package/dist/lib/components/ui/fields/choice-list/choice-list.utils.d.ts +27 -0
  31. package/dist/lib/components/ui/fields/choice-list/index.d.ts +3 -0
  32. package/dist/lib/components/ui/fields/choice-list/partials/choice-mark.d.ts +18 -0
  33. package/dist/lib/components/ui/fields/choice-list/partials/choice-row.d.ts +19 -0
  34. package/dist/lib/components/ui/fields/choice-list/types.d.ts +44 -0
  35. package/dist/lib/components/ui/fields/contacts/placeholders.d.ts +1 -1
  36. package/dist/lib/components/ui/fields/country-field/index.d.ts +2 -0
  37. package/dist/lib/components/ui/fields/country-field/iso-countries.d.ts +2 -0
  38. package/dist/lib/components/ui/fields/index.d.ts +2 -0
  39. package/dist/lib/components/ui/fields/phone-field/index.d.ts +1 -1
  40. package/dist/lib/components/ui/fields/phone-field/parse-phone.d.ts +2 -0
  41. package/dist/lib/components/ui/fields/phone-field/phone-field.d.ts +8 -1
  42. package/dist/lib/components/ui/fields/phone-field/phone-form-field.d.ts +8 -1
  43. package/dist/lib/components/ui/fields/phone-field/use-phone-value.d.ts +2 -1
  44. package/dist/lib/components/ui/fields/postal-address/utils.d.ts +15 -0
  45. package/dist/lib/components/ui/fields/quantity-field/index.d.ts +3 -0
  46. package/dist/lib/components/ui/fields/quantity-field/quantity-field.d.ts +8 -0
  47. package/dist/lib/components/ui/fields/quantity-field/quantity-form-field.d.ts +8 -0
  48. package/dist/lib/components/ui/fields/quantity-field/quantity-row.d.ts +13 -0
  49. package/dist/lib/components/ui/fields/quantity-field/quantity-unit.d.ts +12 -0
  50. package/dist/lib/components/ui/fields/quantity-field/quantity-value.d.ts +9 -0
  51. package/dist/lib/components/ui/fields/quantity-field/types.d.ts +45 -0
  52. package/dist/lib/components/ui/media/upload/index.d.ts +1 -0
  53. package/dist/lib/components/ui/media/upload/network-upload-field/network-upload-field.d.ts +6 -1
  54. package/dist/lib/components/ui/media/upload/network-upload-field/network-upload-field.errors.d.ts +8 -0
  55. package/dist/lib/components/ui/media/upload/network-upload-field/network-upload-field.utils.d.ts +2 -7
  56. package/dist/lib/components/ui/media/upload/network-upload-field/use-start-upload.d.ts +7 -3
  57. package/dist/lib/components/ui/media/upload/network-upload-field/use-upload-item-handlers.d.ts +2 -1
  58. package/dist/lib/components/ui/media/upload/s3-upload-field/s3-upload-field.d.ts +2 -5
  59. package/dist/lib/components/ui/media/upload/s3-upload-field/s3-upload-field.utils.d.ts +26 -1
  60. package/dist/lib/components/ui/media/upload/upload-error.d.ts +33 -0
  61. package/dist/lib/components/ui/selection/autocomplete/autocomplete-enter.d.ts +18 -0
  62. package/dist/lib/components/ui/selection/autocomplete/partials/autocomplete-chip-input.d.ts +2 -1
  63. package/dist/lib/components/ui/selection/autocomplete/partials/autocomplete-text-input.d.ts +2 -1
  64. package/dist/lib/components/ui/selection/autocomplete/types.d.ts +3 -1
  65. package/dist/lib/components/ui/widgets/education/questions/partials/question-content.d.ts +1 -1
  66. package/dist/lib/components/ui/widgets/education/questions/question-text.d.ts +1 -1
  67. package/dist/lib/components/ui/widgets/education/questions/question.d.ts +1 -1
  68. package/dist/lib/components/ui/widgets/education/questions/types.d.ts +10 -0
  69. package/dist/lib/i18n/resources/en/en.d.ts +20 -0
  70. package/dist/lib/i18n/resources/en/material.json.d.ts +21 -1
  71. package/dist/lib/i18n/resources/fr/fr.d.ts +20 -0
  72. package/dist/lib/i18n/resources/fr/material.json.d.ts +21 -1
  73. package/dist/lib/i18n/resources/resources.d.ts +40 -0
  74. package/dist/lib/i18n/translator.d.ts +1 -1
  75. package/dist/lib.js +1 -1
  76. package/dist/main.js +42 -42
  77. package/dist/map.js +8 -8
  78. package/dist/material.css +1 -1
  79. package/dist/pdf-reader.js +1 -1
  80. package/dist/print.js +1 -1
  81. package/dist/{public-AXwv1h3v.js → public-BuGgOtAl.js} +9 -9
  82. package/dist/{rich-code-block-qSvkA5rI.js → rich-code-block-DdHqzzek.js} +5 -5
  83. package/dist/{sortable-provider-DPk-RVEU.js → sortable-provider-DMyaSfZZ.js} +2 -2
  84. package/dist/{spinner-PdSk7zEs.js → spinner-ByYdcukU.js} +1 -1
  85. package/dist/theme-picker.js +2 -2
  86. package/dist/{theme-toggle-CZQckLDd.js → theme-toggle-DB3aZZnO.js} +1 -1
  87. package/dist/{toolbar-items-BmNYNaRB.js → toolbar-items-DbzIKjo7.js} +3 -3
  88. package/dist/{translate-material-BlCQ6AW9.js → translate-material-Dh7MYkHT.js} +1 -1
  89. package/dist/{translator-ChpeKCO3.js → translator-Byhohi1S.js} +42 -2
  90. package/dist/{typography-C4JSRr2B.js → typography-rUf0mE5a.js} +1 -1
  91. package/dist/{use-chat-composer-OUa9akfM.js → use-chat-composer-uUUTRB3Z.js} +2401 -2343
  92. package/dist/{use-heading-spy-K-HUp4o_.js → use-heading-spy-CVzW2NM3.js} +4 -4
  93. package/dist/{use-material-translation-DkE9J5NE.js → use-material-translation-BAYyZOcH.js} +1 -1
  94. package/dist/{use-return-focus-KypnR7vv.js → use-return-focus-CQAuk9kh.js} +1 -1
  95. package/docs/components/form-fields.md +196 -3
  96. package/docs/components/media-and-files.md +55 -9
  97. package/docs/components/primitives.md +9 -14
  98. package/docs/components/rich-widgets.md +2 -1
  99. package/docs/components/selection-and-choice.md +10 -3
  100. package/docs/guides/forms.md +4 -3
  101. package/docs/previews/form-fields-choicelistfield.webp +0 -0
  102. package/docs/previews/form-fields-quantityfield.webp +0 -0
  103. package/docs/roadmap.md +5 -2
  104. package/docs/troubleshooting.md +11 -4
  105. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  import { t as e } from "./utils-BLfntITJ.js";
2
- import { a as t, c as n, d as r, i, n as a, r as o, t as s, u as c } from "./alert-dialog-BgQDxcYh.js";
3
- import { a as l } from "./button-2de_lrix.js";
4
- import { i as u } from "./translate-material-BlCQ6AW9.js";
5
- import { t as d } from "./use-material-translation-DkE9J5NE.js";
2
+ import { a as t, c as n, d as r, i, n as a, r as o, t as s, u as c } from "./alert-dialog-CBkWG32g.js";
3
+ import { a as l } from "./button-D3qzqDY_.js";
4
+ import { i as u } from "./translate-material-Dh7MYkHT.js";
5
+ import { t as d } from "./use-material-translation-BAYyZOcH.js";
6
6
  import { _ as f, g as p, h as m, n as h, v as g, y as _ } from "./number.utils-BG_eEt5g.js";
7
7
  import { useCallback as v, useEffect as y, useMemo as b, useRef as x, useState as S } from "react";
8
8
  import { jsx as C, jsxs as w } from "react/jsx-runtime";
@@ -1,4 +1,4 @@
1
- import { i as e, t } from "./translate-material-BlCQ6AW9.js";
1
+ import { i as e, t } from "./translate-material-Dh7MYkHT.js";
2
2
  import { useCallback as n } from "react";
3
3
  //#region lib/i18n/use-material-translation.ts
4
4
  function r() {
@@ -1,6 +1,6 @@
1
1
  import { o as e, r as t } from "./dist-DVRQkaTK.js";
2
2
  import { a as n, c as r, i, l as a, o, r as s, s as c } from "./dist-lmHmz_bu.js";
3
- import { C as l, S as u, w as d } from "./spinner-PdSk7zEs.js";
3
+ import { C as l, S as u, w as d } from "./spinner-ByYdcukU.js";
4
4
  import { c as f, i as p, l as m, o as h, r as g, s as ee } from "./in-dialog-Vwkyp-M5.js";
5
5
  import { r as _ } from "./escape-owner-DpaP0lm1.js";
6
6
  import * as v from "react";
@@ -12,12 +12,14 @@ for the value type each one writes. Everything is imported from
12
12
  | <img src="../previews/form-fields-field.webp" width="96" alt=""> | [Field](#field) | Renders the field matching a config `type` |
13
13
  | <img src="../previews/form-fields-formsection.webp" width="96" alt=""> | [FormSection](#formsection) | Titled section of a form, one column on a phone |
14
14
  | <img src="../previews/form-fields-repeatablegroup.webp" width="96" alt=""> | [RepeatableGroup](#repeatablegroup) | Rows a user adds, removes and reorders |
15
+ | <img src="../previews/form-fields-choicelistfield.webp" width="96" alt=""> | [ChoiceListField](#choicelistfield) | An editable list of answer choices with correct marks |
15
16
  | <img src="../previews/form-fields-textfield.webp" width="96" alt=""> | [TextField](#textfield) | Labelled input with adornments and helper text |
16
17
  | <img src="../previews/form-fields-textareafield.webp" width="96" alt=""> | [TextareaField](#textareafield) | Labelled textarea with row sizing and a counter |
17
18
  | <img src="../previews/form-fields-emailfield.webp" width="96" alt=""> | [EmailField](#emailfield) | Validates on blur, `mailto:` button |
18
19
  | <img src="../previews/form-fields-passwordfield.webp" width="96" alt=""> | [PasswordField](#passwordfield) | Show / hide toggle and a strength meter |
19
20
  | <img src="../previews/form-fields-numberfield.webp" width="96" alt=""> | [NumberField](#numberfield) | `number \| null` with clamping and +/− buttons |
20
21
  | <img src="../previews/form-fields-percentfield.webp" width="96" alt=""> | [PercentField](#percentfield) | `NumberField` with a `%` suffix |
22
+ | <img src="../previews/form-fields-quantityfield.webp" width="96" alt=""> | [QuantityField](#quantityfield) | One labelled field holding an amount and its unit |
21
23
  | <img src="../previews/form-fields-currencyfield.webp" width="96" alt=""> | [CurrencyField](#currencyfield) | Locale-formatted amount with a currency suffix |
22
24
  | <img src="../previews/form-fields-phonefield.webp" width="96" alt=""> | [PhoneField](#phonefield) | International number, emits E.164 |
23
25
  | <img src="../previews/form-fields-urlfield.webp" width="96" alt=""> | [UrlField](#urlfield) | Validates on blur, open and copy buttons |
@@ -325,6 +327,104 @@ masculine one.
325
327
 
326
328
  ---
327
329
 
330
+ ## ChoiceListField
331
+
332
+ ![ChoiceListField](../previews/form-fields-choicelistfield.webp)
333
+
334
+ An editable list of answer choices, for a quiz question: each row has a text
335
+ and a "correct" mark. `ChoiceListFormField` wires it to react-hook-form; the
336
+ value is an array of `{ id, text, correct }`.
337
+
338
+ ```tsx
339
+ import { useForm } from 'react-hook-form';
340
+ import { Button, ChoiceListFormField } from '@nxgt/material/components';
341
+ import type { ChoiceRow } from '@nxgt/material/components';
342
+
343
+ type Values = { choices: ChoiceRow[] };
344
+
345
+ declare function save(choices: ChoiceRow[]): void;
346
+
347
+ function QuestionForm() {
348
+ const { control, handleSubmit } = useForm<Values>({
349
+ defaultValues: {
350
+ choices: [
351
+ { id: 'a', text: 'Paris', correct: true },
352
+ { id: 'b', text: 'Lyon', correct: false },
353
+ ],
354
+ },
355
+ });
356
+ return (
357
+ <form onSubmit={handleSubmit((values) => save(values.choices))}>
358
+ <ChoiceListFormField
359
+ control={control}
360
+ name="choices"
361
+ label="Answers"
362
+ selection="multiple"
363
+ min={2}
364
+ max={20}
365
+ />
366
+ <Button type="submit">Save</Button>
367
+ </form>
368
+ );
369
+ }
370
+ ```
371
+
372
+ It is a [`RepeatableGroup`](#repeatablegroup) of rows: Add, Remove and move
373
+ up / down buttons, `min` and `max` rows (Remove is disabled at `min`, Add is
374
+ `aria-disabled` at `max`), focus that follows each change and polite
375
+ announcements. Without a form library, `ChoiceListField` takes `value` and
376
+ `onChange`.
377
+
378
+ | Prop | Type | Default | What it does |
379
+ | --- | --- | --- | --- |
380
+ | `control`, `name` | `Control<T>`, `Path<T>` | — | the form and its array (`ChoiceListFormField`) |
381
+ | `value`, `onChange` | `ChoiceRow[]`, `(rows) => void` | — | the rows (`ChoiceListField`) |
382
+ | `selection` | `'single' \| 'multiple'` | `'multiple'` | `single`: native radios sharing one name in a `radiogroup`; marking a row unmarks the others. `multiple`: checkboxes |
383
+ | `requireCorrect` | `boolean` | `true` | the built-in rules ask for a marked row; `false` lets a list with none pass, for a poll (`ChoiceListFormField`) |
384
+ | `min`, `max` | `number` | none | bounds on the row count |
385
+ | `reorderable` | `boolean` | `true` | move up / down buttons on each row |
386
+ | `label` | `ReactNode` | none | names the group (a `group`, or a `radiogroup` in `single`) |
387
+ | `rowLabel`, `addLabel` | `string` | "answer", "Add answer" | lower-case noun for the buttons (translated by default) |
388
+ | `error`, `rowErrors` | `ReactNode`, `{ [id]: ReactNode }` | none | the list's error and each row's, by id (`ChoiceListField`; the form field fills them from the form) |
389
+ | `onBlur` | `() => void` | none | `ChoiceListField` alone: called when the focus leaves the whole list. In a form the list's root is registered with the form's `FieldBlur`, so touched works without it |
390
+ | `onAssignIds` | `(rows) => void` | `onChange` | `ChoiceListField`: called once on mount with ids given to rows that had none or repeated one; the form field sets them without marking the form dirty |
391
+ | other props | `div` props | | passed to the group's root (`className`, `id`, `data-*`, `ref`) |
392
+ | `emptyTitle`, `emptyDescription`, `disabled` | | | as `RepeatableGroup` |
393
+
394
+ - **Ids:** every row keeps its `id` across edits, removals and moves, so a
395
+ server can match a choice to its stored one. A new row gets a generated id;
396
+ a row that arrives without one, or repeating an earlier row's, is given a new
397
+ one on mount (the form field does it with `setValue` when the `FormProvider` above is its own form, and then leaves the form clean; otherwise it calls `onChange`). Give the rows ids in `defaultValues`, the clean path: with assigned ids `defaultValues` still lack them, so `dirtyFields.choices` marks rows after the first real edit.
398
+ In `single`, the new row is marked when no row is. A list that arrives with
399
+ several rows marked is not changed: it fails the `too-many` rule.
400
+ - **Semantics:** each row's text is a labelled input ("Answer 2"); its mark
401
+ is a radio or a checkbox named "Correct: answer 2" (the visible word
402
+ "Correct" starts the name), so `getByRole('checkbox', { name: 'Correct: answer 2' })`
403
+ finds it. Buttons are named after the row ("Remove answer 2", "Move answer 2 up"):
404
+ the keyboard reorders with them.
405
+ - **Tab order:** in `single` the radios are native and share one name, so
406
+ Tab visits each row's text field in order; the checked radio is reached in
407
+ its own row and the unchecked ones are skipped. The arrow keys move between
408
+ the radios and check the one they land on. The radios point their `form`
409
+ attribute at no form, so a surrounding `<form>`'s native `FormData` gets no
410
+ entry for them. (A
411
+ roving radio group over the whole list would send Tab to the checked radio
412
+ first.)
413
+ - **Errors:** without a resolver the field validates by itself: fewer than
414
+ `min` rows, several rows marked in `single`, no row marked (unless
415
+ `requireCorrect` is `false`), or a blank row puts a message under the
416
+ list, and blank rows are marked invalid (only for that rule, never for a resolver's or another rule's message). With a resolver (zod), an issue on
417
+ `choices.2.text` shows under row 3 and an issue on `choices` under the list.
418
+ When both fail in one pass, react-hook-form keeps only the list's.
419
+ - **Translations:** `material.choice-list.*` (English and French). The
420
+ messages name the row with `rowLabel` ("Fill in every answer"); `too-few`
421
+ is a plural that adds `s` to it, and the French templates carry the
422
+ feminine article, so a French app overrides the keys for a masculine noun.
423
+
424
+ **Storybook:** `Form fields/ChoiceListField` · **Vue:** none yet
425
+
426
+ ---
427
+
328
428
  ## TextField
329
429
 
330
430
  ![TextField](../previews/form-fields-textfield.webp)
@@ -511,6 +611,79 @@ ratio (`0.15`).
511
611
 
512
612
  ---
513
613
 
614
+ ## QuantityField
615
+
616
+ ![QuantityField](../previews/form-fields-quantityfield.webp)
617
+
618
+ One labelled field holding an amount and its unit ("2 capsule", "500 mg"): a
619
+ [`NumberField`](#numberfield)'s number beside a unit picker, under one label
620
+ and one error message. The value is `{ value: number | null, unit: string }`.
621
+ `QuantityFormField` wires it to react-hook-form.
622
+
623
+ ```tsx
624
+ import { useForm } from 'react-hook-form';
625
+ import { Button, QuantityFormField } from '@nxgt/material/components';
626
+ import type { QuantityValue } from '@nxgt/material/components';
627
+
628
+ type Values = { dose: QuantityValue };
629
+
630
+ function DoseForm() {
631
+ const { control, handleSubmit } = useForm<Values>({
632
+ defaultValues: { dose: { value: null, unit: '' } },
633
+ });
634
+ return (
635
+ <form onSubmit={handleSubmit((values) => console.log(values.dose))}>
636
+ <QuantityFormField
637
+ control={control}
638
+ name="dose"
639
+ label="Dose"
640
+ min={0}
641
+ step={0.5}
642
+ units={[
643
+ { value: 'mg', label: 'mg' },
644
+ { value: 'ml', label: 'mL' },
645
+ ]}
646
+ />
647
+ <Button type="submit">Save</Button>
648
+ </form>
649
+ );
650
+ }
651
+ ```
652
+
653
+ Without a form library, `QuantityField` takes `value` and `onChange` (or
654
+ `defaultValue`). The number is the one `NumberField` has: the language's
655
+ decimal separator, `min`, `max` and `step` (clamped on blur), and steppers
656
+ named after the field ("Increase Dose"). Both parts stay on one line on a
657
+ phone: the number shrinks, the unit keeps a fixed 7rem.
658
+
659
+ | Prop | Type | Default | What it does |
660
+ | --- | --- | --- | --- |
661
+ | `control`, `name` | `Control<T>`, `Path<T>` | — | the form and its `{ value, unit }` (`QuantityFormField`) |
662
+ | `value`, `defaultValue`, `onChange` | `QuantityValue`, `(value) => void` | empty | the amount and its unit; `onChange` gets both on each change |
663
+ | `label` | `ReactNode` | none | the one label; it names the group |
664
+ | `units` | `{ value, label }[]` | none | the units offered |
665
+ | `allowCustomUnit` | `boolean` | `false` | the unit is typed in a text input and `units` are suggestions; otherwise it is picked from `units` |
666
+ | `min`, `max`, `step`, `hideSteppers` | | `step` 1 | as `NumberField` |
667
+ | `unitLabel`, `unitPlaceholder` | `string` | "<label> unit" | the unit part's accessible name and its placeholder |
668
+ | `helperText`, `error` | `ReactNode`, `boolean` | none | the one message under the pair; in a form, an error on `name`, `name.value` or `name.unit` shows there |
669
+ | `className`, `aria-label` | `string` | none | `className` goes on the group; `aria-label` names it when `label` is not plain text |
670
+ | `name`, `onBlur` | | none | `QuantityField` only (the form field takes them from the form): `name` names the parts `<name>.value` and `<name>.unit`; `onBlur` fires when the number input loses focus |
671
+ | `required`, `disabled`, `placeholder`, `id`, `ref` | | | `placeholder`, `id` and `ref` reach the number input (React Hook Form's `field.ref` focuses it on an error) |
672
+
673
+ - **Semantics:** a `<fieldset>` (role `group`) named by the label; the number
674
+ is a textbox named after the label, the unit a combobox (or, with
675
+ `allowCustomUnit`, a text input) named "Dose unit". The message describes
676
+ the group, the number and the unit, and both are `aria-invalid` on an error.
677
+ - **Translations:** `material.quantity-field.unit` and `.unit-named` (English
678
+ and French) name the unit part.
679
+
680
+ **Emits:** `{ value: number | null, unit: string }`.
681
+ **Form wrapper:** `QuantityFormField` — `control` and `name`, see
682
+ [forms](../guides/forms.md#the-pair) and [what each field emits](../guides/forms.md#what-each-field-emits).
683
+ **Storybook:** `Form fields/QuantityField` · **Vue:** none yet (#2106)
684
+
685
+ ---
686
+
514
687
  ## CurrencyField
515
688
 
516
689
  ![CurrencyField](../previews/form-fields-currencyfield.webp)
@@ -566,9 +739,19 @@ export function Mobile() {
566
739
  | `value` / `defaultValue` | `string \| null` | `null` | an E.164 number (`+33612345678`) |
567
740
  | `onChange` | `(value: string \| null) => void` | — | `null` until the typed number is possible for the country |
568
741
  | `defaultCountry` | `string` (ISO 3166-1) | `'FR'` | the country used when the value has no `+` prefix |
569
-
570
- **Emits:** E.164 `string`, or `null` while the number is incomplete.
571
- **Form wrapper:** `PhoneFormField` — `control` and `name`, see
742
+ | `onValidityChange` | `(incomplete: boolean) => void` | — | `true` while the draft is not empty but not yet a possible number (`onChange` gives `null` for it, as for an empty field); `false` once it is complete or empty. Fires before the matching `onChange`; not called while the field has never been incomplete |
743
+
744
+ **Emits:** E.164 `string`, or `null` while the number is empty or incomplete.
745
+ Outside a form, tell the two apart with `onValidityChange` (or
746
+ `isIncompletePhone(draft, iso)`).
747
+ **Form wrapper:** `PhoneFormField` — `control` and `name`; an empty field is
748
+ `null`, and a half-typed number is a field error « Incomplete number »
749
+ (`material.phone-field.incomplete`, « Numéro incomplet » in French) that blocks
750
+ the submit, so a stored number is never cleared silently. `PhoneFormField` takes `rules`: your own `rules.validate` (a function or an
751
+ object of them) runs beside the incomplete check, as its `custom` key for a
752
+ function. A `rules.required` is checked first by React Hook Form, so a half-typed
753
+ number reads as the required message there. A form `reset()` to `null` does not clear a half-typed draft (the
754
+ value did not change), so an incomplete draft stays an error until it is edited. See
572
755
  [forms](../guides/forms.md#the-pair) and [what each field emits](../guides/forms.md#what-each-field-emits).
573
756
  **Storybook:** `Form fields/PhoneField` (and `/Field look`) · **Vue:** `PhoneField`, `PhoneFormField`
574
757
 
@@ -1771,6 +1954,16 @@ Parts: `PostalAddress` (the card), `PostalAddressDialog` (the edit form).
1771
1954
  | `onEdit` | `() => void` | — | shows the edit button |
1772
1955
  | `disabled` | `boolean` | — | hides it |
1773
1956
 
1957
+ **One line and country codes:** `formatPostalAddress(address, { language?, separator? })`
1958
+ returns the address as one line for a read view, `"12 rue du Lac, 74000 Annecy, France"`:
1959
+ street, city line, then the country named in `language` (default `'en'`, as
1960
+ `postalAddressCountryName`). The city line puts the postal code before the city
1961
+ (FR, BE, CH, DE and any other country) or after the city and region (GB, US, CA,
1962
+ IE, AU, NZ, IN: `"London SW1A 2AA"`). Empty parts are skipped; `separator`
1963
+ defaults to `', '`. `ISO_COUNTRY_CODES` (the 249 ISO 3166-1 alpha-2 codes) and
1964
+ `isCountryCode(code)` (a guard to `string`, upper case only) are exported from
1965
+ `@nxgt/material/components` too.
1966
+
1774
1967
  `PostalAddressFormField` and `PostalAddressDialog` take `cancelVariant` (the
1775
1968
  dialog's Cancel button, a `Button` `variant`, default `'tonal'`; `'outlined'`
1776
1969
  restores the earlier look).
@@ -65,9 +65,10 @@ function Attachments() {
65
65
  <NetworkUploadField
66
66
  mode="single"
67
67
  label="Contract"
68
- onUpload={async (file, onProgress) => {
68
+ onUpload={async (file, onProgress, signal) => {
69
+ const res = await fetch('/api/files', { method: 'POST', body: file, signal });
69
70
  onProgress?.(100);
70
- return `https://files.example.com/${file.name}`;
71
+ return (await res.json()).url;
71
72
  }}
72
73
  />;
73
74
  ```
@@ -75,8 +76,8 @@ function Attachments() {
75
76
  | Prop | Type | Default | Effect |
76
77
  | --- | --- | --- | --- |
77
78
  | `mode` | `'single' \| 'multiple'` | — (required) | `value` is a `string` or a `string[]` |
78
- | `onUpload` | `(file, onProgress?) => Promise<string>` | — | `NetworkUploadField` only; the resolved string becomes the value |
79
- | `getPresignedUrl` | `(file) => Promise<{ uploadUrl, publicUrl, headers? }>` | — | `S3UploadField` only; PUTs the file to `uploadUrl`, stores `publicUrl` |
79
+ | `onUpload` | `(file, onProgress?, signal?) => Promise<string>` | — | `NetworkUploadField` only; the resolved string becomes the value. Removing an uploading row or unmounting the field aborts `signal`: pass it to your request, or the upload still completes (its value is dropped). An abort after the request completed cannot unsend it: the value is dropped and `onRemove` is not called. The S3 fields abort their request themselves and reject with an `UploadError` of kind `aborted` (named `AbortError`, no longer a `DOMException`) |
80
+ | `getPresignedUrl` | `(file) => Promise<PresignedUploadTarget>` | — | `S3UploadField` only; `{ uploadUrl, publicUrl, headers? }` PUTs the file and stores `publicUrl`; `{ method: 'POST', url, fields, value }` posts a form and stores `value` (see [presigned POST](#presigned-post)) |
80
81
  | `accept` | `string` | — | file input `accept`, also shown under the drop zone |
81
82
  | `maxSize` | `number` (bytes) | — | larger files are rejected before upload |
82
83
  | `maxFiles` | `number` | — | `mode="multiple"` only; the drop zone is disabled once reached |
@@ -95,9 +96,51 @@ description instead, so the error is still said: with `error` and that
95
96
  group, so to set files in a test, reach the input through it:
96
97
  `getByRole('group', { name: 'Contract' }).locator('input[type=file]')`.
97
98
 
99
+ ### Presigned POST
100
+
101
+ A backend that signs a POST policy (so the bucket enforces content type and
102
+ size) returns `{ method: 'POST', url, fields, value }`. The field sends
103
+ `multipart/form-data` with `fields` in their order and the file appended last
104
+ under the name `file`, then stores `value` (the key). The same target works for `S3UploadFormField`,
105
+ `ImageS3Field`, `ImageS3FormField`, `FileListS3Field` and `FileListS3FormField`;
106
+ the PUT shape stays valid. Progress, retry, cancel and errors behave as for a PUT.
107
+
108
+ ```tsx
109
+ <S3UploadFormField
110
+ control={control}
111
+ name="file"
112
+ label="Contract"
113
+ getPresignedUrl={async (file) => {
114
+ const { url, fields, key } = await api.presignPost({ name: file.name, contentType: file.type });
115
+ return { method: 'POST', url, fields, value: key }; // fields exactly as signed
116
+ }}
117
+ />
118
+ ```
119
+
120
+ A refused policy (`403`, or `400 EntityTooLarge`) reaches `onError(file, error)`
121
+ and the field's error state; when S3 answers with its XML error, the message
122
+ carries the code. The S3 helpers reject with an `UploadError` (`kind`:
123
+ `status` | `network` | `aborted`, plus `status` and `code`), and the field words
124
+ it in the active language from `material.upload.error-status`,
125
+ `error-status-code`, `error-network` and `error-aborted` (`Échec de l'envoi
126
+ (statut 400, EntityTooLarge)` in French). `error.message` stays English, for
127
+ logs. An error thrown by your own `onUpload` or `getPresignedUrl` is not an
128
+ `UploadError`: its `message` is shown as is, so translate it yourself. A stored
129
+ `value` that is a key needs `resolveUrl` to preview it.
130
+
131
+ The bucket must allow the browser to POST from your origin:
132
+
133
+ ```json
134
+ [{ "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["POST"], "AllowedHeaders": ["*"] }]
135
+ ```
136
+
137
+ If the app sends a `Content-Security-Policy`, add the bucket host to
138
+ `connect-src` (uploads use `XMLHttpRequest`), for example
139
+ `connect-src 'self' https://my-bucket.s3.eu-west-1.amazonaws.com`.
140
+
98
141
  **Form wrapper:** `S3UploadFormField`, `NetworkUploadFormField` — `control` and `name`, see
99
142
  [forms](../guides/forms.md).
100
- **Storybook:** `Media & files/S3UploadField`, `Media & files/NetworkUploadField` · **Vue:** `S3UploadField`, `NetworkUploadField`, `S3UploadFormField`, `NetworkUploadFormField`
143
+ **Storybook:** `Media & files/S3UploadField`, `Media & files/NetworkUploadField` · **Vue:** `S3UploadField`, `NetworkUploadField`, `S3UploadFormField`, `NetworkUploadFormField` (PUT only, #2095)
101
144
 
102
145
  ---
103
146
 
@@ -199,7 +242,9 @@ function Attachments() {
199
242
  ```
200
243
 
201
244
  Parts: `FileList` (display only), `FileListField` (uploads through `onUpload`),
202
- `FileListS3Field` (uploads through `getPresignedUrl`, like `S3UploadField`).
245
+ `FileListS3Field` (uploads through `getPresignedUrl`, like `S3UploadField`;
246
+ with a POST target the value is `value`, a key, so pass `resolveUrl` to preview
247
+ saved images).
203
248
 
204
249
  | Prop | Type | Default | Effect |
205
250
  | --- | --- | --- | --- |
@@ -224,7 +269,7 @@ dropzone as well read the error twice, and the group still says it once
224
269
  `maxFiles` hides the dropzone. The dropzone is outlined through `data-invalid`.
225
270
 
226
271
  **Form wrapper:** `FileListFormField`, `FileListS3FormField` — see [forms](../guides/forms.md).
227
- **Storybook:** `Media & files/FileList`, `Media & files/FileListField` · **Vue:** `FileList`, `FileListField`, `FileListS3Field`, `FileListFormField`, `FileListS3FormField`
272
+ **Storybook:** `Media & files/FileList`, `Media & files/FileListField` · **Vue:** `FileList`, `FileListField`, `FileListS3Field`, `FileListFormField`, `FileListS3FormField` (S3 variants PUT only, #2095)
228
273
 
229
274
  ---
230
275
 
@@ -364,7 +409,8 @@ function Avatar() {
364
409
  ```
365
410
 
366
411
  Parts: `ImageField` (uploads through `onUpload`), `ImageS3Field` (through
367
- `getPresignedUrl`).
412
+ `getPresignedUrl`; with a POST target the value is `value`, a key, so pass
413
+ `resolveUrl` to preview saved images).
368
414
 
369
415
  | Prop | Type | Default | Effect |
370
416
  | --- | --- | --- | --- |
@@ -389,7 +435,7 @@ with no message of its own, by a visually hidden "This value is not valid."
389
435
  (`material.zod.default`, translated), and outlined through `data-invalid`.
390
436
 
391
437
  **Form wrapper:** `ImageFormField`, `ImageS3FormField` — see [forms](../guides/forms.md).
392
- **Storybook:** `Media & files/ImageField` · **Vue:** `ImageField`, `ImageS3Field`, `ImageFormField`, `ImageS3FormField`
438
+ **Storybook:** `Media & files/ImageField` · **Vue:** `ImageField`, `ImageS3Field`, `ImageFormField`, `ImageS3FormField` (S3 variants PUT only, #2095)
393
439
 
394
440
  ---
395
441
 
@@ -788,7 +788,7 @@ hides the indicator.
788
788
 
789
789
  For tabs that go to routes, use [TabsNav](#tabsnav): real links with `aria-current`, not tabs with panels.
790
790
 
791
- `TabsTrigger asChild` makes your link the tab (`<TabsTrigger value="a" asChild><Link to="/a">A</Link></TabsTrigger>`): the indicator and the icon and label spans go inside it. `ToggleGroupItem`, `AccordionTrigger`, `SelectTrigger` and `ExtendedLabel` take `asChild` the same way.
791
+ `TabsTrigger asChild` makes your link the tab (`<TabsTrigger value="a" asChild><Link to="/a">A</Link></TabsTrigger>`): the indicator and the icon and label spans go inside it. `ToggleGroupItem`, `AccordionTrigger`, `SelectTrigger` and `ExtendedLabel` take `asChild` the same way. For links, use [SegmentedNav](#segmentednav), not `ToggleGroupItem asChild`: a radio group of links is announced wrongly.
792
792
 
793
793
  `dir="rtl"` on `Tabs` (or Radix's `DirectionProvider`) runs the tabs from the
794
794
  right: the indicator follows them, and the Left arrow key moves to the next
@@ -849,21 +849,19 @@ Use `Tabs` when the panels are on the page and `TabsNav` when each tab is a
849
849
  place to go: every tab is a real link, so it opens in a new window and its
850
850
  address can be copied. `aria-label` names the navigation and is required.
851
851
 
852
- - **Current link**: `TabsNav`'s `value` is matched against each link's
853
- `value`; a link's `current` boolean overrides it (`current={false}` to
854
- switch one off). The current link carries `aria-current="page"` and
855
- `data-state="active"`, which is what the indicator follows.
852
+ - **Current link**: a link's `current` boolean overrides the nav's `value`. The
853
+ current link carries `aria-current="page"` and `data-state="active"`, which
854
+ is what the indicator follows.
856
855
  - **Keyboard**: the links are plain links, so Tab moves from one to the next;
857
856
  the arrow keys do nothing (they are not tabs, there is no `tablist`).
858
857
  - **Looks**: `variant="underline"` (the default) or `"segmented"`, as `Tabs`.
859
858
  A nav wider than its container scrolls inside itself, with the same edge
860
859
  fades and scroll buttons as `Tabs`. On a coarse pointer each link is 44px
861
- tall at least.
860
+ tall at least. In forced colours the current link keeps a `Highlight` mark.
862
861
  - **The link**: `TabsNavLink` takes `LinkButton`'s props (`to`, `replace`,
863
862
  `state`, `target`, `reloadDocument`, `download`, `linkComponent`) and an
864
863
  `icon`. It renders React Router's `Link`, or the `LinkComponentProvider`'s
865
- component (given `href`). `preventScrollReset` is on by default, so changing
866
- tab keeps the scroll position; pass `preventScrollReset={false}` to reset.
864
+ component (given `href`).
867
865
  - A tab bound to the URL's search params: [`useTabParam`](#route-tabs-in-the-url-usetabparam).
868
866
 
869
867
  **It needs a Router.** Without a `LinkComponentProvider`, `TabsNavLink` renders
@@ -993,9 +991,7 @@ each segment is a place to go: every item is a real link, so it opens in a
993
991
  new window and its address can be copied. A `ToggleGroup` of links would
994
992
  announce each as a radio. `aria-label` names the navigation and is required.
995
993
 
996
- - **Current item**: `SegmentedNav`'s `value` is matched against each item's
997
- `value`; an item's `current` boolean overrides it (`current={false}` to
998
- switch one off). The current item carries `aria-current="page"` and
994
+ - **Current item**: an item's `current` boolean overrides the nav's `value`. The current item carries `aria-current="page"` and
999
995
  `data-state="active"`, and draws the selected pill (its label in
1000
996
  `primary-text`); it does not glide, since the nav may scroll.
1001
997
  - **Keyboard**: the items are plain links, so Tab moves from one to the next;
@@ -1007,8 +1003,7 @@ announce each as a radio. `aria-label` names the navigation and is required.
1007
1003
  - **The link**: `SegmentedNavItem` takes `LinkButton`'s props (`to`, `replace`,
1008
1004
  `state`, `target`, `reloadDocument`, `download`, `linkComponent`). It renders
1009
1005
  React Router's `Link`, or the `LinkComponentProvider`'s component (given
1010
- `href`). `preventScrollReset` is on by default, so changing segment keeps
1011
- the scroll position; pass `preventScrollReset={false}` to reset.
1006
+ `href`).
1012
1007
  - A segment bound to the URL's search params: [`useTabParam`](#route-tabs-in-the-url-usetabparam) (`useTabParam({ param: 'p', values })`), as above.
1013
1008
 
1014
1009
  **It needs a Router.** Without a `LinkComponentProvider`, `SegmentedNavItem`
@@ -1079,7 +1074,7 @@ colour.
1079
1074
  Everything is CSS on data attributes (`data-variant`, `data-size`,
1080
1075
  `data-orientation`): nothing is measured, no inline style, the server
1081
1076
  writes the same markup. Motion is off under `prefers-reduced-motion`, and
1082
- forced colours draw the selected item in `SelectedItem`.
1077
+ forced colours draw the selected item in `SelectedItem`. The segmented look also follows `aria-current="page"`, which is what [SegmentedNav](#segmentednav) uses for links.
1083
1078
 
1084
1079
  ```tsx
1085
1080
  import {
@@ -2457,9 +2457,10 @@ French bundle — see [i18n](../guides/i18n.md).
2457
2457
  | `QuestionCard` `renderCode` | `(field: QuestionCodeField) => ReactNode` | — | the code field of a `code` question; `field` is `{ value, onChange, label, labelledBy, describedBy, language, maxLength, readOnly, disabled }`. Keeps Monaco out of the card's bundle |
2458
2458
  | `Question` `description` | `string` | — | `open`, `code`: a persistent helper line under the answer field ("A person will mark this answer"), linked to it by `aria-describedby` and passed to `renderCode` as `field.describedBy`; always shown, unlike `hint` (a reveal button). Its id is `${promptId}-description` |
2459
2459
  | `QuestionCard` `promptId` | `string` | generated | the id of the prompt text: point a wrapping section's `aria-labelledby` at it (and `aria-describedby` at `${promptId}-description`) |
2460
+ | `QuestionCard` `preview` | `boolean` | `false` | an author's preview, `<QuestionCard question={q} preview />`: forces `revealed` and `readOnly`, drops `onValueChange`, and shows the marks (the `question.correct` options marked, the expected text or number filled in) and the `explanation`, with no verdict banner. `verdict` and `showFeedback` are always ignored in a preview; `value` is ignored when the question has a `correct` — an `open` or `code` question has none, so pass its reference answer as `value`. Not for a learner's attempt |
2460
2461
  | `QuestionCard` `readOnly` / `busy` | `boolean` | `false` | `readOnly`: an answer already submitted, shown without the revealed marks; `busy`: read-only and `aria-busy` while it is saved; every kind honours both |
2461
2462
  | new strings | `material.education.kind-open`, `kind-code`, `code-placeholder` | — | English fallbacks and a French bundle |
2462
- | `QuestionCard` `variant` / `density` / `showShortcuts` / `number` / `showFeedback` | — | `'card'`, `'comfortable'`, `false`, —, `true` | framed or plain, spacing, key badges, "Question 2", the verdict banner |
2463
+ | `QuestionCard` `variant` / `density` / `showShortcuts` / `number` / `showFeedback` | — | `'card'`, `'comfortable'`, `false`, —, `true` | framed or plain, spacing, key badges, "Question 2", the verdict banner (for a card with marks and no banner, use `preview`) |
2463
2464
 
2464
2465
  An open answer and a code answer, with the code editor from its own subpath
2465
2466
  (it is client-only, so load it lazily in an SSR app):
@@ -417,7 +417,7 @@ disabled with the field; Backspace in the empty input removes the last one.
417
417
  | `getOptionValue`, `getOptionLabel` | `(option: T) => string` | `.value`, `.label` | for other option shapes |
418
418
  | `getOptionLeading` | `(option: T) => ReactNode` | — | an avatar (or a flag) before the label, in the list and in the chip; `renderOption` replaces the whole row |
419
419
  | `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 |
420
- | `freeSolo` | `boolean` | — | accept a typed value that is not an option |
420
+ | `freeSolo` | `boolean` | — | accept a typed value that is not an option; Enter adds it |
421
421
  | `onSearch` | `(query: string, { signal }: { signal: AbortSignal }) => Promise<T[]>` | — | server search: called once typing pauses; its results become the list. Each search aborts the previous one, and a response that lands after a newer one is dropped |
422
422
  | `onSearchError` | `(error: unknown) => void` | — | `onSearch` rejected (an abort is not a rejection) |
423
423
  | `minQueryLength` | `number` | `1` | `onSearch` waits for this many characters; below it the list says "Type at least N characters to search." |
@@ -433,6 +433,12 @@ disabled with the field; Backspace in the empty input removes the last one.
433
433
  | `disabled` | `boolean` | — | disables the input and each chip's remove button |
434
434
  | `error` | `boolean` | — | error border on the chips box, error helper text, `aria-invalid` on the input |
435
435
 
436
+ Enter in a form: it acts on the list and does not submit the form while it has
437
+ something to do (WAI-ARIA combobox practice). It picks the highlighted option;
438
+ with `freeSolo` and no option highlighted it adds the typed text; with the list
439
+ open it is only default-prevented. On a closed list with nothing typed it
440
+ submits, as a plain input does.
441
+
436
442
  An avatar in the rows and the chips:
437
443
  `getOptionLeading={(person) => <Avatar src={person.photo} alt="" />}`; see
438
444
  [the field look](../guides/field-look-2-x.md#autocomplete-and-the-chips-box).
@@ -471,8 +477,9 @@ text, as a `Select` shows it, and keeps the search:
471
477
  gets the label back on leave: the value can only be replaced by another pick.
472
478
  - The option's `getOptionLeading` (or `renderSelectedLeading`) avatar shows
473
479
  before the text while it is picked.
474
- - `freeSolo` still adds through its "Add" button; text left in the input and
475
- not added is dropped on leave, as any other typed text.
480
+ - `freeSolo` adds through its "Add" button, or with Enter when no option is
481
+ highlighted; text left in the input and not added is dropped on leave, as
482
+ any other typed text.
476
483
  - Keep the selected option in `options` (as with `onSearch` and chips): a value
477
484
  with no matching option has no label, so the input stays empty.
478
485
  - The input is the combobox: `aria-expanded` follows the list,
@@ -115,7 +115,7 @@ export function PlanForm() {
115
115
  | The field | Touched when |
116
116
  | --- | --- |
117
117
  | one input (`TextFormField`, `EmailFormField`, `TextareaFormField`, `SwitchFormField`, `CheckboxFormField`, …) | its input loses focus, as a native `blur` |
118
- | several controls (`OpeningHoursFormField`, inline by default: focus leaves the week editor and its phone sheet; `ContactsFormField`, inline by default: focus leaves the rows and the phone sheet; `AttributesFormField`, inline by default: focus leaves the rows, their open fields and the phone sheet; `PasswordFormField` and its eye button, `NumberFormField` and its steppers, `PhoneFormField`, `OtpFormField`, `SliderFormField` with two thumbs, `DurationFormField`, `RatingFormField`, `RadioGroupFormField`, `CheckboxGroupFormField`, `ChipGroupFormField`, `ChoiceCardGroupFormField`, `SortableListFormField`, `TransferListFormField`, `InlineEditFormField`, `LocalizedTextFormField`, `AttributeValuesFormFieldSet`, …) | focus leaves all of them; moving between them is not leaving |
118
+ | several controls (`OpeningHoursFormField`, inline by default: focus leaves the week editor and its phone sheet; `ContactsFormField`, inline by default: focus leaves the rows and the phone sheet; `AttributesFormField`, inline by default: focus leaves the rows, their open fields and the phone sheet; `PasswordFormField` and its eye button, `NumberFormField` and its steppers, `QuantityFormField`, `PhoneFormField`, `OtpFormField`, `SliderFormField` with two thumbs, `DurationFormField`, `RatingFormField`, `RadioGroupFormField`, `CheckboxGroupFormField`, `ChipGroupFormField`, `ChoiceCardGroupFormField`, `SortableListFormField`, `TransferListFormField`, `InlineEditFormField`, `LocalizedTextFormField`, `AttributeValuesFormFieldSet`, …) | focus leaves all of them; moving between them is not leaving |
119
119
  | a picker (`SelectFormField`, `ComboboxFormField`, `AutocompleteFormField`, `TreeSelectFormField`, `CountryFormField`, `TimezoneFormField`, `ColorFormField`, `DateFormField`, `DateRangeFormField`, `TimeFormField`, …) | focus leaves the trigger **and** the popover it opened. Opening the popover, or picking in it, is not leaving; closing it hands focus back to the trigger, and the next Tab or click elsewhere is. A typed `DateFormField`, `DateRangeFormField` or `TimeFormField` is one trigger: moving between its segments (both ends of a range) and its buttons is not leaving |
120
120
  | a dialog editor (`PostalAddressFormField`, `ContactsFormField` with `editing="dialog"`, `OpeningHoursFormField` with `editing="dialog"`, `AttributesFormField` with `editing="dialog"`, and `LocationFormField` from `@nxgt/material/map`) | focus leaves its buttons and the dialog they open. Closing the dialog hands focus back to the button that opened it |
121
121
  | an upload (`UploadFormField`, `NetworkUploadFormField`, `S3UploadFormField`, `ImageFormField`, `ImageS3FormField`, `FileListFormField`, `FileListS3FormField`) | focus leaves the drop zone and its buttons. Choosing a file in the browser's own dialog is not leaving |
@@ -485,8 +485,9 @@ or fails validation.
485
485
  | `PasswordField` · `PasswordFormField` | `string` | `''` |
486
486
  | `NumberField` · `NumberFormField` | `number \| null`. `null` when cleared; clamped to `min` / `max` on blur | `null` |
487
487
  | `PercentField` · `PercentFormField` | `number \| null`, `min` 0, `max` 100, `step` 1 by default | `null` |
488
+ | `QuantityField` · `QuantityFormField` | `{ value: number \| null, unit: string }`. An error on the field, `.value` or `.unit` shows as one message under the pair | `{ value: null, unit: '' }` |
488
489
  | `CurrencyField` · `CurrencyFormField` | `number \| null`, the amount only, not rounded. `currency` (default `'EUR'`) and `fractionDigits` (the currency's own decimals by default) are display | `null` |
489
- | `PhoneField` · `PhoneFormField` | E.164 `string` (`'+33612345678'`), or `null` while the number is not yet a possible one. `defaultCountry` is `'FR'` | `null` |
490
+ | `PhoneField` · `PhoneFormField` | E.164 `string` (`'+33612345678'`), or `null` when empty. `PhoneFormField` reports a non-empty number that is not yet possible as the field error `material.phone-field.incomplete`; `PhoneField` signals it with `onValidityChange`. `defaultCountry` is `'FR'` | `null` |
490
491
  | `UrlField` · `UrlFormField` | `string` (as typed) | `''` |
491
492
  | `SearchField` · `SearchFormField` | `string`, after `debounceMs` (default `0`) | `''` |
492
493
  | `ColorField` · `ColorFormField` | `string` (hex, `'#1e293b'`) | `''` |
@@ -535,7 +536,7 @@ or fails validation.
535
536
  | Field · wrapper | Form value | Empty default |
536
537
  | --- | --- | --- |
537
538
  | `NetworkUploadField` · `NetworkUploadFormField` | `mode="single"`: the `string` your `onUpload` resolved (a URL or a key), or `null` after removal. `mode="multiple"`: `string[]` | `undefined` / `[]` |
538
- | `S3UploadField` · `S3UploadFormField` | as above; the value is `publicUrl` from `getPresignedUrl` | `undefined` / `[]` |
539
+ | `S3UploadField` · `S3UploadFormField` | as above; the value is `publicUrl` (PUT) or `value` (POST) from `getPresignedUrl` | `undefined` / `[]` |
539
540
  | `ImageField`, `ImageS3Field` · `ImageFormField`, `ImageS3FormField` | `string \| null` (the uploaded URL) | `undefined` |
540
541
  | `FileListField`, `FileListS3Field` · `FileListFormField`, `FileListS3FormField` | `string[]` | `[]` |
541
542
  | `FilesFormField` | `mode="single"`: `string \| null`. `mode="multiple"`: `string[]` (typed or picked URLs) | `null` / `[]` |
package/docs/roadmap.md CHANGED
@@ -6,8 +6,6 @@ version they shipped in, and planned entries may name the major they target.
6
6
 
7
7
  ## Now
8
8
 
9
- - **Link navigation** — `TabsNav` and `SegmentedNav` render route tabs and segmented navigation as a `nav` of links with `aria-current="page"`, looking like Tabs and the segmented ToggleGroup, plus `useTabParam` to keep the current tab in the URL. Tracked in [#2029](https://github.com/softistx/nxgt-material/issues/2029).
10
-
11
9
  - **Barcodes and label sheets** — `@nxgt/material/barcode` draws Code 128
12
10
  and Data Matrix as accessible SVG (themed, dark-aware, black on white in
13
11
  print) and lays labels out on a sheet to print: label size, columns, rows,
@@ -248,6 +246,9 @@ version they shipped in, and planned entries may name the major they target.
248
246
 
249
247
  ## Later
250
248
 
249
+ - **One `onUpload` options object (next major)** — the upload fields'
250
+ `onUpload(file, onProgress?, signal?)` becomes `onUpload(file, { onProgress,
251
+ signal })`, like the editor's upload context.
251
252
  - **A strict theme `motif` (4.0)** — a theme background's `motif` with an
252
253
  unknown key, a `layout` or `density` outside its list or a bad `icons` is
253
254
  dropped in 3.22 (`@nxgt/material/themes`'s `defineTheme` warns for each one in
@@ -363,6 +364,8 @@ version they shipped in, and planned entries may name the major they target.
363
364
 
364
365
  ## Shipped
365
366
 
367
+ - **Link navigation (3.66.0)** — `TabsNav` and `SegmentedNav` render route tabs and segmented navigation as a `nav` of links with `aria-current="page"`, looking like Tabs and the segmented ToggleGroup, plus `useTabParam`, `skipTabChange` and `tabIds` to keep the current tab in the URL. Tracked in [#2029](https://github.com/softistx/nxgt-material/issues/2029).
368
+
366
369
  - **Smaller, easier-to-read components (3.58.0)** — the library's own source
367
370
  is kept to files under 200 lines and functions under 80, enforced by a
368
371
  check, so a component is quicker to read and to fix. No API or behaviour
@@ -4675,7 +4675,12 @@ expect(await within(button.parentElement!).findByRole('status')).toHaveTextConte
4675
4675
  error)`. The status may be any non-2xx.
4676
4676
  **Why:** the storage rejected the `PUT` to the presigned URL. For `403` the
4677
4677
  usual causes are an expired URL, or a `Content-Type` that differs from the
4678
- one the URL was signed with.
4678
+ one the URL was signed with. With a presigned POST target, the policy refused
4679
+ the file (size, content type, expiry) and the message names the S3 code when
4680
+ it is readable, such as `Upload failed with status 400 (EntityTooLarge)`.
4681
+ The field shows it translated (`material.upload.error-status`, `error-status-code`);
4682
+ `error.message` in `onError` stays English, and `error` is an `UploadError`
4683
+ (`kind`, `status`, `code`).
4679
4684
  **Fix:**
4680
4685
 
4681
4686
  ```ts
@@ -4690,12 +4695,14 @@ const getPresignedUrl = async (file: File) => {
4690
4695
 
4691
4696
  **When:** an `S3UploadField` upload fails immediately. The browser console
4692
4697
  shows a CORS error for the storage host.
4693
- **Why:** the browser blocked the cross-origin `PUT`. The bucket's CORS rules
4694
- do not allow your origin, the `PUT` method, or the headers you send.
4698
+ **Why:** the browser blocked the cross-origin `PUT` (or `POST` with a presigned
4699
+ POST target). The bucket's CORS rules do not allow your origin, the method, or
4700
+ the headers you send; a `Content-Security-Policy` without the bucket host in
4701
+ `connect-src` fails the same way.
4695
4702
  **Fix:**
4696
4703
 
4697
4704
  ```json
4698
- [{ "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT"], "AllowedHeaders": ["*"] }]
4705
+ [{ "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT", "POST"], "AllowedHeaders": ["*"] }]
4699
4706
  ```
4700
4707
 
4701
4708
  Apply it on the bucket, with your own origin.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/material",
3
- "version": "3.67.0",
3
+ "version": "3.70.0",
4
4
  "license": "MIT",
5
5
  "bugs": {
6
6
  "email": "stevegires@gmail.com"