@nxgt/material 3.101.0 → 3.103.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 (155) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +32 -2
  3. package/dist/{alert-dialog-DYzxchiS.js → alert-dialog-NFyAiSqc.js} +1 -1
  4. package/dist/{async-slice-drJyPvuM.js → async-slice-D35bX79S.js} +1 -1
  5. package/dist/{bubble-input-CQwVSoU3.js → bubble-input-BxI1WmNs.js} +17 -5
  6. package/dist/{button-GeIsUekU.js → button-Be7GZzF9.js} +2 -2
  7. package/dist/{calculator-BS4PAm6l.js → calculator-BGy1Q7rn.js} +261 -460
  8. package/dist/calculator-field.js +7 -7
  9. package/dist/{calculator-segment-CEdwE24M.js → calculator-segment-uixk6lBg.js} +1 -1
  10. package/dist/calculator.js +1 -1
  11. package/dist/charts.js +3 -3
  12. package/dist/{chip-BdEtFQ85.js → chip-3rWebZEM.js} +2 -2
  13. package/dist/{code-editor-BByXZOGy.js → code-editor-BqSVMfy-.js} +178 -177
  14. package/dist/code-editor.js +5 -5
  15. package/dist/{code-language-items-CSZX5zxD.js → code-language-items-Cf2LJVg2.js} +14 -5
  16. package/dist/components/ui/actions/action-card/action-card-tile.css +47 -0
  17. package/dist/components/ui/display/code/code-moonlight.css +10 -10
  18. package/dist/{components-DQ4MljCr.js → components-D2liJ9Yu.js} +17725 -17287
  19. package/dist/components.js +38 -37
  20. package/dist/{conversion-variant-DTQqAyDt.js → conversion-variant-clFZ6ZMJ.js} +5 -5
  21. package/dist/{currency-field-Dnju7ss2.js → currency-field-CBFEVNM1.js} +24 -23
  22. package/dist/{dialog-5_2__tu8.js → dialog-CrIIINTk.js} +3 -3
  23. package/dist/dnd.js +1 -1
  24. package/dist/{dropdown-menu-FdGC5kIP.js → dropdown-menu-BnfLWu8n.js} +1 -1
  25. package/dist/editor.js +9 -9
  26. package/dist/{empty-state-BoepPG5v.js → empty-state-CuUaCPY7.js} +1 -1
  27. package/dist/{field-blur-DkiZOVNV.js → field-blur-DMJwPLsg.js} +9 -9
  28. package/dist/field-blur-context-3rKussBQ.js +29 -0
  29. package/dist/{financial-variant-BhqJp5C-.js → financial-variant-BY_VCUuk.js} +5 -5
  30. package/dist/{hooks-DjcbJZiO.js → hooks-hHoZbL-4.js} +106 -61
  31. package/dist/hooks.js +3 -3
  32. package/dist/{hover-card-CmHJeBeY.js → hover-card-Do2-uzlF.js} +1 -1
  33. package/dist/i18n.js +3 -3
  34. package/dist/{icon-badge-LGUPQh_c.js → icon-badge-CHaVNEmb.js} +1 -1
  35. package/dist/{icon-button-BSVpO1jP.js → icon-button-BIAubLeJ.js} +1 -1
  36. package/dist/key-layout-D2goCDkD.js +31 -0
  37. package/dist/keypad.js +551 -0
  38. package/dist/label-DR-zZyGQ.js +47 -0
  39. package/dist/lib/components/ui/display/code/code-block-parts.d.ts +1 -1
  40. package/dist/lib/components/ui/display/code/code-block.d.ts +4 -1
  41. package/dist/lib/components/ui/display/code/languages.d.ts +2 -0
  42. package/dist/lib/components/ui/display/code/moonlight-tokens.d.ts +10 -10
  43. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-dialog-props.d.ts +73 -0
  44. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-dialog-rules.d.ts +12 -0
  45. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-dialog.d.ts +5 -61
  46. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-footer.d.ts +12 -0
  47. package/dist/lib/components/ui/feedback/confirm-dialog/confirm-phrase.d.ts +26 -0
  48. package/dist/lib/components/ui/feedback/confirm-dialog/use-confirm-action.d.ts +1 -1
  49. package/dist/lib/components/ui/fields/contacts/placeholders.d.ts +1 -1
  50. package/dist/lib/components/ui/layout/index.d.ts +1 -0
  51. package/dist/lib/components/ui/layout/panel-card/index.d.ts +1 -0
  52. package/dist/lib/components/ui/layout/panel-card/panel-card.d.ts +21 -0
  53. package/dist/lib/components/ui/selection/index.d.ts +1 -0
  54. package/dist/lib/components/ui/selection/status-menu/index.d.ts +3 -0
  55. package/dist/lib/components/ui/selection/status-menu/partials/status-confirm.d.ts +12 -0
  56. package/dist/lib/components/ui/selection/status-menu/partials/status-icon.d.ts +6 -0
  57. package/dist/lib/components/ui/selection/status-menu/partials/status-items.d.ts +18 -0
  58. package/dist/lib/components/ui/selection/status-menu/partials/status-menu-list.d.ts +14 -0
  59. package/dist/lib/components/ui/selection/status-menu/partials/status-sheet-row.d.ts +11 -0
  60. package/dist/lib/components/ui/selection/status-menu/partials/status-sheet.d.ts +21 -0
  61. package/dist/lib/components/ui/selection/status-menu/partials/status-trigger.d.ts +18 -0
  62. package/dist/lib/components/ui/selection/status-menu/status-menu-data.d.ts +12 -0
  63. package/dist/lib/components/ui/selection/status-menu/status-menu-form-field.d.ts +20 -0
  64. package/dist/lib/components/ui/selection/status-menu/status-menu.d.ts +13 -0
  65. package/dist/lib/components/ui/selection/status-menu/types.d.ts +76 -0
  66. package/dist/lib/components/ui/selection/status-menu/use-status-change.d.ts +18 -0
  67. package/dist/lib/components/ui/selection/status-menu/use-status-menu.d.ts +30 -0
  68. package/dist/lib/components/ui/widgets/calculator/partials/calculator-keypad.d.ts +1 -5
  69. package/dist/lib/components/ui/widgets/calculator/variants/key-layout.d.ts +3 -49
  70. package/dist/lib/components/ui/widgets/keypad/cash-model.d.ts +16 -0
  71. package/dist/lib/components/ui/widgets/keypad/index.d.ts +5 -0
  72. package/dist/lib/components/ui/widgets/keypad/key-layout.d.ts +56 -0
  73. package/dist/lib/components/ui/widgets/keypad/keypad-binding.d.ts +32 -0
  74. package/dist/lib/components/ui/widgets/keypad/keypad-cash.d.ts +28 -0
  75. package/dist/lib/components/ui/widgets/keypad/keypad-display.d.ts +11 -0
  76. package/dist/lib/components/ui/widgets/keypad/keypad-entry.d.ts +39 -0
  77. package/dist/lib/components/ui/widgets/keypad/keypad-grid.d.ts +27 -0
  78. package/dist/lib/components/ui/widgets/{calculator/partials/calculator-key.d.ts → keypad/keypad-key.d.ts} +6 -3
  79. package/dist/lib/components/ui/widgets/keypad/keypad-layout.d.ts +19 -0
  80. package/dist/lib/components/ui/widgets/{calculator/partials → keypad}/keypad-navigation.d.ts +1 -1
  81. package/dist/lib/components/ui/widgets/keypad/keypad.d.ts +9 -0
  82. package/dist/lib/components/ui/widgets/keypad/keypad.types.d.ts +91 -0
  83. package/dist/lib/components/ui/widgets/keypad/use-keypad-cash.d.ts +14 -0
  84. package/dist/lib/components/ui/widgets/keypad/use-keypad-keyboard.d.ts +7 -0
  85. package/dist/lib/components/ui/widgets/keypad/use-keypad-number.d.ts +10 -0
  86. package/dist/lib/components/ui/widgets/keypad/use-keypad-quantity.d.ts +16 -0
  87. package/dist/lib/components/ui/widgets/keypad/use-keypad-value.d.ts +17 -0
  88. package/dist/lib/components/ui/widgets/keypad/use-keypad.d.ts +23 -0
  89. package/dist/lib/hooks/index.d.ts +1 -0
  90. package/dist/lib/hooks/use-barcode-scanner.d.ts +39 -0
  91. package/dist/lib/i18n/resources/en/en.d.ts +25 -0
  92. package/dist/lib/i18n/resources/en/material.json.d.ts +26 -1
  93. package/dist/lib/i18n/resources/fr/fr.d.ts +25 -0
  94. package/dist/lib/i18n/resources/fr/material.json.d.ts +26 -1
  95. package/dist/lib/i18n/resources/resources.d.ts +50 -0
  96. package/dist/lib/i18n/translator.d.ts +1 -1
  97. package/dist/lib/keypad/index.d.ts +20 -0
  98. package/dist/lib.js +1 -1
  99. package/dist/main.js +54 -53
  100. package/dist/map.js +9 -9
  101. package/dist/material.css +1 -1
  102. package/dist/pdf-reader.js +1 -1
  103. package/dist/{percent-field-DGlU_xii.js → percent-field-DU8eIMD3.js} +15 -14
  104. package/dist/{picker-surface-CGhqNR8a.js → picker-surface-XmA--JVI.js} +4 -4
  105. package/dist/{popover-ZxL7ktKk.js → popover-D8iHiAbl.js} +1 -1
  106. package/dist/{pos-types-BQ0QpYMp.js → pos-types-BEAY27Rm.js} +1 -1
  107. package/dist/{pos-variant-CKy8h_5i.js → pos-variant-ClGI92Lt.js} +25 -25
  108. package/dist/print.js +1 -1
  109. package/dist/{programmer-variant-CbHstE28.js → programmer-variant-Boq2O8hB.js} +30 -30
  110. package/dist/{public-D-tdOz4b.js → public-DOrXKmcL.js} +9 -9
  111. package/dist/{rich-code-block-CTPj1LuD.js → rich-code-block-Djj2XE_u.js} +18 -18
  112. package/dist/{scientific-variant-B8U9h3II.js → scientific-variant-BzF_aMQP.js} +8 -8
  113. package/dist/{sortable-provider-CSoWE7Ce.js → sortable-provider-DCZjsIIH.js} +2 -2
  114. package/dist/{spinner-ffuwBXHl.js → spinner-Cg0y_E_K.js} +1 -1
  115. package/dist/standard-layout-DTr9yy8Z.js +48 -0
  116. package/dist/{tabs-JgRq6ZJx.js → tabs-DpessV5s.js} +1 -1
  117. package/dist/{text-field-DURkIP-v.js → text-field-Dxk9lAKM.js} +16 -15
  118. package/dist/theme-picker.js +13 -13
  119. package/dist/{theme-toggle-n_Zg7JAA.js → theme-toggle-B6Jl5Kfn.js} +1 -1
  120. package/dist/{toolbar-items-IANZdcw4.js → toolbar-items-Cs1XrZsH.js} +4 -4
  121. package/dist/{translate-material-CMHUH5Ha.js → translate-material-Du0j0t97.js} +1 -1
  122. package/dist/{translator-9zA-Um3W.js → translator-BVs4n0Fz.js} +52 -2
  123. package/dist/use-calculator-keyboard-BZ1CA4Ut.js +222 -0
  124. package/dist/{use-chat-composer-BoIN7RCA.js → use-chat-composer-B4m3OEm1.js} +241 -240
  125. package/dist/{use-heading-spy-Ciw9GBHL.js → use-heading-spy-CO3_AtY9.js} +235 -154
  126. package/dist/{use-material-translation-CLNaMhUf.js → use-material-translation-uNc1FyC8.js} +1 -1
  127. package/dist/{use-return-focus-DWhbTGVe.js → use-return-focus-AfLRrUm-.js} +1 -1
  128. package/docs/README.md +1 -0
  129. package/docs/components/README.md +1 -0
  130. package/docs/components/buttons-and-actions.md +34 -2
  131. package/docs/components/data-display.md +4 -1
  132. package/docs/components/feedback-and-status.md +20 -0
  133. package/docs/components/navigation-and-layout.md +28 -0
  134. package/docs/components/rich-widgets.md +28 -0
  135. package/docs/components/selection-and-choice.md +120 -0
  136. package/docs/guides/calculator.md +1 -1
  137. package/docs/guides/cards-and-tiles.md +21 -24
  138. package/docs/guides/forms.md +1 -0
  139. package/docs/guides/helpers.md +49 -0
  140. package/docs/guides/keypad.md +225 -0
  141. package/docs/previews/data-display-codeblock.webp +0 -0
  142. package/docs/previews/data-display-codegroup.webp +0 -0
  143. package/docs/previews/data-display-jsonviewer.webp +0 -0
  144. package/docs/previews/navigation-layout-panelcard.webp +0 -0
  145. package/docs/previews/rich-widgets-keypad.webp +0 -0
  146. package/docs/previews/selection-choice-statusmenu.webp +0 -0
  147. package/docs/roadmap.md +21 -0
  148. package/docs/troubleshooting.md +63 -0
  149. package/lib/components/ui/actions/action-card/action-card-tile.css +47 -0
  150. package/lib/components/ui/display/code/code-moonlight.css +10 -10
  151. package/lib/components/ui/widgets/calculator/calculator.css +87 -0
  152. package/package.json +7 -2
  153. package/dist/field-blur-context-BLGgf_3z.js +0 -73
  154. package/dist/key-layout-cR79xfGE.js +0 -38
  155. package/dist/standard-layout-BuSRr3TY.js +0 -40
@@ -1,6 +1,6 @@
1
1
  import { o as e, r as t } from "./dist-DVRQkaTK.js";
2
2
  import { i as n, n as r, r as i, t as a } from "./dist-DWZXlCZR.js";
3
- import { C as o, S as s, x as c } from "./spinner-ffuwBXHl.js";
3
+ import { C as o, S as s, x as c } from "./spinner-Cg0y_E_K.js";
4
4
  import { a as l, i as u, r as d } from "./dist-DHdUUM1w.js";
5
5
  import { c as f, i as p, l as m, o as h, r as g, s as ee } from "./in-dialog-D61Yeaol.js";
6
6
  import { r as te } from "./escape-owner-DpaP0lm1.js";
package/docs/README.md CHANGED
@@ -11,6 +11,7 @@ detail: every option, the second example, and the traps.
11
11
  | [Themes for your app](guides/themes.md) | You define your own themes: `defineTheme` modules, the Vite plugin's `themes` option, the `nxgt-theme` command and its CI check, the tone warnings |
12
12
  | [Barcodes and label sheets](guides/barcode.md) | You draw a Code 128 or Data Matrix code and print a sheet of labels with `@nxgt/material/barcode`: `Barcode`, `LabelSheet` and `Label`, label size, columns, rows, gaps and a start offset, dark mode, print and accessibility |
13
13
  | [Calculator](guides/calculator.md) | You add an exact-decimal calculator with `@nxgt/material/calculator` (and `CalculatorField` with `@nxgt/material/calculator-field`): `Calculator`, the keyboard, the tape and memory, copy and paste, what is stored, feedback, sizing and accessibility |
14
+ | [Keypad](guides/keypad.md) | You add a numeric keypad outside the calculator with `@nxgt/material/keypad`: quantity, price, percentage and cash-tendered entry, decimal or minor-unit digits, calculation keys, field bindings (`useKeypadNumber`, `useKeypadQuantity`), the cash variant with change and bill shortcuts, the keyboard and accessibility |
14
15
  | [Charts](guides/charts.md) | You draw charts with `@nxgt/material/charts`: each chart, the shared props, reference bands, thresholds and point tones, zoom and phone scroll, the keyboard, accessibility, theming the `--chart-1..5` tokens, and moving off the Nivo charts, removed in 3.0 |
15
16
  | [Cards and tiles](guides/cards-and-tiles.md) | You want a `DataTable` as a list of cards or tiles: `layout`, `renderCard`, `renderTile`, the card's one link, selection, the sort control, columns by the list's width |
16
17
  | [EventCalendar](guides/event-calendar.md) | You mark periods with `spans`, label days and weeks with `dayBadges` and `weekBadges`, colour events with `tone`, cancel them with `status`, or make them read-only with `readOnlyEvents` |
@@ -65,6 +65,7 @@ Picking among options: select, combobox, autocomplete, checkbox, radio, card and
65
65
  [<img src="../previews/selection-choice-autocomplete.webp" height="72" alt="Autocomplete">](selection-and-choice.md#autocomplete)
66
66
  [<img src="../previews/selection-choice-choicecardgroup.webp" height="72" alt="ChoiceCardGroup">](selection-and-choice.md#choicecardgroup)
67
67
  [<img src="../previews/selection-choice-transferlist.webp" height="72" alt="TransferList">](selection-and-choice.md#transferlist)
68
+ [<img src="../previews/selection-choice-statusmenu.webp" height="72" alt="StatusMenu">](selection-and-choice.md#statusmenu)
68
69
 
69
70
  ## [Data display](data-display.md)
70
71
 
@@ -1199,7 +1199,7 @@ import { Banknote, CreditCard } from 'lucide-react';
1199
1199
  | `title`, `description`, `icon` | `ReactNode` | — | the content |
1200
1200
  | `status` | `ReactNode` | — | a line under the title; a string or number becomes a soft `Badge`, a node is shown as given |
1201
1201
  | `meta` | `ReactNode` | — | a secondary line, one line with an ellipsis |
1202
- | `action` | `ReactNode` | — | a trailing control outside the press surface, see [tile](#actioncard-tile); `title` is then required, a `role` moves to the title button; the card's `disabled` does not reach it, disable its control too |
1202
+ | `action` | `ReactNode` | — | a trailing control outside the press surface, see [tile](#actioncard-tile); `title` is then required, a `role` moves to the title button; the card's `disabled` does not reach it, disable its control too; in a `tile` it wraps under a narrow title |
1203
1203
 
1204
1204
  A card with `active`, `onClick`, `disabled` or your own `role` is `role="button"` (unless you set another): it takes the Tab stop, and
1205
1205
  Enter and Space press it. `active` sets `aria-pressed`; pass your own `role`
@@ -1218,7 +1218,39 @@ For a card choice bound to a form value, see `ChoiceCardGroup` on
1218
1218
  `variant="tile"` is a compact grid cell for boards: no minimum width (it fills
1219
1219
  its grid track), the title and the `action` on the first row, then `status`,
1220
1220
  `description` and `meta`, and no indicator unless `withIndicator`. `status` and
1221
- `meta` work on every variant.
1221
+ `meta` work on every variant. The `action` wraps under a narrow title instead of
1222
+ overflowing the tile.
1223
+
1224
+ A status that changes in place is a [`StatusMenu`](selection-and-choice.md#statusmenu)
1225
+ in `action`: the chip shows the status (so the card needs no `status` badge) and
1226
+ opens the choices, in a bottom sheet on a phone. Its button is named "Status of
1227
+ Unit 21: Free". The chip is 6 to 10rem wide (a long status word is wider). A tile
1228
+ with an `action` is a wrapping line: the title may shrink to 4.5rem
1229
+ (`--ac-title-min`), and an action that does not fit beside it drops under the
1230
+ title, so a chip fits in a two-per-row `xs` grid on a 360px phone and an icon
1231
+ button such as `RowMenu` stays beside the title. A wrapped action starts at the tile's line start (under the icon when the tile
1232
+ has one), not under the title text.
1233
+
1234
+ ```tsx
1235
+ import { ActionCard, StatusMenu } from '@nxgt/material/components';
1236
+
1237
+ <ActionCard
1238
+ variant="tile"
1239
+ title="Unit 21"
1240
+ meta="Zone B, second floor"
1241
+ onClick={() => openDetail(unit.id)}
1242
+ action={
1243
+ <StatusMenu
1244
+ label="Unit 21"
1245
+ value={unit.status}
1246
+ choices={statuses}
1247
+ onSelect={(next) => saveStatus(unit.id, next)} // a promise keeps the old status with a spinner
1248
+ />
1249
+ }
1250
+ />;
1251
+ ```
1252
+
1253
+ A compact icon-button action, with a `status` badge, uses `RowMenu`:
1222
1254
 
1223
1255
  ```tsx
1224
1256
  import {
@@ -1652,7 +1652,7 @@ The colour shows as a thin accent bar inset from the chip's edges (never a borde
1652
1652
 
1653
1653
  ![CodeBlock](../previews/data-display-codeblock.webp)
1654
1654
 
1655
- Highlighted code with line numbers and copy, a collapsible JSON tree, and tabbed samples (`Tabs`, underlined).
1655
+ Highlighted code (or plain text) with line numbers and copy, a collapsible JSON tree, and tabbed samples (`Tabs`, underlined).
1656
1656
 
1657
1657
  <img src="../previews/data-display-codegroup.webp" height="96" alt="CodeGroup">
1658
1658
  <img src="../previews/data-display-jsonviewer.webp" height="96" alt="JsonViewer">
@@ -1661,6 +1661,7 @@ Highlighted code with line numbers and copy, a collapsible JSON tree, and tabbed
1661
1661
  import { CodeBlock, CodeGroup, JsonViewer } from '@nxgt/material/components';
1662
1662
 
1663
1663
  <CodeBlock code={source} language="typescript" filename="greet.ts" highlightLines={[2]} />
1664
+ <CodeBlock code={dnsRecord} language="text" label={false} />
1664
1665
  <CodeGroup tabs={[
1665
1666
  { label: 'bun', language: 'bash', code: 'bun add @nxgt/material' },
1666
1667
  { label: 'npm', language: 'bash', code: 'npm i @nxgt/material' },
@@ -1671,6 +1672,8 @@ import { CodeBlock, CodeGroup, JsonViewer } from '@nxgt/material/components';
1671
1672
  | Prop | Type | Default | Effect |
1672
1673
  | --- | --- | --- | --- |
1673
1674
  | `scheme` | `'dark' \| 'light'` | follows the theme | forces the palette (all three) |
1675
+ | `language` | `string` | `typescript` | `CodeBlock`: one of 16 ids or aliases, or `text` (also `plaintext`, `plain`, `txt`) to show the code as is, labelled "Text". An unknown id still falls back to `typescript` |
1676
+ | `label` | `ReactNode \| false` | the language's name | `CodeBlock`: replaces the language label; `false` hides it |
1674
1677
  | `showLineNumbers` | `boolean` | `true` | `CodeBlock`, `CodeGroup` |
1675
1678
  | `highlightLines` | `number[]` | — | `CodeBlock`: marked lines |
1676
1679
  | `maxHeight` | `number \| string` | — | `CodeBlock`, `JsonViewer`: scrolls past it |
@@ -250,6 +250,8 @@ function DeleteProject({ onDelete }: { onDelete: () => Promise<void> }) {
250
250
  | `details` | `ReactNode` | — | a note under the description, read with it by assistive technology, for what else is affected or cannot be undone |
251
251
  | `refusal` | `(refusal: ConfirmRefusalContext) => ReactNode` | the message; an `ActionRefusal`'s details and action too | renders the refusal inside the error alert (still `role="alert"`, a `div`) instead of the default, so it can carry a link. Receives `{ message, cause, close }`: `message` is the thrown error's message or the translated `material.confirm-dialog.failed` text, `cause` is what `onConfirm` threw, `close()` closes the dialog |
252
252
  | `confirmLabel` / `cancelLabel` | `string` | "Confirm" / "Cancel", translated (`material.confirm-dialog.confirm` / `.cancel`) | button text |
253
+ | `confirmPhrase` | `string` | — | asks the user to type this text before Confirm enables, for a deletion worth a second thought. A field labelled by the translated `material.confirm-dialog.type-phrase` ("Type {phrase} to confirm", the phrase in code style) takes the focus on open instead of Cancel and starts empty each time the dialog opens. Paste is allowed; Enter in the field confirms only once it matches. It combines with `confirmDisabled` (both must allow). Empty or unset: no field |
254
+ | `confirmPhraseMatch` | `'exact' \| 'trim' \| 'trim-case-insensitive'` | `'exact'` | how the typed text is compared: character for character, ignoring spaces around it, or ignoring those and the letter case |
253
255
  | `cancelVariant` | `ButtonVariant` | `'tonal'` | the Cancel button's variant; `'outlined'` restores the earlier look |
254
256
  | `onCloseAutoFocus` | `(event: Event) => void` | — | called as the dialog closes, before the focus goes back; `event.preventDefault()` places it yourself, e.g. `ctx.focusTrigger()` of a `ServerTable` bulk action whose button a redraw replaced |
255
257
 
@@ -269,6 +271,24 @@ function DeleteProject({ onDelete }: { onDelete: () => Promise<void> }) {
269
271
  />
270
272
  ```
271
273
 
274
+ **Type the value to confirm.** For a deletion that should not happen by habit (a domain, a mailbox), ask for the name:
275
+
276
+ ```tsx
277
+ <ConfirmDialog
278
+ open={open}
279
+ onOpenChange={setOpen}
280
+ variant="destructive"
281
+ title="Delete example.com?"
282
+ description="Its mailboxes are deleted with it."
283
+ confirmLabel="Delete"
284
+ confirmPhrase="example.com"
285
+ confirmPhraseMatch="trim-case-insensitive"
286
+ onConfirm={remove}
287
+ />
288
+ ```
289
+
290
+ `ConfirmDialog` has no form; `ConfirmationDialog` (with `formOptions`) does not take `confirmPhrase`. The Vue twin does not have it yet.
291
+
272
292
  **The text stays while the dialog fades out.** A confirm is usually driven by a nullable subject, and closing clears it: `open={item !== null}`. `ConfirmDialog` keeps the `title`, `description`, `details`, button labels and `variant` of its last open render until it has closed, so the title does not read "Delete ?" during the exit animation. Nothing to add in the app:
273
293
 
274
294
  ```tsx
@@ -22,6 +22,7 @@ documentation-site shell. Everything is imported from
22
22
  | <img src="../previews/navigation-layout-dashboardgrid.webp" width="96" alt=""> | [DashboardGrid, DashboardTile](#dashboardgrid) | Tiles with spans that the user rearranges in an edit mode, by drag or keyboard; the order is the app's |
23
23
  | <img src="../previews/navigation-layout-editorlayout.webp" width="96" alt=""> | [EditorLayout](#editorlayout) | The editor page: toolbar, canvas that scrolls alone, properties at the end (a bottom sheet on a phone), optional outline at the start |
24
24
  | <img src="../previews/navigation-layout-sectioncolumns.webp" width="96" alt=""> | [SectionColumns](#sectioncolumns) | Short page sections side by side from a viewport breakpoint, stacked below it |
25
+ | <img src="../previews/navigation-layout-panelcard.webp" width="96" alt=""> | [PanelCard](#panelcard) | Titled card for a side panel: heading, description, header actions |
25
26
  | <img src="../previews/navigation-layout-recordpage.webp" width="96" alt=""> | [RecordPage, RecordDetails](#recordpage) | The page of one record: header card, content, facts as a side panel from `xl` |
26
27
  | <img src="../previews/navigation-layout-resizable.webp" width="96" alt=""> | [ResizableGroup](#resizablegroup) | Resizable panels |
27
28
  | <img src="../previews/navigation-layout-skiplink.webp" width="96" alt=""> | [SkipLink](#skiplink) | Skip-to-content link |
@@ -1197,6 +1198,33 @@ props, so `className` adds to it.
1197
1198
 
1198
1199
  **Storybook:** `Navigation & layout/SectionColumns` · **Vue:** later ([#1614](https://github.com/softistx/nxgt-material/issues/1614))
1199
1200
 
1201
+ ## PanelCard
1202
+
1203
+ A titled card for the side of a page (an `ActivityContent` aside) or its sections: a heading, an optional one-line description and header actions over the content. It replaces the `Card` + `CardHeader` + `CardTitle as="h2"` + `CardDescription` + `CardContent` wrapper apps wrote themselves.
1204
+
1205
+ ```tsx
1206
+ import { PanelCard } from '@nxgt/material/components';
1207
+
1208
+ <PanelCard
1209
+ title="Recent activity"
1210
+ description="The last five changes to this record."
1211
+ actions={<Button size="sm" variant="outlined">Refresh</Button>}
1212
+ >
1213
+ …
1214
+ </PanelCard>
1215
+ ```
1216
+
1217
+ | Prop | Type | Default | Effect |
1218
+ | --- | --- | --- | --- |
1219
+ | `title` | `ReactNode` | required | The heading, and the accessible name of the card |
1220
+ | `description` | `ReactNode` | none | One line under the title |
1221
+ | `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `2` | The title's heading level; use `3` inside a section that already has an `h2` |
1222
+ | `actions` | `ReactNode` | none | A slot at the end of the title row (a button, a `RowMenu`) |
1223
+
1224
+ The card is a `role="region"` labelled by its title, so `getByRole('region', { name })` finds it. Children are the card's content (`CardContent`); with none, the card is its header only. It takes the `div`'s props, so `className` adds to it.
1225
+
1226
+ **Storybook:** `Navigation & layout/PanelCard` · **Vue:** later, [#2866](https://github.com/softistx/nxgt-material/issues/2866)
1227
+
1200
1228
  ---
1201
1229
 
1202
1230
  ## RecordPage
@@ -13,6 +13,7 @@ the app owns the data.
13
13
  | <img src="../previews/rich-widgets-drawing.webp" width="96" alt=""> | [Drawing](#drawing) | Vector drawing from a scene of plain data or JSX primitives: theme colours, accessible, focusable nodes, strict-CSP safe |
14
14
  | <img src="../previews/rich-widgets-barcode.webp" width="96" alt=""> | [Barcode](#barcode) | Code 128 and Data Matrix as accessible SVG, themed and dark-aware, black on white in print, with `LabelSheet` for a printed sheet of labels |
15
15
  | <img src="../previews/rich-widgets-calculator.webp" width="96" alt=""> | [Calculator](#calculator) | Exact decimal calculator (Standard, Scientific, a point of sale, Financial, Conversion and Programmer) with the usual precedence, a shaped keypad, tape, memory, keyboard, copy and paste, and a number field whose button opens it (`CalculatorField`, in `@nxgt/material/calculator-field`), in its own entry; variants other than Standard load on demand |
16
+ | <img src="../previews/rich-widgets-keypad.webp" width="96" alt=""> | [Keypad](#keypad) | A touch and keyboard numeric keypad for a quantity, a price, a percentage or a cash tender with change (`@nxgt/material/keypad`), decimal or minor-unit entry, field bindings, in its own entry, styled by `calculator.css` |
16
17
  | <img src="../previews/rich-widgets-emojipicker.webp" width="96" alt=""> | [EmojiPicker](#emojipicker) | Shared emoji picker and list |
17
18
  | <img src="../previews/rich-widgets-mentionlist.webp" width="96" alt=""> | [MentionList](#mentionlist) | `@` mention list with local search |
18
19
  | <img src="../previews/rich-widgets-documenteditor.webp" width="96" alt=""> | [DocumentEditor](#documenteditor) | Rich text editor for long-form writing, built on a headless core |
@@ -1012,6 +1013,33 @@ import { CalculatorField, CalculatorFormField } from '@nxgt/material/calculator-
1012
1013
 
1013
1014
  See the [calculator guide](../guides/calculator.md).
1014
1015
 
1016
+ ## Keypad
1017
+
1018
+ A numeric keypad from `@nxgt/material/keypad`, usable outside the Calculator:
1019
+ quantity, price, percentage and cash-tendered entry. The value is a canonical
1020
+ decimal string and the arithmetic is the calculator's exact engine. Digits go in
1021
+ as typed (`entry="decimal"`) or like a till (`entry="minor"`, `1 2 5` is `1.25`);
1022
+ `operations` adds calculation keys; `min` and `max` hold the value; the keyboard
1023
+ works when focus is in the keypad (`keyboard="global"` for a till). Its own entry,
1024
+ server-safe, styled by `@nxgt/material/calculator.css` (import it after
1025
+ `styles.css`; there is no keypad stylesheet).
1026
+
1027
+ ```tsx
1028
+ import { Keypad, useKeypadNumber } from '@nxgt/material/keypad';
1029
+
1030
+ <Keypad value={qty} onValueChange={setQty} onCommit={(value) => addLine(value)} />
1031
+ const binding = useKeypadNumber({ value: qty, onChange: setQty, min: 1 });
1032
+ <Keypad binding={binding} />
1033
+ <Keypad variant="cash" total="17.35" denominations={[5, 10, 20, 50]} currency="EUR" onTender={pay} />
1034
+ ```
1035
+
1036
+ `useKeypadNumber` binds it to a `NumberField`, `PercentField` or `CurrencyField`
1037
+ (also with `minorUnits`), `useKeypadQuantity` to a `QuantityField`; the bound
1038
+ keypad hides its own display unless `operations` are offered. The `cash` variant shows the total, the change or
1039
+ the amount still due, bill shortcuts from `denominations`, and reports
1040
+ `onTender(tendered, settlement)`; your app owns the charge. See the
1041
+ [keypad guide](../guides/keypad.md).
1042
+
1015
1043
  ## EmojiPicker
1016
1044
 
1017
1045
  ![EmojiPicker](../previews/rich-widgets-emojipicker.webp)
@@ -29,6 +29,7 @@ value type each one writes. Everything is imported from
29
29
  | <img src="../previews/selection-choice-sortablelist.webp" width="96" alt=""> | [SortableList](#sortablelist) | Reorderable list: drag or keyboard, announced; two-line labels, row menu |
30
30
  | <img src="../previews/selection-choice-treeselectfield.webp" width="96" alt=""> | [TreeSelectField](#treeselectfield) | Pick from a tree, single or multiple |
31
31
  | <img src="../previews/selection-choice-switcher.webp" width="96" alt=""> | [Switcher](#switcher) | Workspace, establishment or class switcher: menu, search, groups, links; `size="compact"` an icon-only round trigger for the app bar's `titleAfter` slot |
32
+ | <img src="../previews/selection-choice-statusmenu.webp" width="96" alt=""> | [StatusMenu](#statusmenu) | A record's status as a soft chip that opens a menu (a bottom sheet on a phone) of statuses: transitions, locked, busy, read-only; `StatusMenuFormField` for forms |
32
33
 
33
34
  ## Select
34
35
 
@@ -1020,6 +1021,125 @@ on a touch screen.
1020
1021
 
1021
1022
  ---
1022
1023
 
1024
+ ## StatusMenu
1025
+
1026
+ ![StatusMenu](../previews/selection-choice-statusmenu.webp)
1027
+
1028
+ A record's status (a ticket, an order, a patient, a device) as a chip that
1029
+ changes it in one tap: a soft Badge pill (the tone's container, the tone's
1030
+ icon, the words, a chevron) that opens a menu of radio items. The held status
1031
+ is checked and inert. The button is named "Status of Ticket 42: Open", and
1032
+ `onSelect` never runs for the held status, for a blocked one, or when locked or
1033
+ busy.
1034
+
1035
+ - `transitions` greys the statuses it does not reach, each saying why. A
1036
+ terminal status needs `done: []`; unlike `useAllowedTransitions`, a missing
1037
+ key allows every move.
1038
+ - `locked` picks nothing and shows its `reason` as a tooltip and as the menu's
1039
+ first line.
1040
+ - `busy` keeps the old status and swaps the chevron for a spinner. A returned
1041
+ promise does the same until it settles; a rejection is announced and sent to
1042
+ `onError`.
1043
+ - `confirm` on a choice asks first, in a dialog.
1044
+ - `readOnly` draws a plain label, no button.
1045
+
1046
+ On a phone (under 768px) the statuses open in a bottom sheet of 44px rows
1047
+ instead of the menu; a status that cannot be picked says why in its own row
1048
+ (touch has no tooltip). `presentation` is `'auto'` (default), `'menu'` or
1049
+ `'sheet'`.
1050
+
1051
+ ```tsx
1052
+ import { StatusMenu, type StatusChoice } from '@nxgt/material/components';
1053
+
1054
+ const statuses: StatusChoice<'todo' | 'progress' | 'done'>[] = [
1055
+ { value: 'todo', label: 'To do', tone: 'neutral', icon: 'circle' },
1056
+ { value: 'progress', label: 'In progress', tone: 'info', icon: 'circle-half-stroke' },
1057
+ { value: 'done', label: 'Done', tone: 'success', icon: 'circle-check' },
1058
+ ];
1059
+
1060
+ <StatusMenu
1061
+ label="Ticket 42"
1062
+ value={status}
1063
+ choices={statuses}
1064
+ transitions={{ done: ['progress'] }}
1065
+ presentation="auto" // 'menu' | 'sheet' to force one
1066
+ onSelect={(next) => setStatus(next)}
1067
+ />;
1068
+ ```
1069
+
1070
+ Saving through a server, with a status that asks first:
1071
+
1072
+ ```tsx
1073
+ const statuses: StatusChoice<'open' | 'cancelled'>[] = [
1074
+ { value: 'open', label: 'Open', tone: 'info' },
1075
+ {
1076
+ value: 'cancelled',
1077
+ label: 'Cancelled',
1078
+ tone: 'error',
1079
+ confirm: { title: 'Cancel this ticket?', confirmLabel: 'Cancel ticket', destructive: true },
1080
+ },
1081
+ ];
1082
+
1083
+ <StatusMenu
1084
+ label="Ticket 42"
1085
+ value={status}
1086
+ choices={statuses}
1087
+ onSelect={async (next) => {
1088
+ await saveStatus(ticketId, next); // pending until it settles; a throw is announced
1089
+ setStatus(next);
1090
+ }}
1091
+ onError={(error) => toast.error(String(error))}
1092
+ />;
1093
+ ```
1094
+
1095
+ `confirm` defaults: `confirmLabel` "Confirm", `destructive` `false`, `description`
1096
+ "Change the status to {label}?" (`material.status-menu.confirm-default`). The menu
1097
+ marks such a choice "Asks first" (`material.status-menu.confirms`). A confirmed
1098
+ save shows its progress in the dialog (busy buttons, a failure kept in it), not on the chip.
1099
+
1100
+ | Prop | Type | Default | Effect |
1101
+ | --- | --- | --- | --- |
1102
+ | `label` | `string` | — | what the status belongs to; names the button "Status of {label}: {status}" |
1103
+ | `value` / `choices` | `V` / `readonly StatusChoice<V>[]` | — | the held status, and every status: `value`, `label`, `tone`, `icon`, `description`, `disabled`, `disabledReason`, `confirm` (`title`, `description`, `confirmLabel`, `destructive`: a `ConfirmDialog` runs `onSelect` on confirm, a failure stays in the dialog and reaches `onError`) |
1104
+ | `onSelect` | `(next, prev) => void \| Promise<void>` | — | a different status was picked; a returned promise keeps the chip pending (menu closed, focus on the chip, spinner, `aria-busy`, old status shown) until it settles |
1105
+ | `onError` | `(error) => void` | — | the promise rejected (or threw): the failure is announced and the old status stays |
1106
+ | `transitions` | `Partial<Record<V, readonly V[]>>` | — | the statuses reachable from each status (no key: every move; a terminal status is `[]`) |
1107
+ | `unavailable` | `'disabled' \| 'hidden'` | `'disabled'` | an unreachable status is greyed with "Not available from {status}", or dropped |
1108
+ | `locked` / `reason` | `boolean` / `string` | — | nothing can be picked; `reason` is the tooltip, the button's description and the menu's first line |
1109
+ | `busy` | `boolean` | `false` | `aria-busy` and `aria-disabled`, a spinner, the old status kept, the menu does not open |
1110
+ | `readOnly` | `boolean` | `false` | a plain label |
1111
+ | `size` | `'sm' \| 'md'` | `'md'` | chip height |
1112
+ | `presentation` | `'auto' \| 'menu' \| 'sheet'` | `'auto'` | a dropdown menu, a bottom sheet of 44px rows, or the sheet under 768px |
1113
+ | `open` / `onOpenChange` | `boolean` / `(open) => void` | — | controlled menu |
1114
+ | `triggerProps` / `className` | | | on the button / on the menu |
1115
+
1116
+ Texts come from `material.status-menu.*` (English and French). The rules
1117
+ (which statuses can be picked, and why not) are `@nxgt/material-core/status-menu`.
1118
+
1119
+ **Form wrapper:** `StatusMenuFormField` takes `control`, `name`, `rules` and the
1120
+ StatusMenu props except `value` (the form holds it). A pick runs
1121
+ `onSelect` first; the form takes the new status once `onSelect` resolves (a
1122
+ promise keeps the pending state and the old status, and a rejection keeps the old
1123
+ value). `field.onBlur` fires when focus leaves the chip and its menu or sheet. An
1124
+ empty value (`''`) matches no choice, so the chip shows an empty value: give an
1125
+ initial status. The error message (or `helperText`) is written under the chip and linked to
1126
+ it with `aria-describedby` and `aria-invalid`, a refused submit focuses the chip,
1127
+ and `disabled` (or the form's) locks it.
1128
+
1129
+ ```tsx
1130
+ <StatusMenuFormField
1131
+ control={form.control}
1132
+ name="status"
1133
+ label="Ticket 42"
1134
+ choices={statuses}
1135
+ rules={{ required: 'Pick a status.' }}
1136
+ />
1137
+ ```
1138
+
1139
+ **Storybook:** `Selection & choice/StatusMenu` (and `/In a form`) · **Vue:** React only for now (#2816)
1140
+
1141
+ ---
1142
+
1023
1143
  ## OptionTile
1024
1144
 
1025
1145
  ![OptionTile](../previews/selection-choice-optiontile.webp)
@@ -123,7 +123,7 @@ numbers: `sqrt(-1)` is "Undefined result".
123
123
  />
124
124
  ```
125
125
 
126
- The props below are read only by this variant (`currency` is shared). A point of sale till for a counter, on `@nxgt/material-core/calculator/pos`; the
126
+ The props below are read only by this variant (`currency` is shared). A point of sale till for a counter, on `@nxgt/material-core/calculator/pos` (the engine; apps import `@nxgt/material/calculator`). The
127
127
  tab is named "Point of sale". The keys type an amount the way a till does:
128
128
  `1`, `2`, `5` is `1.25`, `00` adds two zeros, backspace drops the last digit,
129
129
  `C` clears it. `+` adds it to the ticket. `×` types a quantity first (`2.50 ×
@@ -326,49 +326,46 @@ grid of compact cells you read at a glance and change in place. Use
326
326
  [`ActionCard variant="tile"`](../components/buttons-and-actions.md#actioncard-tile)
327
327
  in a `ResponsiveGrid minItem="xs"`, one grid per section under its own `h2`:
328
328
 
329
- - **`status`** is a soft `Badge` with its word and an icon, so a status is
330
- never told by colour alone.
329
+ - **`action`** is a [`StatusMenu`](../components/selection-and-choice.md#statusmenu)
330
+ chip: it shows the status as a word, an icon and a tone (never colour alone),
331
+ so the tile needs no `status` badge, and it changes the status in place (a
332
+ bottom sheet on a phone). Its button is named "Status of {name}: {status}".
333
+ The title is the one button (it opens the detail), so the card and its chip
334
+ are two tab stops and nothing nests. When the tile is too narrow for the title
335
+ and the chip side by side (two per row at 360px), the chip wraps under the
336
+ title.
331
337
  - **`meta`** is one line, the occupant only while occupied; a long one is
332
338
  clipped and shown in full in a tooltip.
333
- - **`action`** is a `RowMenu` with a `DropdownMenuRadioGroup` of statuses,
334
- named "Change status of {name}". The title is the one button (it opens the
335
- detail), so the card and its menu are two tab stops and nothing nests.
336
339
 
337
340
  ```tsx
338
- import {
339
- ActionCard,
340
- Badge,
341
- DropdownMenuRadioGroup,
342
- DropdownMenuRadioItem,
343
- ResponsiveGrid,
344
- RowMenu,
345
- } from '@nxgt/material/components';
341
+ import { ActionCard, ResponsiveGrid, StatusMenu } from '@nxgt/material/components';
342
+
343
+ const choices = [
344
+ { value: 'free', label: 'Free', tone: 'success', icon: 'check' },
345
+ { value: 'occupied', label: 'Occupied', tone: 'warning', icon: 'user' },
346
+ { value: 'out', label: 'Out of service', tone: 'error', icon: 'ban' },
347
+ ];
346
348
 
347
349
  <ResponsiveGrid minItem="xs">
348
350
  <ActionCard
349
351
  variant="tile"
350
352
  title="Bed A1"
351
- status={<Badge variant="soft" tone="warning" icon="user">Occupied</Badge>}
352
353
  meta="Amara Diallo"
353
354
  onClick={() => openDetail('a1')}
354
355
  action={
355
- <RowMenu
356
+ <StatusMenu
356
357
  label="Bed A1"
357
- triggerProps={{ 'aria-label': 'Change status of Bed A1' }}
358
- >
359
- <DropdownMenuRadioGroup value={status} onValueChange={setStatus}>
360
- <DropdownMenuRadioItem value="free">Free</DropdownMenuRadioItem>
361
- <DropdownMenuRadioItem value="occupied">Occupied</DropdownMenuRadioItem>
362
- <DropdownMenuRadioItem value="cleaning">Cleaning</DropdownMenuRadioItem>
363
- </DropdownMenuRadioGroup>
364
- </RowMenu>
358
+ value={status}
359
+ choices={choices}
360
+ onSelect={setStatus}
361
+ />
365
362
  }
366
363
  />
367
364
  </ResponsiveGrid>;
368
365
  ```
369
366
 
370
367
  `title` is required with `action`: it is the card's button, named after the
371
- title, the status and the meta line.
368
+ title and the meta line.
372
369
 
373
370
  For the figures of the whole board, put a `StatCardGroup` above a `DataTable`
374
371
  with one row per section, one numeric column per status (they add up to the
@@ -537,6 +537,7 @@ or fails validation.
537
537
  | `Checkbox` · `CheckboxFormField` | `boolean` | `false` (see [Traps](#traps)) |
538
538
  | `CheckboxGroup` · `CheckboxGroupFormField` | `string[]` | `[]` |
539
539
  | `RadioGroup` · `RadioGroupFormField` | `string` | `''` |
540
+ | `StatusMenu` · `StatusMenuFormField` | `string`: the picked `choices` value | `''` matches no choice, so the chip shows an empty value: give an initial status |
540
541
  | `CheckCardField` · `CheckCardFormField` | `boolean`: `true` for the first card, `false` for the second | `false` |
541
542
  | `ChipGroupField` · `ChipGroupFormField` | `type="single"` (default): `string \| null`. `type="multiple"`: `string[]` | `null` / `[]` |
542
543
  | `ChoiceCardGroup` · `ChoiceCardGroupFormField` | same as `ChipGroupField` | `null` / `[]` |
@@ -139,6 +139,7 @@ Vue: the Redux helpers are Pinia store factories: `defineLocalStore`
139
139
  | `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). |
140
140
  | `useScrollAway(ref?, { threshold? })` | True while the reader scrolls down (by `threshold`, 24 px, since the scroll last turned); false scrolling up, at the top or at the end. Folds an extended `Fab`. Without `ref`, the window; false on the server. See [Fab](../components/buttons-and-actions.md#fab). |
141
141
  | `useLatched(value)`, `useLatchedWhileClosed(open, value)` | The last value that was not `null` / `undefined`, and the value from the last open render while `open` is false: keep a dialog's text while it fades out after the app cleared its subject. `ConfirmDialog` does it itself. See [below](#uselatched). |
142
+ | `useBarcodeScanner({ onScan, enabled?, acceptIn?, maxGap?, minLength?, onKey? })`, `createScanner()`, `attachBarcodeScanner(target, options)` | Hears a keyboard-wedge barcode reader anywhere on the page: a run of keys each within `maxGap` ms (50), at least `minLength` (4) long, closed by Enter, reaches `onScan(code)`; slower or shorter runs are typing. `createScanner` is the framework-free detector (`@nxgt/material-core/barcode-scanner`, also re-exported by `@nxgt/material/hooks`). See [below](#usebarcodescanner). |
142
143
  | `useCountdown(duration, { paused?, onExpire?, key? })` | Seconds left of `duration` (the clock `CountdownTimer` draws), for a button or a message that waits: ticks on the client only, `duration` on the server and first render. A new `duration` **or a new `key`** starts over, so the same `retryAfter` twice restarts it. See [below](#usecountdown). |
143
144
  | `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). |
144
145
  | `useDrafts<T>(storageKey, { serialize?, parse?, storage? })`, `clearDrafts(storageKey, storage?)` | Unsent values kept by key in `sessionStorage`: `{ get, set, drop, clear, keys, loaded }`. Read after the first render (no hydration mismatch), storage errors swallowed, `drop(key)` once saved; `clearDrafts` empties a store key without a hook (sign-out). See [below](#usedrafts). |
@@ -260,6 +261,54 @@ export function ItemDialog({ item, onClose }: { item: Item | null; onClose: () =
260
261
  things: it returns `value` while `open`, and the value of the last open render
261
262
  once `open` is false.
262
263
 
264
+ ### `useBarcodeScanner`
265
+
266
+ A USB or Bluetooth barcode reader is a keyboard: it types the whole code in a
267
+ few milliseconds a key and ends with Enter. The hook tells that run from a
268
+ person typing by timing alone, with one capture-phase `keydown` listener on
269
+ the document, for a till, a stock count or a check-in screen. It does not draw
270
+ barcodes: that is [`Barecode`](../components/data-display.md#barecode).
271
+
272
+ ```tsx
273
+ import { useState } from 'react';
274
+ import { useBarcodeScanner } from '@nxgt/material/hooks';
275
+
276
+ export function Till({ add }: { add: (code: string) => void }) {
277
+ const [query, setQuery] = useState('');
278
+ useBarcodeScanner({ onScan: add, acceptIn: '[data-till-search]' });
279
+ return (
280
+ <input
281
+ data-till-search=""
282
+ aria-label="Search an item"
283
+ value={query}
284
+ onChange={(event) => setQuery(event.target.value)}
285
+ />
286
+ );
287
+ }
288
+ ```
289
+
290
+ | Option | Default | Effect |
291
+ | --- | --- | --- |
292
+ | `onScan` | required | called with the code when a scan ends; the Enter that closed it is swallowed (`preventDefault`, `stopPropagation`) |
293
+ | `enabled` | `true` | while `false` nothing is listened to and nothing is swallowed |
294
+ | `acceptIn` | none | a CSS selector of the form fields a scan is also accepted in (the search field); every other `input`, `textarea`, `select` and editable element is typed in. Outside form fields a scan is always accepted. In an accepted field the scan's characters are already typed in when Enter arrives: `onScan` clears the field itself |
295
+ | `maxGap` | `50` | the longest pause between two keys of one scan, in ms |
296
+ | `minLength` | `4` | the shortest code taken for a scan |
297
+ | `onKey` | none | `(event, { result, pending })` for each key the scanner looked at: a screen with its own shortcuts skips them while `pending.length > 1`, since those keys are a scan's |
298
+
299
+ Ctrl, Meta and Alt chords and key repeat are ignored. `onScan` and `onKey` are
300
+ read from a ref, so a new function each render never re-binds the listener.
301
+ The hook does nothing on the server. A reader that types slower than 50 ms a
302
+ key (some Bluetooth modes) needs a larger `maxGap`.
303
+
304
+ Outside React, `createScanner({ maxGap?, minLength? })` from
305
+ `@nxgt/material-core/barcode-scanner` (or `@nxgt/material/hooks`, which re-exports it) is the pure detector:
306
+ `feed(key, time)` answers `{ kind: 'pass' }`, `{ kind: 'buffering' }` or
307
+ `{ kind: 'scan', code }`; `reset()` and `pending` complete it. The timing is
308
+ the caller's (`event.timeStamp`), so it is tested without a clock.
309
+ `attachBarcodeScanner(target, options)` is the DOM binding without React and
310
+ returns what removes the listener.
311
+
263
312
  ### `useCountdown`
264
313
 
265
314
  The clock behind [`CountdownTimer`](../components/feedback-and-status.md#countdowntimer),