@nxgt/material 3.55.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 (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +7 -3
  3. package/dist/{async-slice-yhXT9cml.js → async-slice-CORlM_wu.js} +2 -2
  4. package/dist/{button-CD7f4KFL.js → button-CdpVEza0.js} +1 -1
  5. package/dist/charts.js +3 -3
  6. package/dist/{chip-Cl7Wm8Pn.js → chip-C0yNYz9m.js} +2 -2
  7. package/dist/{code-editor-tOhXy6Qo.js → code-editor-Bwzp2038.js} +6 -6
  8. package/dist/code-editor.js +5 -5
  9. package/dist/{code-language-items-BxlY6onk.js → code-language-items-nP-pF7yH.js} +2 -2
  10. package/dist/components/components.css +4 -0
  11. package/dist/components/ui/display/secret-card/secret-card.css +65 -0
  12. package/dist/components/ui/display/vote-tally/vote-tally.css +94 -0
  13. package/dist/{components-B58ZWZYR.js → components-Dl2hVfzt.js} +16817 -16226
  14. package/dist/components.js +26 -26
  15. package/dist/{dialog-BzfHcRo6.js → dialog-BVgsN9Ch.js} +4 -4
  16. package/dist/dnd.js +1 -1
  17. package/dist/{dropdown-menu-DUZF4xAJ.js → dropdown-menu-C8PBmMIa.js} +1 -1
  18. package/dist/editor.js +9 -9
  19. package/dist/{empty-state-D9o2id1p.js → empty-state-C5wfbbW9.js} +2 -2
  20. package/dist/{escape-owner-BYs2_7T5.js → escape-owner-CYnfySUl.js} +1 -1
  21. package/dist/{field-blur-r1tHwqdF.js → field-blur-Nid7-bt_.js} +1 -1
  22. package/dist/{field-blur-context-ZsK8OTNg.js → field-blur-context-DxdIuHAY.js} +1 -1
  23. package/dist/hooks.js +28 -28
  24. package/dist/{hover-card-NfF8taLj.js → hover-card-C_9jiwp1.js} +2 -2
  25. package/dist/{i18n-provider-TQAEPlJ5.js → i18n-provider-CLVZK6tQ.js} +1 -1
  26. package/dist/i18n.js +5 -5
  27. package/dist/{icon-badge-BlaY_a-P.js → icon-badge-Dcjg_SKJ.js} +3 -3
  28. package/dist/{icon-button-B8tdlP6R.js → icon-button-Bo-VuZ2n.js} +1 -1
  29. package/dist/lib/components/ui/actions/link/link.d.ts +8 -0
  30. package/dist/lib/components/ui/collections/data-table/partials/data-table-page-control.d.ts +1 -1
  31. package/dist/lib/components/ui/collections/data-table/partials/data-table-row.d.ts +1 -1
  32. package/dist/lib/components/ui/collections/data-table/partials/layouts/data-table-card.d.ts +1 -1
  33. package/dist/lib/components/ui/collections/server-table/partials/server-table-past-end.d.ts +1 -1
  34. package/dist/lib/components/ui/display/index.d.ts +2 -0
  35. package/dist/lib/components/ui/display/list-tile/partials/list-tile-line.d.ts +1 -1
  36. package/dist/lib/components/ui/display/period-cards/period-cards.d.ts +1 -1
  37. package/dist/lib/components/ui/display/secret-card/index.d.ts +2 -0
  38. package/dist/lib/components/ui/display/secret-card/secret-card.d.ts +11 -0
  39. package/dist/lib/components/ui/display/secret-card/types.d.ts +49 -0
  40. package/dist/lib/components/ui/display/secret-card/use-secret-card.d.ts +20 -0
  41. package/dist/lib/components/ui/display/vote-tally/announcer.d.ts +11 -0
  42. package/dist/lib/components/ui/display/vote-tally/index.d.ts +2 -0
  43. package/dist/lib/components/ui/display/vote-tally/partials/tally-bar.d.ts +8 -0
  44. package/dist/lib/components/ui/display/vote-tally/partials/tally-row.d.ts +9 -0
  45. package/dist/lib/components/ui/display/vote-tally/partials/tally-voters.d.ts +9 -0
  46. package/dist/lib/components/ui/display/vote-tally/types.d.ts +73 -0
  47. package/dist/lib/components/ui/display/vote-tally/use-announcement.d.ts +7 -0
  48. package/dist/lib/components/ui/display/vote-tally/use-tally-summary.d.ts +3 -0
  49. package/dist/lib/components/ui/display/vote-tally/vote-tally.d.ts +10 -0
  50. package/dist/lib/components/ui/display/vote-tally/vote-tally.utils.d.ts +18 -0
  51. package/dist/lib/components/ui/fields/contacts/placeholders.d.ts +1 -1
  52. package/dist/lib/components/ui/layout/account-menu/account-menu.d.ts +1 -1
  53. package/dist/lib/components/ui/layout/app-shell/app-sidebar-icon-item.d.ts +1 -1
  54. package/dist/lib/components/ui/layout/app-shell/app-sidebar-label-item.d.ts +1 -1
  55. package/dist/lib/components/ui/layout/app-shell/app-sidebar.d.ts +1 -1
  56. package/dist/lib/components/ui/layout/pager/pager.d.ts +1 -1
  57. package/dist/lib/components/ui/layout/see-also/see-also.d.ts +1 -1
  58. package/dist/lib/components/ui/layout/toc/toc.d.ts +1 -1
  59. package/dist/lib/components/ui/primitives/index.d.ts +1 -0
  60. package/dist/lib/components/ui/primitives/link/index.d.ts +2 -0
  61. package/dist/lib/components/ui/primitives/link/link-component-provider.d.ts +26 -0
  62. package/dist/lib/components/ui/widgets/education/index.d.ts +1 -0
  63. package/dist/lib/components/ui/widgets/education/navigator/index.d.ts +2 -0
  64. package/dist/lib/components/ui/widgets/education/navigator/partials/navigator-item.d.ts +10 -0
  65. package/dist/lib/components/ui/widgets/education/navigator/question-navigator.d.ts +27 -0
  66. package/dist/lib/components/ui/widgets/education/questions/index.d.ts +1 -1
  67. package/dist/lib/components/ui/widgets/education/questions/partials/question-media.d.ts +5 -0
  68. package/dist/lib/components/ui/widgets/education/questions/partials/question-verdict.d.ts +12 -0
  69. package/dist/lib/components/ui/widgets/education/questions/question-options.d.ts +2 -2
  70. package/dist/lib/components/ui/widgets/education/questions/question-text.d.ts +1 -1
  71. package/dist/lib/components/ui/widgets/education/questions/question.d.ts +1 -1
  72. package/dist/lib/components/ui/widgets/education/questions/types.d.ts +33 -1
  73. package/dist/lib/hooks/index.d.ts +1 -0
  74. package/dist/lib/hooks/use-countdown.d.ts +18 -0
  75. package/dist/lib/i18n/resources/en/en.d.ts +31 -0
  76. package/dist/lib/i18n/resources/en/material.json.d.ts +31 -0
  77. package/dist/lib/i18n/resources/fr/fr.d.ts +31 -0
  78. package/dist/lib/i18n/resources/fr/material.json.d.ts +31 -0
  79. package/dist/lib/i18n/resources/resources.d.ts +62 -0
  80. package/dist/lib/i18n/translator.d.ts +1 -1
  81. package/dist/lib.js +1 -1
  82. package/dist/main.js +43 -43
  83. package/dist/map.js +8 -8
  84. package/dist/material.css +1 -1
  85. package/dist/pdf-reader.js +1 -1
  86. package/dist/print.js +2 -2
  87. package/dist/{public-tWgM204K.js → public-BtKAthSs.js} +9 -9
  88. package/dist/{rich-code-block-0OcsqURY.js → rich-code-block-BZYZ6ewr.js} +5 -5
  89. package/dist/{sortable-provider-DzFAlPU_.js → sortable-provider-hUCwt69K.js} +2 -2
  90. package/dist/{spinner-GKZcQmrR.js → spinner-BmUXTzvJ.js} +1 -1
  91. package/dist/{stored-language-B39mABN7.js → stored-language-_OGPBCn0.js} +62 -0
  92. package/dist/theme-picker.js +2 -2
  93. package/dist/{theme-toggle-C8idk_jQ.js → theme-toggle-DdvEafno.js} +1 -1
  94. package/dist/{toolbar-items-BrJI-BCs.js → toolbar-items-DKfY_6AG.js} +4 -4
  95. package/dist/{translate-material-hUA-UAP6.js → translate-material-DzBMPErh.js} +2 -2
  96. package/dist/{translator-1ql2sxrx.js → translator-BIVSiq4X.js} +1 -1
  97. package/dist/{use-chat-composer-DQpb8ZXV.js → use-chat-composer-DCQW2EJ8.js} +16 -16
  98. package/dist/{use-heading-spy-vlPOI1Aj.js → use-heading-spy-Cr2C_Px8.js} +32 -5
  99. package/dist/{use-material-translation-B1p5y3s9.js → use-material-translation-C_bAxfvp.js} +2 -2
  100. package/docs/components/README.md +1 -1
  101. package/docs/components/buttons-and-actions.md +1 -0
  102. package/docs/components/data-display.md +147 -2
  103. package/docs/components/data-table-filter-and-lists.md +3 -3
  104. package/docs/components/feedback-and-status.md +5 -3
  105. package/docs/components/navigation-and-layout.md +4 -4
  106. package/docs/components/primitives.md +62 -0
  107. package/docs/components/rich-widgets.md +51 -0
  108. package/docs/components/selection-and-choice.md +1 -1
  109. package/docs/guides/app-shell.md +2 -2
  110. package/docs/guides/helpers.md +40 -0
  111. package/docs/previews/data-display-secretcard.webp +0 -0
  112. package/docs/previews/data-display-votetally.webp +0 -0
  113. package/lib/components/components.css +4 -0
  114. package/lib/components/ui/display/secret-card/secret-card.css +65 -0
  115. package/lib/components/ui/display/vote-tally/vote-tally.css +94 -0
  116. package/package.json +1 -1
  117. package/dist/lib/components/ui/feedback/countdown-timer/use-countdown.d.ts +0 -6
@@ -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
@@ -138,6 +138,10 @@
138
138
  @import "./ui/display/seat-ring/seat-ring.css";
139
139
  @import "./ui/display/seat-ring/seat-ring-index.css";
140
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";
141
145
  /* Alert and Banner span the row in the form grids (#1497). */
142
146
  @import "./ui/feedback/alert/alert.css";
143
147
  @import "./ui/display/stat-card/stat-card.css";
@@ -0,0 +1,65 @@
1
+ /* SecretCard: a card whose secret face is not rendered until it is revealed.
2
+ * It carries `data-tone`, so the tone's tokens resolve on it. The hidden face
3
+ * is a quiet diagonal pattern in the tone's ring colour. The card's border is
4
+ * that colour too (set by a utility class on the Card, which wins over the
5
+ * Card's own). A change flips the face in, never on mount, and only when the person has not asked for reduced motion. The card is
6
+ * full width up to a reading width and keeps the reveal button a 44px target
7
+ * on a phone. */
8
+ @layer components {
9
+ .nxgt-secret-card {
10
+ width: 100%;
11
+ max-width: 28rem;
12
+ margin-inline: auto;
13
+ --secret-card-line: var(--nxgt-tone-ring, var(--border));
14
+ border-color: var(--secret-card-line);
15
+ perspective: 60rem;
16
+ }
17
+ .nxgt-secret-card [data-slot="secret-card-title"] {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: 0.5rem;
21
+ color: var(--nxgt-tone-text, inherit);
22
+ }
23
+ .nxgt-secret-card [data-slot="secret-card-pattern"] {
24
+ min-height: 6rem;
25
+ border-radius: 0.5rem;
26
+ border: 1px dashed var(--secret-card-line);
27
+ background-image: repeating-linear-gradient(
28
+ 45deg,
29
+ color-mix(in oklab, var(--secret-card-line) 25%, transparent) 0 0.5rem,
30
+ transparent 0.5rem 1rem
31
+ );
32
+ }
33
+ .nxgt-secret-card [data-slot="secret-card-back"] {
34
+ min-height: 6rem;
35
+ display: grid;
36
+ place-items: center;
37
+ text-align: center;
38
+ overflow-wrap: anywhere;
39
+ font-size: 1.25rem;
40
+ font-weight: 600;
41
+ user-select: text;
42
+ }
43
+ .nxgt-secret-card [data-slot="secret-card-actions"] {
44
+ flex-wrap: wrap;
45
+ gap: 0.5rem;
46
+ }
47
+ .nxgt-secret-card [data-slot="secret-card-actions"] > button {
48
+ min-height: 44px;
49
+ }
50
+ @media (prefers-reduced-motion: no-preference) {
51
+ .nxgt-secret-card [data-slot="secret-card-face"][data-changed] {
52
+ animation: nxgt-secret-flip 0.35s ease-out;
53
+ }
54
+ }
55
+ @keyframes nxgt-secret-flip {
56
+ from {
57
+ opacity: 0;
58
+ transform: rotateY(90deg);
59
+ }
60
+ to {
61
+ opacity: 1;
62
+ transform: rotateY(0);
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,94 @@
1
+ /* VoteTally: ranked rows, label and "n votes, x%" as text, a decorative bar
2
+ * beneath. The bar is an SVG whose rect width is an attribute (no `style`,
3
+ * strict CSP). Leader and tie take the primary fill and a word; the others a
4
+ * quieter one. Layout is one column at any width: the stat wraps under the
5
+ * label on a phone, the bar keeps its own line, so counts changing never move
6
+ * a row. Bars ease only without reduced motion. */
7
+ @layer components {
8
+ .nxgt-vote-tally {
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: 0.75rem;
12
+ min-inline-size: 0;
13
+ }
14
+ .nxgt-vote-tally [data-slot="vote-tally-list"] {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 0.875rem;
18
+ margin: 0;
19
+ padding: 0;
20
+ list-style: none;
21
+ }
22
+ .nxgt-vote-tally [data-slot="vote-tally-row"] {
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: 0.375rem;
26
+ min-inline-size: 0;
27
+ }
28
+ .nxgt-vote-tally [data-slot="vote-tally-head"] {
29
+ display: flex;
30
+ flex-wrap: wrap;
31
+ align-items: baseline;
32
+ justify-content: space-between;
33
+ column-gap: 0.75rem;
34
+ row-gap: 0.125rem;
35
+ }
36
+ .nxgt-vote-tally [data-slot="vote-tally-name"] {
37
+ display: inline-flex;
38
+ flex-wrap: wrap;
39
+ align-items: center;
40
+ gap: 0.375rem 0.5rem;
41
+ min-inline-size: 0;
42
+ font-weight: 500;
43
+ overflow-wrap: anywhere;
44
+ }
45
+ .nxgt-vote-tally [data-slot="vote-tally-marks"] {
46
+ display: inline-flex;
47
+ flex-wrap: wrap;
48
+ gap: 0.25rem;
49
+ }
50
+ .nxgt-vote-tally [data-slot="vote-tally-stat"] {
51
+ color: var(--muted-foreground);
52
+ font-size: 0.875rem;
53
+ font-variant-numeric: tabular-nums;
54
+ white-space: nowrap;
55
+ }
56
+ .nxgt-vote-tally [data-slot="vote-tally-bar"] {
57
+ display: block;
58
+ block-size: 0.625rem;
59
+ overflow: hidden;
60
+ border-radius: 9999px;
61
+ background: var(--muted);
62
+ }
63
+ .nxgt-vote-tally [data-slot="vote-tally-bar"] svg {
64
+ display: block;
65
+ inline-size: 100%;
66
+ block-size: 100%;
67
+ }
68
+ .nxgt-vote-tally:dir(rtl) [data-slot="vote-tally-bar"] svg {
69
+ transform: scaleX(-1);
70
+ }
71
+ .nxgt-vote-tally [data-slot="vote-tally-fill"] {
72
+ fill: color-mix(in oklab, var(--muted-foreground) 70%, var(--background));
73
+ }
74
+ .nxgt-vote-tally [data-leader] [data-slot="vote-tally-fill"],
75
+ .nxgt-vote-tally [data-selected] [data-slot="vote-tally-fill"] {
76
+ fill: var(--primary-indicator, var(--primary));
77
+ }
78
+ .nxgt-vote-tally [data-slot="vote-tally-voters"] {
79
+ margin: 0;
80
+ color: var(--muted-foreground);
81
+ font-size: 0.8125rem;
82
+ }
83
+ .nxgt-vote-tally [data-slot="vote-tally-empty"],
84
+ .nxgt-vote-tally [data-slot="vote-tally-abstain"] {
85
+ margin: 0;
86
+ color: var(--muted-foreground);
87
+ font-size: 0.875rem;
88
+ }
89
+ @media (prefers-reduced-motion: no-preference) {
90
+ .nxgt-vote-tally[data-animated] [data-slot="vote-tally-fill"] {
91
+ transition: width 400ms cubic-bezier(0.2, 0, 0, 1);
92
+ }
93
+ }
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/material",
3
- "version": "3.55.0",
3
+ "version": "3.56.0",
4
4
  "license": "MIT",
5
5
  "bugs": {
6
6
  "email": "stevegires@gmail.com"
@@ -1,6 +0,0 @@
1
- /**
2
- * Seconds left of `duration`, ticking on the client only (the server and the
3
- * first render show the full time). Pausing keeps what is left; a new
4
- * `duration` starts over. `onExpire` runs once, when it reaches zero.
5
- */
6
- export declare function useCountdown(duration: number, paused: boolean, onExpire?: () => void): number;