@nxgt/material 3.54.0 → 3.56.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 (132) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +8 -3
  3. package/dist/{async-slice-DDSsekfm.js → async-slice-CORlM_wu.js} +2 -2
  4. package/dist/{button-Ct8DSLVv.js → button-CdpVEza0.js} +1 -1
  5. package/dist/charts.js +3 -3
  6. package/dist/{chip-BzoNsd-W.js → chip-C0yNYz9m.js} +2 -2
  7. package/dist/{code-editor-DsLQeiZc.js → code-editor-Bwzp2038.js} +6 -6
  8. package/dist/code-editor.js +5 -5
  9. package/dist/{code-language-items-4zXlIEZ0.js → code-language-items-nP-pF7yH.js} +2 -2
  10. package/dist/components/components.css +8 -0
  11. package/dist/components/ui/display/seat-ring/seat-ring-index.css +125 -0
  12. package/dist/components/ui/display/seat-ring/seat-ring-ring.css +87 -0
  13. package/dist/components/ui/display/seat-ring/seat-ring.css +139 -0
  14. package/dist/components/ui/display/secret-card/secret-card.css +65 -0
  15. package/dist/components/ui/display/vote-tally/vote-tally.css +94 -0
  16. package/dist/{components-DFG2mgsM.js → components-Dl2hVfzt.js} +16273 -15482
  17. package/dist/components.js +26 -26
  18. package/dist/{dialog-DwPmwmA3.js → dialog-BVgsN9Ch.js} +4 -4
  19. package/dist/dnd.js +1 -1
  20. package/dist/{dropdown-menu-CDN6Nqx6.js → dropdown-menu-C8PBmMIa.js} +1 -1
  21. package/dist/editor.js +9 -9
  22. package/dist/{empty-state-Bb9W9did.js → empty-state-C5wfbbW9.js} +2 -2
  23. package/dist/{escape-owner-DI_lg2ID.js → escape-owner-CYnfySUl.js} +1 -1
  24. package/dist/{field-blur-BcJH8H9B.js → field-blur-Nid7-bt_.js} +1 -1
  25. package/dist/{field-blur-context-Dg1nwWOV.js → field-blur-context-DxdIuHAY.js} +1 -1
  26. package/dist/hooks.js +28 -28
  27. package/dist/{hover-card-Qse4gnop.js → hover-card-C_9jiwp1.js} +2 -2
  28. package/dist/{i18n-provider-DlVPS58N.js → i18n-provider-CLVZK6tQ.js} +1 -1
  29. package/dist/i18n.js +5 -5
  30. package/dist/{icon-badge-DVPBQOKe.js → icon-badge-Dcjg_SKJ.js} +3 -3
  31. package/dist/{icon-button-C3wDxsh6.js → icon-button-Bo-VuZ2n.js} +1 -1
  32. package/dist/lib/components/ui/actions/link/link.d.ts +8 -0
  33. package/dist/lib/components/ui/collections/data-table/partials/data-table-page-control.d.ts +1 -1
  34. package/dist/lib/components/ui/collections/data-table/partials/data-table-row.d.ts +1 -1
  35. package/dist/lib/components/ui/collections/data-table/partials/layouts/data-table-card.d.ts +1 -1
  36. package/dist/lib/components/ui/collections/server-table/partials/server-table-past-end.d.ts +1 -1
  37. package/dist/lib/components/ui/display/index.d.ts +3 -0
  38. package/dist/lib/components/ui/display/list-tile/partials/list-tile-line.d.ts +1 -1
  39. package/dist/lib/components/ui/display/period-cards/period-cards.d.ts +1 -1
  40. package/dist/lib/components/ui/display/seat-ring/index.d.ts +2 -0
  41. package/dist/lib/components/ui/display/seat-ring/partials/empty-seat.d.ts +5 -0
  42. package/dist/lib/components/ui/display/seat-ring/partials/seat-body.d.ts +9 -0
  43. package/dist/lib/components/ui/display/seat-ring/partials/seat-item.d.ts +17 -0
  44. package/dist/lib/components/ui/display/seat-ring/seat-ring.d.ts +9 -0
  45. package/dist/lib/components/ui/display/seat-ring/seat-ring.utils.d.ts +9 -0
  46. package/dist/lib/components/ui/display/seat-ring/types.d.ts +67 -0
  47. package/dist/lib/components/ui/display/seat-ring/use-seat-ring.d.ts +12 -0
  48. package/dist/lib/components/ui/display/secret-card/index.d.ts +2 -0
  49. package/dist/lib/components/ui/display/secret-card/secret-card.d.ts +11 -0
  50. package/dist/lib/components/ui/display/secret-card/types.d.ts +49 -0
  51. package/dist/lib/components/ui/display/secret-card/use-secret-card.d.ts +20 -0
  52. package/dist/lib/components/ui/display/vote-tally/announcer.d.ts +11 -0
  53. package/dist/lib/components/ui/display/vote-tally/index.d.ts +2 -0
  54. package/dist/lib/components/ui/display/vote-tally/partials/tally-bar.d.ts +8 -0
  55. package/dist/lib/components/ui/display/vote-tally/partials/tally-row.d.ts +9 -0
  56. package/dist/lib/components/ui/display/vote-tally/partials/tally-voters.d.ts +9 -0
  57. package/dist/lib/components/ui/display/vote-tally/types.d.ts +73 -0
  58. package/dist/lib/components/ui/display/vote-tally/use-announcement.d.ts +7 -0
  59. package/dist/lib/components/ui/display/vote-tally/use-tally-summary.d.ts +3 -0
  60. package/dist/lib/components/ui/display/vote-tally/vote-tally.d.ts +10 -0
  61. package/dist/lib/components/ui/display/vote-tally/vote-tally.utils.d.ts +18 -0
  62. package/dist/lib/components/ui/fields/contacts/placeholders.d.ts +1 -1
  63. package/dist/lib/components/ui/layout/account-menu/account-menu.d.ts +1 -1
  64. package/dist/lib/components/ui/layout/app-shell/app-sidebar-icon-item.d.ts +1 -1
  65. package/dist/lib/components/ui/layout/app-shell/app-sidebar-label-item.d.ts +1 -1
  66. package/dist/lib/components/ui/layout/app-shell/app-sidebar.d.ts +1 -1
  67. package/dist/lib/components/ui/layout/pager/pager.d.ts +1 -1
  68. package/dist/lib/components/ui/layout/see-also/see-also.d.ts +1 -1
  69. package/dist/lib/components/ui/layout/toc/toc.d.ts +1 -1
  70. package/dist/lib/components/ui/primitives/index.d.ts +1 -0
  71. package/dist/lib/components/ui/primitives/link/index.d.ts +2 -0
  72. package/dist/lib/components/ui/primitives/link/link-component-provider.d.ts +26 -0
  73. package/dist/lib/components/ui/widgets/education/index.d.ts +1 -0
  74. package/dist/lib/components/ui/widgets/education/navigator/index.d.ts +2 -0
  75. package/dist/lib/components/ui/widgets/education/navigator/partials/navigator-item.d.ts +10 -0
  76. package/dist/lib/components/ui/widgets/education/navigator/question-navigator.d.ts +27 -0
  77. package/dist/lib/components/ui/widgets/education/questions/index.d.ts +1 -1
  78. package/dist/lib/components/ui/widgets/education/questions/partials/question-media.d.ts +5 -0
  79. package/dist/lib/components/ui/widgets/education/questions/partials/question-verdict.d.ts +12 -0
  80. package/dist/lib/components/ui/widgets/education/questions/question-options.d.ts +2 -2
  81. package/dist/lib/components/ui/widgets/education/questions/question-text.d.ts +1 -1
  82. package/dist/lib/components/ui/widgets/education/questions/question.d.ts +1 -1
  83. package/dist/lib/components/ui/widgets/education/questions/types.d.ts +33 -1
  84. package/dist/lib/hooks/index.d.ts +1 -0
  85. package/dist/lib/hooks/use-countdown.d.ts +18 -0
  86. package/dist/lib/i18n/resources/en/en.d.ts +34 -0
  87. package/dist/lib/i18n/resources/en/material.json.d.ts +34 -0
  88. package/dist/lib/i18n/resources/fr/fr.d.ts +34 -0
  89. package/dist/lib/i18n/resources/fr/material.json.d.ts +34 -0
  90. package/dist/lib/i18n/resources/resources.d.ts +68 -0
  91. package/dist/lib/i18n/translator.d.ts +1 -1
  92. package/dist/lib.js +1 -1
  93. package/dist/main.js +43 -43
  94. package/dist/map.js +8 -8
  95. package/dist/material.css +1 -1
  96. package/dist/pdf-reader.js +1 -1
  97. package/dist/print.js +2 -2
  98. package/dist/{public-BoVE0ffh.js → public-BtKAthSs.js} +9 -9
  99. package/dist/{rich-code-block-DAUM7Yh_.js → rich-code-block-BZYZ6ewr.js} +5 -5
  100. package/dist/{sortable-provider-BLXsGE_0.js → sortable-provider-hUCwt69K.js} +2 -2
  101. package/dist/{spinner-Rg4PnBPH.js → spinner-BmUXTzvJ.js} +1 -1
  102. package/dist/{stored-language-B1lJMPbR.js → stored-language-_OGPBCn0.js} +64 -0
  103. package/dist/theme-picker.js +2 -2
  104. package/dist/{theme-toggle-jVdxF_mc.js → theme-toggle-DdvEafno.js} +1 -1
  105. package/dist/{toolbar-items-BFY_hns5.js → toolbar-items-DKfY_6AG.js} +4 -4
  106. package/dist/{translate-material-q-JNxP1m.js → translate-material-DzBMPErh.js} +2 -2
  107. package/dist/{translator-BU1o3_l9.js → translator-BIVSiq4X.js} +1 -1
  108. package/dist/{use-chat-composer-DGbDuIUa.js → use-chat-composer-DCQW2EJ8.js} +16 -16
  109. package/dist/{use-heading-spy-CP63sAcS.js → use-heading-spy-Cr2C_Px8.js} +32 -5
  110. package/dist/{use-material-translation-nNF8fUXz.js → use-material-translation-C_bAxfvp.js} +2 -2
  111. package/docs/components/README.md +1 -1
  112. package/docs/components/buttons-and-actions.md +1 -0
  113. package/docs/components/data-display.md +212 -2
  114. package/docs/components/data-table-filter-and-lists.md +3 -3
  115. package/docs/components/feedback-and-status.md +5 -3
  116. package/docs/components/navigation-and-layout.md +4 -4
  117. package/docs/components/primitives.md +62 -0
  118. package/docs/components/rich-widgets.md +51 -0
  119. package/docs/components/selection-and-choice.md +1 -1
  120. package/docs/guides/app-shell.md +2 -2
  121. package/docs/guides/helpers.md +40 -0
  122. package/docs/previews/data-display-seatring.webp +0 -0
  123. package/docs/previews/data-display-secretcard.webp +0 -0
  124. package/docs/previews/data-display-votetally.webp +0 -0
  125. package/lib/components/components.css +8 -0
  126. package/lib/components/ui/display/seat-ring/seat-ring-index.css +125 -0
  127. package/lib/components/ui/display/seat-ring/seat-ring-ring.css +87 -0
  128. package/lib/components/ui/display/seat-ring/seat-ring.css +139 -0
  129. package/lib/components/ui/display/secret-card/secret-card.css +65 -0
  130. package/lib/components/ui/display/vote-tally/vote-tally.css +94 -0
  131. package/package.json +1 -1
  132. package/dist/lib/components/ui/feedback/countdown-timer/use-countdown.d.ts +0 -6
@@ -8,6 +8,8 @@ from `@nxgt/material/components`.
8
8
  | --- | --- | --- |
9
9
  | <img src="../previews/data-display-avatar.webp" width="96" alt=""> | [Avatar](#avatar) | Image with a fallback and an optional online / offline dot |
10
10
  | <img src="../previews/data-display-avatargroup.webp" width="96" alt=""> | [AvatarGroup](#avatargroup) | Overlapping avatars with a `+N` overflow |
11
+ | <img src="../previews/data-display-seatring.webp" width="96" alt=""> | [SeatRing](#seatring) | Participants as seats around a circle or in a grid, optionally pickable |
12
+ | <img src="../previews/data-display-secretcard.webp" width="96" alt=""> | [SecretCard](#secretcard) | A card whose secret is not rendered until it is revealed |
11
13
  | <img src="../previews/data-display-shape.webp" width="96" alt=""> | [Shape](#shape) | Material 3 Expressive's shapes as a backdrop, an icon container or a mask for a photo |
12
14
  | <img src="../previews/data-display-badge.webp" width="96" alt=""> | [Badge](#badge) | Status pill, 20 px, in seven tones, soft or outlined, with an optional decorative `icon` before the words |
13
15
  | <img src="../previews/data-display-countbadge.webp" width="96" alt=""> | [CountBadge](#countbadge) | Count in the corner of its children |
@@ -27,6 +29,7 @@ from `@nxgt/material/components`.
27
29
  | <img src="../previews/data-display-timeline.webp" width="96" alt=""> | [Timeline](#timeline) | Vertical event timeline: shaped markers, cards, alternating sides, day groups (`DaySeparator` headings, words from `material.day-separator.*`), relative times; `layout="auto"` needs a parent with a definite width (`rail` in a `w-fit` parent) |
28
30
  | <img src="../previews/data-display-changehistory.webp" width="96" alt=""> | [ChangeHistory](#changehistory) | History of the changes made to anything, as a rail or as cards |
29
31
  | <img src="../previews/data-display-zonedtime.webp" width="96" alt=""> | [ZonedTime](#zonedtime) | A moment written in a given time zone, identical on the server and the client |
32
+ | <img src="../previews/data-display-votetally.webp" width="96" alt=""> | [VoteTally](#votetally) | A live ranked tally of a vote, with counts, shares and voters |
30
33
  | <img src="../previews/data-display-eventchip.webp" width="96" alt=""> | [EventChip](#eventchip) | Calendar event pill, soft, solid or outlined |
31
34
  | <img src="../previews/data-display-codeblock.webp" width="96" alt=""> | [CodeBlock](#codeblock) | Code, JSON tree and tabbed samples |
32
35
  | <img src="../previews/data-display-markdown.webp" width="96" alt=""> | [Markdown](#markdown) | GFM with math and mermaid |
@@ -127,6 +130,144 @@ array (`people.map(...)`); a single wrapper component or fragment counts as one.
127
130
 
128
131
  ---
129
132
 
133
+ ## SeatRing
134
+
135
+ ![SeatRing](../previews/data-display-seatring.webp)
136
+
137
+ Participants as seats: around a circle when there is room, in a grid on a
138
+ phone. A seating plan, a round-table meeting, a party-game lobby. Each seat has
139
+ an avatar, a name, a status written in words and a badge; with `selectionMode`
140
+ it is a listbox to pick one or several seats from.
141
+
142
+ ```tsx
143
+ import { SeatRing } from '@nxgt/material/components';
144
+
145
+ <SeatRing
146
+ aria-label="Players"
147
+ seats={[
148
+ { id: 'ada', label: 'Ada Example', status: { label: 'Ready', tone: 'success' }, badge: 'Host' },
149
+ { id: 'bo', label: 'Bo Sample', status: { label: 'Away', tone: 'warning' } },
150
+ { id: 'free', label: 'Free', empty: true },
151
+ ]}
152
+ highlightedId="ada"
153
+ centerSlot={<p>Round 2</p>}
154
+ selectionMode="single"
155
+ value={target}
156
+ onValueChange={setTarget}
157
+ />
158
+ ```
159
+
160
+ | Prop | Type | Default | Notes |
161
+ | --- | --- | --- | --- |
162
+ | `seats` | `Seat[]` | required | `{ id, label, avatar?: { src?, fallback? }, status?: { label, tone? }, badge?, disabled?, empty? }`; clockwise from the top |
163
+ | `layout` | `'ring' \| 'grid'` | `'ring'` | `ring` falls back to the grid in a narrow box and past the seat limit (10 seats) |
164
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | seat width 4.75, 6 or 8rem; ring from a box of 32, 38 or 46rem; at most 10 seats on the circle |
165
+ | `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'none'` | `none`: a list; otherwise a listbox |
166
+ | `value`, `defaultValue`, `onValueChange` | `string \| null` (single), `string[]` (multiple) | — | controlled or not |
167
+ | `highlightedId` | `string` | — | emphasised seat (whose turn it is); `aria-current="true"` on it, in a list and in a listbox |
168
+ | `renderAvatar`, `renderStatus`, `renderBadge` | `(seat) => ReactNode` | — | replace the default avatar, status line or badge |
169
+ | `renderEmptySeat` | `(seat) => ReactNode` | dashed circle and "Empty seat" | content of an empty seat |
170
+ | `centerSlot` | `ReactNode` | — | middle of the ring (a timer, a title); above the seats in the grid |
171
+ | `aria-label` / `aria-labelledby` | `string` | — | names the list or the listbox: always give one |
172
+
173
+ The layout follows the component's own width (a container query), not the
174
+ window, so it also collapses in a narrow column or a dialog. The grid is two columns
175
+ at 30rem or less and auto-fill above it, with a 44px minimum seat height. The circle is CSS only, so server markup has
176
+ no `style` attribute and a strict CSP is fine.
177
+
178
+ **Accessibility**
179
+
180
+ - Not selectable: a `list` of `listitem`s. Selectable: a `listbox` of `option`s
181
+ (`aria-multiselectable` in `multiple`).
182
+ - A roving tab stop sits on the chosen seat, else the first taken one. Arrow
183
+ keys, Home and End move the focus (an empty seat is skipped); Space or Enter
184
+ picks, and pressing the chosen seat again clears it.
185
+ - A seat is named by its label and its status text, never by colour alone.
186
+ - In a listbox, empty and `disabled` seats are `aria-disabled`, and a disabled
187
+ seat can be reached but not picked (in a list, `disabled` only dims the seat).
188
+ - Put no button in a selectable seat: interactive content (an "Invite" action
189
+ in `renderEmptySeat`) belongs to a non-selectable ring.
190
+ - Seat changes are not animated beyond a colour fade, which stops under
191
+ `prefers-reduced-motion`.
192
+
193
+ **Storybook:** `Data display/SeatRing` · **Vue:** not yet (#1931)
194
+
195
+ ---
196
+
197
+ ## SecretCard
198
+
199
+ ![SecretCard](../previews/data-display-secretcard.webp)
200
+
201
+ A card that holds a secret and shows it only after a deliberate tap or click: a
202
+ private role in a party game, a one-time code, a recovery key, a card to turn
203
+ over. It is hidden by default, can be hidden again, and can hide itself after a
204
+ delay. The secret (`back`) is **not rendered** while hidden, so it is not in the
205
+ markup, not read by a screen reader and not copied; it is not merely covered by CSS.
206
+
207
+ ```tsx
208
+ import { Button, SecretCard } from '@nxgt/material/components';
209
+
210
+ <SecretCard
211
+ title="Your role"
212
+ description="Look alone, then hide it."
213
+ back={<p>Night watch</p>}
214
+ autoHideMs={8000}
215
+ actions={<Button variant="outlined">Next player</Button>}
216
+ />
217
+ ```
218
+
219
+ | Prop | Type | Default | Notes |
220
+ | --- | --- | --- | --- |
221
+ | `title` | `ReactNode` | required | the name, always shown (a heading, `h3`) |
222
+ | `back` | `ReactNode` | required | the secret, rendered only while revealed |
223
+ | `description` | `ReactNode` | - | a hint, shown only while hidden |
224
+ | `front` | `ReactNode` | neutral pattern | the hidden face; put no secret in it |
225
+ | `icon` | `ReactNode` | - | decorative, before the title |
226
+ | `tone` | `Tone` | - | `data-tone`: a named tone or an app's |
227
+ | `actions` | `ReactNode` | - | buttons beside the reveal button |
228
+ | `revealed` + `onRevealedChange` | `boolean` + `(revealed) => void` | - | controlled |
229
+ | `defaultRevealed`, `onRevealedChange` | `boolean`, `(revealed) => void` | `false` | uncontrolled; never together with `revealed` |
230
+ | `autoHideMs` | `number` | - | hides again after this long shown; also calls `onRevealedChange(false)` |
231
+ | `disabled` | `boolean` | `false` | the card cannot be revealed; a revealed card can always be hidden |
232
+ | `labels` | `Partial<{ reveal, revealed, hidden }>` | `material.secret-card.*` | the button's name ("Show secret") and the announcements |
233
+
234
+ Controlled, with your own words:
235
+
236
+ ```tsx
237
+ const [open, setOpen] = useState(false);
238
+
239
+ <SecretCard
240
+ title="Recovery key"
241
+ back={<code>K7QF-92LM</code>}
242
+ revealed={open}
243
+ onRevealedChange={setOpen}
244
+ labels={{ reveal: 'Show the key', revealed: 'Key shown', hidden: 'Key hidden' }}
245
+ />
246
+ ```
247
+
248
+ **Accessibility**
249
+
250
+ - The reveal is a real `button`, a disclosure: one name ("Show secret"; the
251
+ icon changes) and `aria-expanded` for the state, with `aria-controls` on the
252
+ face. Only a click, a tap, Enter or Space reveals: no hover, so a scroll
253
+ never reveals it.
254
+ - A click is carried by `aria-expanded`, so nothing is announced for it. A
255
+ change a click does not explain (`autoHideMs`, the parent) is said through a
256
+ polite live region ("Secret revealed", "Secret hidden"). The message never
257
+ holds the secret, and nothing is said on the first render.
258
+ - When the face that held focus is swapped (a focusable `back` on auto-hide, a
259
+ focusable `front` on reveal), focus moves to the reveal button.
260
+ - `disabled` stops a reveal only: a revealed card can always be hidden.
261
+ - The flip runs only after a change (not on first render or hydration) and
262
+ without `prefers-reduced-motion`; with it the face changes at once.
263
+ - The card is full width up to 28rem, the button is at least 44px high, and
264
+ the layout follows `dir` (RTL) and dark mode.
265
+ - Keep `front` and `description` free of the secret: they are always in the page.
266
+
267
+ **Storybook:** `Data display/SecretCard` · **Vue:** not yet (#1938)
268
+
269
+ ---
270
+
130
271
  ## Shape
131
272
 
132
273
  ![Shape](../previews/data-display-shape.webp)
@@ -352,7 +493,7 @@ import { Link } from 'react-router';
352
493
  | `items` | `PeriodCardItem[]` | — | `id`, `title`, `start`, `end` (`Date`, ISO string or timestamp, read as local days; `'2026-09-01'` is that day, not UTC), and optionally `state`, `description`, `badges`, `footer`, `href` |
353
494
  | `today` | `DayInput \| false` | now | the day that tells the current period, by calendar day, both ends included; `false` turns the detection off |
354
495
  | `state` (per item) | `'past' \| 'current' \| 'upcoming'` | from `today` | an explicit state wins over the dates |
355
- | `linkComponent` | `LinkComponent` | `<a>` | the title's link, as the app shell's |
496
+ | `linkComponent` | `LinkComponent` | `<a>` | the title's link, as the app shell's. Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
356
497
  | `emptyState` | `ReactNode` | — | shown instead of the list when `items` is empty |
357
498
  | `headingLevel` | `2 \| 3 \| 4` | `3` | the level of each card's heading |
358
499
  | `dateFormat` | `Intl.DateTimeFormatOptions` | day, short month, year | how the range reads, through `formatRange` |
@@ -817,7 +958,7 @@ A tile with an `href` ends with a discreet chevron after `trailing`: decorative
817
958
  | --- | --- | --- | --- |
818
959
  | `href` | `string` | — | renders an `<a>`, with a chevron by default |
819
960
  | `chevron` | `boolean` | `true` with an `href`, else `false` | the decorative chevron after `trailing` |
820
- | `linkComponent` | `AppShellLinkComponent` | plain `<a>` | the link element, as in the app shell: takes `href`, spreads every other prop, forwards `ref` |
961
+ | `linkComponent` | `AppShellLinkComponent` | plain `<a>` | the link element, as in the app shell: takes `href`, spreads every other prop, forwards `ref`. Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
821
962
  | `onClick` | `MouseEventHandler` | — | renders a `<button>` |
822
963
  | `selected` | `boolean` | `false` | the current row: drawn selected; a link gets `aria-current="page"` (your own `aria-current` replaces it), a button `aria-pressed` |
823
964
  | `toggle` | `Switch` props | — | makes the tile a switch row (see below): the whole row toggles a Switch at its end; `href`, `onClick`, `actions`, `chips`, `chevron` and `linkComponent` are refused beside it by type |
@@ -1166,6 +1307,75 @@ the same way; pass the app's zone from the server so every screen agrees.
1166
1307
 
1167
1308
  ---
1168
1309
 
1310
+ ## VoteTally
1311
+
1312
+ ![VoteTally](../previews/data-display-votetally.webp)
1313
+
1314
+ A live ranked tally of a vote: a poll, a decision in a meeting, an election, a
1315
+ party-game ballot. One row per option with its count and share written as text
1316
+ ("Ada Example, 3 votes, 60%"), a bar beneath, and who voted for it. The leader,
1317
+ a tie and the viewer's own vote are marked in words. Counts can change while
1318
+ the page is open.
1319
+
1320
+ ```tsx
1321
+ import { VoteTally } from '@nxgt/material/components';
1322
+
1323
+ <VoteTally
1324
+ aria-label="Best day"
1325
+ options={[
1326
+ { id: 'mon', label: 'Monday', count: 5, voters: [{ id: 'ada', label: 'Ada Example' }] },
1327
+ { id: 'tue', label: 'Tuesday', count: 2 },
1328
+ ]}
1329
+ selectedId="tue"
1330
+ abstainCount={1}
1331
+ />
1332
+ ```
1333
+
1334
+ | Prop | Type | Default | Notes |
1335
+ | --- | --- | --- | --- |
1336
+ | `options` | `VoteOption[]` | required | `{ id, label, count, voters?: { id, label, avatar? }[], avatar? }` |
1337
+ | `total` | `number` | sum of counts | base of the shares; abstentions are never in it. `0` or less falls back to the sum. A `total` below the sum caps each share at 100 |
1338
+ | `sort` | `'count' \| 'none'` | `'count'` | highest first; equal counts keep their order and share a rank |
1339
+ | `selectedId` | `string` | — | the viewer's vote: "Your vote" badge and the primary fill |
1340
+ | `showLeader` | `boolean` | `true` | "Leader" on a single top option, "Tie" on each of several; no arbitrary leader. Off: no mark, no emphasis, and the status says the votes cast instead of who leads |
1341
+ | `showVoters` | `'none' \| 'avatars' \| 'names'` | `'avatars'` | avatars, or a line of names |
1342
+ | `maxVoters` | `number` | `5` | voters shown before "+N"; names mode ends with "+N more" |
1343
+ | `abstainCount` | `number` | — | shown under the bars ("2 abstentions") and added to the announcement |
1344
+ | `animated` | `boolean` | `true` | the bar's width eases when a count changes (only the width), never under `prefers-reduced-motion` |
1345
+ | `emptyLabel` | `ReactNode` | "No votes yet" | shown while no option has a vote, rows (if any) stay beneath it |
1346
+ | `announceDelay` | `number` | `1000` | ms a change waits before it is announced |
1347
+ | `renderOption`, `renderVoters` | `(row) => ReactNode` | — | replace a row's label cell or voters; `row` has `option`, `rank`, `percent`, `leader`, `tied`, `selected` |
1348
+ | `header`, `footer` | `ReactNode` | — | above and below the rows |
1349
+ | `aria-label` / `aria-labelledby` | `string` | — | names the list: always give one |
1350
+ | `className`, other `div` props | — | — | go to the root `div` |
1351
+
1352
+ **Shares.** Percentages are whole numbers. When `total` is the sum of the
1353
+ counts they add up to 100 (largest remainder: 1, 1 and 1 vote read 34%, 33%,
1354
+ 33%). With a larger `total` (voters still to come) each share is rounded on its
1355
+ own, so they add up to less.
1356
+
1357
+ **Accessibility**
1358
+
1359
+ - A `list` of `listitem`s. Each row's text is real: the label, then
1360
+ "3 votes" and "60%". The bars are `aria-hidden`.
1361
+ - Leader, tie and "Your vote" are words in badges, never colour alone.
1362
+ - A visually hidden polite status says where the vote stands ("Monday leads
1363
+ with 5 votes", "Tie between Ada and Bo, 3 votes each"), and any
1364
+ abstentions, once changes stop for
1365
+ `announceDelay`. It never says the page as loaded, and a burst of updates is
1366
+ one announcement.
1367
+ - Voters are a named group (`Voters for Monday`) of avatars each named by the
1368
+ voter, or a line of names.
1369
+ - One column at any width: on a phone the count wraps under the label, long
1370
+ labels wrap, and a bar keeps its own line so changing counts do not move rows.
1371
+ It mirrors in right-to-left, and the bars follow the theme in dark mode.
1372
+ - Bar widths are SVG attributes, so server markup has no `style` attribute and
1373
+ a strict CSP is fine.
1374
+
1375
+ **Storybook:** `Data display/VoteTally` · **Vue:** not yet (#1940)
1376
+
1377
+ ---
1378
+
1169
1379
  ## EventChip
1170
1380
 
1171
1381
  ![EventChip](../previews/data-display-eventchip.webp)
@@ -125,7 +125,7 @@ table enables, for typing your own column defs.
125
125
  | `footer` | `false \| (table) => ReactNode` | the pagination | replaces the footer, or removes it |
126
126
  | `getRowHref` | `(row) => string \| undefined` | — | the first data cell links to the row's page; a click elsewhere on the row follows it |
127
127
  | `onRowClick` | `(row, event) => void` | — | a click on the row (not on a control); alone (without `getRowHref`), it makes the row focusable and Enter calls it |
128
- | `linkComponent` | `ComponentType<{ href, className?, children?, rel?, 'aria-label'?, onClick? }>` | `<a>` | the link for `getRowHref` and the page links: wrap your router's. It must forward `className` and `ref` (the past-end page's "Back to the first page" is a `Button asChild` over it) |
128
+ | `linkComponent` | `ComponentType<{ href, className?, children?, rel?, 'aria-label'?, onClick? }>` | `<a>` | the link for `getRowHref` and the page links: wrap your router's. It must forward `className` and `ref` (the past-end page's "Back to the first page" is a `Button asChild` over it). Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
129
129
  | `reserveActions` | `boolean` | `true` | with `actions` and a `select` column: keep the toolbar's 68px band while nothing is selected, so the bar does not push the rows down; `false` keeps none and the bar arrives on the first selection (`ServerTable` passes `false`) |
130
130
  | `focusable` | `boolean` | `true` with `actions`, else `false` | the `<table>` (or the list of cards or tiles) takes the focus by script (`tabindex="-1"`, never a tab stop): where the focus goes once the control that had it is gone. When the selection is cleared and the focused control went with it (a bulk action's button), the table takes the focus instead of the page's body ([focus after a bulk action](#focus-after-a-bulk-action)); `false` turns both off |
131
131
  | `tableRef` | `Ref<HTMLTableElement>` | — | the `<table>`, to focus it yourself (with `focusable`); `controls.focusTable()` does the same from a toolbar slot. It stays `null` while the layout is a list (`cards`, `tiles`, or the phone side of `{ phone, wide }`): use `focusRef` |
@@ -1258,7 +1258,7 @@ import { ListEmpty } from '@nxgt/material/components';
1258
1258
  | `clear` | `string` | "Clear search and filters" | the way out's label (`material.list-empty.clear`) |
1259
1259
  | `onClear` | `() => void` | — | the way out is a button |
1260
1260
  | `clearHref` | `string` | — | the way out is a link (without `onClear`) |
1261
- | `linkComponent` | `DataTableLinkComponent` | `<a>` | the link for `clearHref` (a router's); it must forward `className` and `ref`, since the button is `asChild` over it |
1261
+ | `linkComponent` | `DataTableLinkComponent` | `<a>` | the link for `clearHref` (a router's); it must forward `className` and `ref`, since the button is `asChild` over it. Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
1262
1262
  | `headingLevel` | `2`–`6` | `2` | the title is a heading of this level, so the page keeps a landmark when the list empties; the look does not change |
1263
1263
  | `busy` | `boolean` | `false` | the next answer is loading: the page stays, marked `data-busy` (no `aria-busy`: the element has no role) |
1264
1264
 
@@ -1314,7 +1314,7 @@ A list that fits one page shows "1–5 of 5" by default. `hideOnSinglePage` rend
1314
1314
  | `hideOnSinglePage` | `boolean` | `false` | renders nothing when there is no previous and no next page; the range is then not shown, so tell the count elsewhere (a heading, a status line) or keep the footer |
1315
1315
  | `live` | `boolean` | `true` | `false` renders the range as plain text instead of a polite live region |
1316
1316
  | `disabled` | `boolean` | `false` | makes Previous and Next inert while the feed loads |
1317
- | `linkComponent` | `DataTableLinkComponent` | `<a>` | your router's link; it must forward `className` and `ref` |
1317
+ | `linkComponent` | `DataTableLinkComponent` | `<a>` | your router's link; it must forward `className` and `ref`. Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
1318
1318
 
1319
1319
  **Storybook:** `Data table, filter & lists/PageFooter`
1320
1320
 
@@ -760,7 +760,9 @@ import { CountdownTimer } from '@nxgt/material/components';
760
760
  It ticks on the client only: the server renders the full time. The clock is a
761
761
  `role="timer"`; a polite live region speaks only when a threshold is crossed
762
762
  ("1:00 left") and when the time is up, never every second. `formatClock` and
763
- `countdownTone` are exported too.
763
+ `countdownTone` are exported too, and so is the clock itself as the
764
+ [`useCountdown`](../guides/helpers.md#usecountdown) hook (`@nxgt/material/hooks`),
765
+ for a button that waits without a ring.
764
766
 
765
767
  **Storybook:** `Feedback & status/CountdownTimer` · **Vue:** React only for now (#890)
766
768
 
@@ -1210,7 +1212,7 @@ function invite(answer: (id: string, yes: boolean) => void): NotificationItem {
1210
1212
  | `footer` | `ReactNode` | — | under the list: "View all", settings… |
1211
1213
  | `fullScreenOnMobile` | `boolean` | `true` | a phone gets a full-screen sheet instead of the popover |
1212
1214
  | `toastOnArrival` | `boolean` | `false` | shows a toast for each new item (needs a `Toaster`) |
1213
- | `linkComponent` | `LinkComponent` | native `<a>` | renders each row's `href` through your router's link, so a click navigates without a page load: `linkComponent={({ href, ...p }) => <Link to={href} {...p} />}` (spread every prop, as for `AppShell`) |
1215
+ | `linkComponent` | `LinkComponent` | native `<a>` | renders each row's `href` through your router's link, so a click navigates without a page load: `linkComponent={({ href, ...p }) => <Link to={href} {...p} />}` (spread every prop, as for `AppShell`). Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
1214
1216
  | `error` / `onRetry` | `ReactNode \| boolean` / `() => unknown` | — | the feed could not be read: the list is replaced by an error line (`true` for the default words, a node for your own message) and, with `onRetry`, a Retry button (a returned promise keeps it loading). The empty state is not shown; counts still come from `items` / `count` |
1215
1217
 
1216
1218
  A row is interactive only when a click does something: it has an `href`, or
@@ -1284,7 +1286,7 @@ import { NotificationRow } from '@nxgt/material/components';
1284
1286
  | `createdAt` / `now` | `Date \| string \| number` | — | the time, counted from `now` (the clock when unset) |
1285
1287
  | `unread` | `boolean` | `false` | tint, bold, dot and edge |
1286
1288
  | `href` / `onOpen` | — | — | the row as a link or a button |
1287
- | `linkComponent` | `LinkComponent` | native `<a>` | renders the `href` link through your router's link (`Notifications` passes its own down) |
1289
+ | `linkComponent` | `LinkComponent` | native `<a>` | renders the `href` link through your router's link (`Notifications` passes its own down). Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
1288
1290
  | `onMarkRead` / `onDismiss` | `() => void` | — | the quick actions (mark read only while unread) |
1289
1291
  | `actions` | `NotificationAction[]` | — | inline buttons; the first is filled, the rest tonal (`emphasis` overrides) |
1290
1292
  | `density` | `'comfortable' \| 'compact'` | `'comfortable'` | spacing, avatar size, one-line description |
@@ -66,7 +66,7 @@ phone's menu sheet pad clear of `env(safe-area-inset-*)`, as classes; see the
66
66
  | Prop | Type | Default | Effect |
67
67
  | --- | --- | --- | --- |
68
68
  | `items` | `MenuItem[]` | — | `label` (already translated), `url`, `icon`, `footer`, `display` (`'icon'`: a round icon-only button, the label as tooltip and name; see the [app shell guide](../guides/app-shell.md)), `badge` (`true` a dot, a number a count up to "99+"), `badgeLabel` (see [Badges](../guides/app-shell.md#badges)) |
69
- | `linkComponent` | `AppShellLinkComponent` | React Router `Link` | The link each menu entry renders, given `href`; see the [app shell guide](../guides/app-shell.md) |
69
+ | `linkComponent` | `AppShellLinkComponent` | React Router `Link` | The link each menu entry renders, given `href`; see the [app shell guide](../guides/app-shell.md). Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
70
70
  | `logo`, `name` | `AppLogo` (`ReactNode \| string \| AppLogoImage`, `AppLogoImage` = `{ src, alt? }`), `string` | — | the app's logo alone in the collapsed rail, named by `name` (also its tooltip); logo and name side by side in the phone's menu sheet |
71
71
  | `context` | `ReactNode` | — | a line at the top of the phone's menu sheet, above the brand: the current tenant's name; `name` stays the app's brand; `AppHeader` and `AppSidebar` take it too (see the [app shell guide](../guides/app-shell.md#the-brand-logo-and-name)) |
72
72
  | `brand` | `ReactNode` | — | replaces the sidebar header, in the rail and the menu sheet; wins over `logo` and `name` |
@@ -148,7 +148,7 @@ import { AccountMenu, ActivityLayout } from '@nxgt/material/components';
148
148
  | `secondary` | `ReactNode` | — | a second line under the name, the e-mail say |
149
149
  | `src` | `string` | — | the avatar's picture; the initials until it loads, or without one |
150
150
  | `items` | `AccountMenuItem[]` | `[]` | `{ label: ReactNode; url: string; icon?: IconName }`: links between the header and Sign out; `label` already translated, `icon` a [Font Awesome](https://fontawesome.com/icons) symbol id of the duotone sprite (see [Icon](primitives.md#icon)) |
151
- | `linkComponent` | `AppShellLinkComponent` | React Router `Link` | the link each item renders, as in the app shell |
151
+ | `linkComponent` | `AppShellLinkComponent` | React Router `Link` | the link each item renders, as in the app shell. Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
152
152
  | `onSignOut` | `() => void` | — | Sign out was chosen; the menu stays open, so `signingOut` shows |
153
153
  | `signOutLabel` | `ReactNode` | "Sign out" | `material.account-menu.sign-out` |
154
154
  | `signingOut` | `boolean` | `false` | Sign out is busy (`aria-busy`, a spinner) and ignores a second selection |
@@ -588,7 +588,7 @@ import { Pager } from '@nxgt/material/components';
588
588
  | `previous`, `next` | `{ title; href; description? }` | — | either may be omitted |
589
589
  | `shortcuts` | `boolean` | `false` | shows `[` / `]` key hints (the `Doc` shell handles the keys) |
590
590
  | `previousLabel`, `nextLabel` | `string` | translated | small labels |
591
- | `linkComponent` | `ComponentType<{ href; className?; children? }>` | `<a>` | your router's link |
591
+ | `linkComponent` | `ComponentType<{ href; className?; children? }>` | `<a>` | your router's link. Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
592
592
 
593
593
  External `href`s open in a new tab.
594
594
 
@@ -617,7 +617,7 @@ import { SeeAlso } from '@nxgt/material/components';
617
617
  | --- | --- | --- | --- |
618
618
  | `items` | `{ id?; title; href }[]` | — | external links get the outbound icon |
619
619
  | `label` | `string` | translated "See also" | heading |
620
- | `linkComponent` | `ComponentType<{ href; className?; children? }>` | `<a>` | your router's link |
620
+ | `linkComponent` | `ComponentType<{ href; className?; children? }>` | `<a>` | your router's link. Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
621
621
 
622
622
  **Storybook:** `Navigation & layout/SeeAlso` · **Vue:** `SeeAlso`
623
623
 
@@ -28,6 +28,7 @@ other pages are built from these.
28
28
  | <img src="../previews/primitives-togglegroup.webp" width="96" alt=""> | [ToggleGroup](#togglegroup) | Connected, segmented or separate toggle group |
29
29
  | | [Activity](#activity) | React `<Activity>` switched by a boolean |
30
30
  | | [NonceProvider](#nonceprovider) | Gives the page's CSP nonce to every component below |
31
+ | | [LinkComponentProvider](#linkcomponentprovider) | Sets, once, the link component of every component that renders a link |
31
32
 
32
33
  ## Accordion
33
34
 
@@ -960,6 +961,67 @@ import { Activity } from '@nxgt/material/components';
960
961
 
961
962
  **Storybook:** — · **Vue:** `Activity`
962
963
 
964
+ ## LinkComponentProvider
965
+
966
+ No preview: it draws nothing of its own, it hands the app's link component down.
967
+
968
+ Sets once the link the components below render their links with: `LinkButton`
969
+ (with `IconLinkButton` and `ResponsiveLinkButton`), the sidebar and app shell,
970
+ `AccountMenu`, `Pager`, `SeeAlso`, `Toc`, `PeriodCards`, `NotificationRow`,
971
+ `Doc` (which then skips its `onNavigate`, as for its own `linkComponent`),
972
+ `ListTile`, `DataTable`, `ServerTable`, `PageFooter`, `ListEmpty`, `Switcher`,
973
+ `Notifications`, `Breadcrumb` and `Pagination` links, `LinkCard` (internal addresses only; an external one stays a
974
+ plain `<a>`) and `QuestionNavigator`'s link variant.
975
+
976
+ Not wired, by design: `ActionCard`'s `titleLink` and `Markdown`'s links are
977
+ elements or content you supply, `SkipLink` and `PaginationBar` are in-page
978
+ (`#`) anchors, `ContactLinks` renders `mailto:` and `tel:` links, and the
979
+ `FileList` and `FilePreview` download links, `EnrolmentSecret`'s `otpauth:`
980
+ link and the editor's action card (`target="_blank"`) are not navigation. A component's own `linkComponent` prop still wins. Without the provider
981
+ and without the prop, each keeps its own default, so nothing changes until you
982
+ render it.
983
+
984
+ ```tsx
985
+ import {
986
+ LinkComponentProvider,
987
+ type LinkComponentProps,
988
+ } from '@nxgt/material/components';
989
+ import { Link } from 'react-router';
990
+
991
+ // Spread every prop onto the element; a function component receives `ref`
992
+ // as a prop in React 19, so spreading forwards it.
993
+ const RouterLink = ({ href, ...props }: LinkComponentProps) => (
994
+ <Link to={href} {...props} />
995
+ );
996
+
997
+ <LinkComponentProvider component={RouterLink}>
998
+ <App />
999
+ </LinkComponentProvider>;
1000
+ ```
1001
+
1002
+ | Prop | Type | Default | Effect |
1003
+ | --- | --- | --- | --- |
1004
+ | `component` | `LinkComponent` | — | the default link: `ComponentType<LinkComponentProps>`, where `LinkComponentProps` is the anchor's props plus a required `href` |
1005
+
1006
+ `LinkComponent` and `LinkComponentProps` are exported from
1007
+ `@nxgt/material/components`, and `useLinkComponent(own?)` returns the link a
1008
+ component of your own should render (`own`, else the provider's, else
1009
+ `undefined`). `LinkButton` and its siblings take a router `to`: with a link
1010
+ component they hand it over as `href`, with `target` and `download`;
1011
+ `replace`, `state`, `reloadDocument` and `preventScrollReset` belong to React
1012
+ Router's `Link` and are not passed. They also take `linkComponent` themselves.
1013
+
1014
+ ```tsx
1015
+ import { useLinkComponent } from '@nxgt/material/components';
1016
+
1017
+ function MyCrumb({ href, children }: { href: string; children: string }) {
1018
+ const Link = useLinkComponent() ?? 'a';
1019
+ return <Link href={href}>{children}</Link>;
1020
+ }
1021
+ ```
1022
+
1023
+ **Storybook:** — · **Vue:** —
1024
+
963
1025
  ## NonceProvider
964
1026
 
965
1027
  No preview: it draws nothing of its own, it hands the page's Content Security
@@ -2412,7 +2412,58 @@ French bundle — see [i18n](../guides/i18n.md).
2412
2412
  | `onComplete` | `(result: QuizResult) => void` | — | `{ correct, total, percent, answers, timedOut }`, once per results screen |
2413
2413
  | `onAnswersChange` / `onIndexChange` | callbacks | — | follow progress |
2414
2414
  | `QuestionCard` `revealed` | `boolean` | `false` | marks right / wrong and shows the verdict and `explanation` |
2415
+ | `QuestionCard` `verdict` | `'correct' \| 'incorrect' \| 'pending'` | — | the server's verdict, shown as given (word and icon, never colour alone); reveals the card. `question.correct` is optional and only highlights the right choices. Without it, `revealed` compares in the browser as before |
2416
+ | `QuestionCard` `points` / `feedback` | `{ awarded, possible }` / `string` | — | points earned and a grader's remark, in the verdict banner (with `verdict`) |
2417
+ | `Question` `media` | `{ src, alt?, kind?, type?, name? }` | — | an image (default), audio (`kind: 'audio'` or `type: 'audio/*'`, an `AudioPlayer`) or a document link (any other `type`, a `FilePreview`); `name` (else `alt`) is the text alternative |
2415
2418
  | `QuestionCard` `value` / `onValueChange` | `QuestionAnswer` | — | controlled answer |
2416
2419
  | `QuestionCard` `variant` / `density` / `showShortcuts` / `number` / `showFeedback` | — | `'card'`, `'comfortable'`, `false`, —, `true` | framed or plain, spacing, key badges, "Question 2", the verdict banner |
2417
2420
 
2421
+ A server-graded answer, shown as given (no comparison in the browser):
2422
+
2423
+ ```tsx
2424
+ <QuestionCard
2425
+ question={{ id: 'q1', kind: 'text', prompt: 'Explain X' }}
2426
+ value="my answer"
2427
+ verdict="incorrect" // or 'correct' | 'pending'
2428
+ points={{ awarded: 2, possible: 3 }}
2429
+ feedback="Good, but cite a source"
2430
+ />
2431
+ ```
2432
+
2418
2433
  **Storybook:** `Rich widgets/Quiz`, `Rich widgets/QuestionCard` · **Vue:** `Quiz`, `QuestionCard` (the modes, results, number kind and shortcuts are React only for now, #890)
2434
+
2435
+ ### QuestionNavigator
2436
+
2437
+ The numbered questions of a quiz or exam player: jump to one, see the current
2438
+ one and which are answered or flagged. Import it from
2439
+ `@nxgt/material/widgets/education`.
2440
+
2441
+ ```tsx
2442
+ <QuestionNavigator
2443
+ count={20}
2444
+ current={3}
2445
+ answered={[1, 2]} // or a Set
2446
+ flagged={[2]}
2447
+ onSelect={(n) => go(n)}
2448
+ />
2449
+ // URL-driven player: <QuestionNavigator count={20} current={3} getHref={(n) => `/quiz/${n}`} />
2450
+ ```
2451
+
2452
+ | Prop | Type | Default | Notes |
2453
+ | --- | --- | --- | --- |
2454
+ | `count` | `number` | — | how many questions |
2455
+ | `current` | `number` | — | the current question, **from 1**; gets `aria-current="step"` |
2456
+ | `answered` / `flagged` | `number[] \| Set<number>` | — | question numbers, from 1 |
2457
+ | `onSelect` | `(n: number) => void` | — | each number is a button |
2458
+ | `getHref` | `(n: number) => string` | — | each number is a link; wins over `onSelect`. With neither, the numbers are plain marks |
2459
+ | `label` | `string` | `"Questions"` | the navigation's name |
2460
+
2461
+ Each target is at least 44 x 44 px. Answered shows a check and flagged a flag,
2462
+ and both are named in the accessible label ("Question 2, answered, flagged,
2463
+ current"), never by colour alone. With many questions it is **one row that
2464
+ scrolls sideways and keeps the current number in view** (rather than a wrapping
2465
+ grid): its height stays constant at 20 or 100 questions on a 360px phone, so it
2466
+ can sit above the question without pushing it down. Labels live under
2467
+ `material.question-navigator.*` (`label`, `item`, `answered`, `unanswered`, `flagged`, `current`).
2468
+
2469
+ **Storybook:** `Rich widgets/QuestionNavigator` · **Vue:** not yet (React first, #1948)
@@ -1351,7 +1351,7 @@ To put a Switcher at the start of a page header, pass it as [EntityHeader](data-
1351
1351
  | `searchable` | `boolean` | from 8 options (always, expressive) | the search field of the panel; `true` also replaces the menu with the panel |
1352
1352
  | `recent` | `string[]` | — | up to 3 option ids shown as tiles (expressive) |
1353
1353
  | `getHref` | `(item) => string \| undefined` | — | link mode; falls back to `item.href` |
1354
- | `linkComponent` | `LinkComponent` | `<a>` | your router's link: takes `href`, spreads every prop, forwards `ref` |
1354
+ | `linkComponent` | `LinkComponent` | `<a>` | your router's link: takes `href`, spreads every prop, forwards `ref`. Defaults to the [`LinkComponentProvider`](./primitives.md#linkcomponentprovider)'s link when one is set. |
1355
1355
  | `footer` | `{ label?, onSelect?, href? }` | — | a "Manage…" button (a link with `href`) at the end of the panel; `label` defaults to `material.switcher.manage` |
1356
1356
  | `emptyLabel` | `ReactNode` | `material.switcher.empty` | shown when the search matches nothing |
1357
1357
  | `sheet` | `boolean` | on a phone | `true` always opens a sheet, `false` never |
@@ -198,7 +198,7 @@ partitionMenu(items); // { content, footer }
198
198
  | `context` | `ReactNode` | — | A line at the top of the phone's menu sheet, above the brand: the current tenant's name, already translated (see [The brand](#the-brand-logo-and-name)). `name` stays the app's brand; the collapsed rail shows no context |
199
199
  | `brand` | `ReactNode` | — | Replaces the sidebar header entirely, in the rail and in the menu sheet; wins over `logo` and `name` |
200
200
  | `avatar` | `ReactNode` | — | End of the header. A `string` is used as the image URL of an `Avatar`; anything else is rendered as-is |
201
- | `linkComponent` | `AppShellLinkComponent` | React Router `Link` | The link each menu entry renders, given `href` (see below) |
201
+ | `linkComponent` | `AppShellLinkComponent` | React Router `Link` | The link each menu entry renders, given `href` (see below). Defaults to the [`LinkComponentProvider`](../components/primitives.md#linkcomponentprovider)'s link when one is set. |
202
202
  | `headerTitle` | `ReactNode` | — | The header's leading title: the context the app is in (the active organisation's name), right after the phone's menu button, truncated when long; when it is cut off, its full text is the `title` tooltip (#629) |
203
203
  | `titleAfter` | `ReactNode` | — | A node right after the page's title in the app bar, outside its `h1` (a record's `Switcher`); wins over a route's `handle.titleAfter` (see [A slot after the title](#a-slot-after-the-title)) |
204
204
  | `headerContext` | `ReactNode` | — | A small muted context line (the tenant's name) after the page's title and its slot, from `md` up: outside the `h1`, truncated, its full text the tooltip once cut off. Not shown on a phone, where the menu sheet's `context` carries it (see [A context line](#a-context-line-after-the-title)) |
@@ -231,7 +231,7 @@ hydration on a server-rendered page, opens in a new tab on middle-click, and
231
231
  is announced as a link, named by its label alone ("Identities": the icon beside
232
232
  it is decorative); the current page's entry carries `aria-current="page"`.
233
233
  By default it is a React Router `Link` (client-side navigation, the router's
234
- `basename`), so leaving `linkComponent` out never reloads the page. Pass it only
234
+ `basename`), so leaving `linkComponent` out never reloads the page; a [`LinkComponentProvider`](../components/primitives.md#linkcomponentprovider) above the shell sets another default once. Pass it only
235
235
  for another router or a custom link: a component taking
236
236
  `href`. It must spread **every other prop it receives** onto the element it
237
237
  renders (`className`, `aria-current`, the tooltip's handlers, `data-state`) and
@@ -137,6 +137,7 @@ Vue: the Redux helpers are Pinia store factories: `defineLocalStore`
137
137
  | `useAllowedTransitions(status, transitions, configs)` | Status-machine actions (icon, colour, label, dialog copy) allowed from the current status. |
138
138
  | `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). |
139
139
  | `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). |
140
+ | `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). |
140
141
  | `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). |
141
142
  | `createSubmitGuard()`, `busySettler(guard)` | The framework-free guard behind `useSubmitOnce` (`@nxgt/material-core/submit-guard`): `{ once(run, { settleSync? }), settle(run?), pendingRun() }`, and the function that settles it from a busy flag. |
142
143
 
@@ -204,6 +205,45 @@ export function Sent({ email }: { email: string }) {
204
205
  `useFocusOnMount(open)` focuses again whenever `open` turns true, for content
205
206
  that stays mounted and is only shown. Storybook: `Guides/Hooks/useFocusOnMount`.
206
207
 
208
+ ### `useCountdown`
209
+
210
+ The clock behind [`CountdownTimer`](../components/feedback-and-status.md#countdowntimer),
211
+ without the ring: the seconds left, for a submit button that stays disabled
212
+ while a rate limit (a 429 `retryAfter`) runs out.
213
+
214
+ ```tsx
215
+ import { useState } from 'react';
216
+ import { Button } from '@nxgt/material/components';
217
+ import { useCountdown } from '@nxgt/material/hooks';
218
+
219
+ export function SignInButton({ signIn }: { signIn: () => Promise<{ retryAfter?: number }> }) {
220
+ const [wait, setWait] = useState({ seconds: 0, refusals: 0 });
221
+ const left = useCountdown(wait.seconds, { key: wait.refusals });
222
+ return (
223
+ <Button
224
+ disabled={left > 0}
225
+ onClick={async () => {
226
+ const { retryAfter } = await signIn();
227
+ if (retryAfter) setWait((w) => ({ seconds: retryAfter, refusals: w.refusals + 1 }));
228
+ }}
229
+ >
230
+ {left > 0 ? `Try again in ${Math.ceil(left)} s` : 'Sign in'}
231
+ </Button>
232
+ );
233
+ }
234
+ ```
235
+
236
+ | Option | Default | Effect |
237
+ | --- | --- | --- |
238
+ | `paused` | `false` | holds the clock where it is; it carries on from there |
239
+ | `onExpire` | none | called once, when it reaches zero |
240
+ | `key` | none (`string \| number`) | a new value starts the clock over from `duration`, even for the same duration: without it, a second refusal that answers `retryAfter: 30` again would not restart anything |
241
+
242
+ It returns a number of seconds, fractional (it ticks every 250 ms): use
243
+ `Math.ceil` to show it, `formatClock` (from `@nxgt/material/components`) for
244
+ `m:ss`. The options type is exported as `UseCountdownOptions`. A `duration` of `0` is
245
+ already expired and never calls `onExpire`.
246
+
207
247
  ### `useSubmitOnce`
208
248
 
209
249
  A disabled or `loading` button only stops clicks once it has rendered, and
@@ -134,6 +134,14 @@
134
134
  @import "./ui/display/period-cards/period-cards.css";
135
135
  @import "./ui/layout/sidebar/sidebar-tokens.css";
136
136
  @import "./ui/display/avatar/avatar.css";
137
+ /* SeatRing: seats as a grid or on a circle by its own width. */
138
+ @import "./ui/display/seat-ring/seat-ring.css";
139
+ @import "./ui/display/seat-ring/seat-ring-index.css";
140
+ @import "./ui/display/seat-ring/seat-ring-ring.css";
141
+ /* VoteTally: ranked bars with counts, shares and voters. */
142
+ @import "./ui/display/vote-tally/vote-tally.css";
143
+ /* SecretCard: a face that is not rendered until it is revealed. */
144
+ @import "./ui/display/secret-card/secret-card.css";
137
145
  /* Alert and Banner span the row in the form grids (#1497). */
138
146
  @import "./ui/feedback/alert/alert.css";
139
147
  @import "./ui/display/stat-card/stat-card.css";