@nxgt/material 3.62.0 → 3.63.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 (92) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -1
  3. package/dist/alert-dialog-RHm6WtQj.js +189 -0
  4. package/dist/{async-slice-HJY_VJjD.js → async-slice-DmX59NyA.js} +3 -4
  5. package/dist/{button-7BuUCGRm.js → button-DfS_amfQ.js} +1 -1
  6. package/dist/charts.js +3 -3
  7. package/dist/{chip-kQOrHlDX.js → chip-Cbndukko.js} +2 -2
  8. package/dist/{code-editor-Ckc9TTCM.js → code-editor-B0tN8Ha_.js} +11 -11
  9. package/dist/code-editor.js +5 -5
  10. package/dist/{code-language-items-rCPB0Xee.js → code-language-items-andgHKrM.js} +2 -2
  11. package/dist/components/ui/layout/sidebar/sidebar-tokens.css +13 -1
  12. package/dist/{components-TDDUQ1Y0.js → components-Cm2doOJS.js} +14345 -14224
  13. package/dist/components.js +28 -26
  14. package/dist/dialog-Bd7Hid2L.js +219 -0
  15. package/dist/dnd.js +1 -1
  16. package/dist/{dropdown-menu-CiobN1wi.js → dropdown-menu-_SyVmSuy.js} +1 -1
  17. package/dist/editor.js +707 -706
  18. package/dist/{empty-state-DA517HAb.js → empty-state-D-2ZMXXJ.js} +2 -2
  19. package/dist/escape-owner-DpaP0lm1.js +19 -0
  20. package/dist/{field-blur-DdvY0NQQ.js → field-blur-DyOByjzw.js} +1 -1
  21. package/dist/{field-blur-context-DbJQZkEB.js → field-blur-context-DPe5_Nv5.js} +1 -1
  22. package/dist/hooks-a7_exKWS.js +329 -0
  23. package/dist/hooks.js +3 -3
  24. package/dist/{hover-card-DPs9huSs.js → hover-card-D_0smKBQ.js} +2 -2
  25. package/dist/i18n.js +8 -10
  26. package/dist/{icon-badge-C646JTOI.js → icon-badge-CBCU3ySF.js} +14 -14
  27. package/dist/{icon-button-DvP0Gy3j.js → icon-button-hKHsWTAt.js} +1 -1
  28. package/dist/lib/components/ui/feedback/index.d.ts +1 -0
  29. package/dist/lib/components/ui/feedback/unsaved-dialog/index.d.ts +1 -0
  30. package/dist/lib/components/ui/feedback/unsaved-dialog/unsaved-dialog.d.ts +20 -0
  31. package/dist/lib/components/ui/fields/contacts/placeholders.d.ts +1 -1
  32. package/dist/lib/components/ui/fields/textarea-field/textarea-footer.d.ts +2 -1
  33. package/dist/lib/components/ui/widgets/education/questions/index.d.ts +3 -1
  34. package/dist/lib/components/ui/widgets/education/questions/partials/question-content.d.ts +1 -1
  35. package/dist/lib/components/ui/widgets/education/questions/partials/question-top.d.ts +6 -0
  36. package/dist/lib/components/ui/widgets/education/questions/question-long-text.d.ts +5 -0
  37. package/dist/lib/components/ui/widgets/education/questions/question-media-content.d.ts +24 -0
  38. package/dist/lib/components/ui/widgets/education/questions/question-media-helpers.d.ts +11 -0
  39. package/dist/lib/components/ui/widgets/education/questions/question-media-view.d.ts +18 -0
  40. package/dist/lib/components/ui/widgets/education/questions/question-media-view.types.d.ts +32 -0
  41. package/dist/lib/components/ui/widgets/education/questions/question-options.d.ts +2 -2
  42. package/dist/lib/components/ui/widgets/education/questions/question-text.d.ts +1 -1
  43. package/dist/lib/components/ui/widgets/education/questions/question.d.ts +1 -1
  44. package/dist/lib/components/ui/widgets/education/questions/types.d.ts +38 -1
  45. package/dist/lib/components/ui/widgets/education/questions/use-reload-focus.d.ts +13 -0
  46. package/dist/lib/hooks/index.d.ts +3 -0
  47. package/dist/lib/hooks/unsaved-guard-rules.d.ts +30 -0
  48. package/dist/lib/hooks/use-react-router-block.d.ts +11 -0
  49. package/dist/lib/hooks/use-unsaved-guard/guard-core.d.ts +30 -0
  50. package/dist/lib/hooks/use-unsaved-guard/index.d.ts +3 -0
  51. package/dist/lib/hooks/use-unsaved-guard/types.d.ts +63 -0
  52. package/dist/lib/hooks/use-unsaved-guard/use-discard-guard.d.ts +15 -0
  53. package/dist/lib/hooks/use-unsaved-guard/use-unsaved-guard.d.ts +19 -0
  54. package/dist/lib/i18n/resources/en/en.d.ts +20 -0
  55. package/dist/lib/i18n/resources/en/material.json.d.ts +21 -1
  56. package/dist/lib/i18n/resources/fr/fr.d.ts +20 -0
  57. package/dist/lib/i18n/resources/fr/material.json.d.ts +21 -1
  58. package/dist/lib/i18n/resources/resources.d.ts +40 -0
  59. package/dist/lib/i18n/translator.d.ts +1 -1
  60. package/dist/lib.js +1 -1
  61. package/dist/main.js +42 -43
  62. package/dist/map.js +63 -63
  63. package/dist/material.css +1 -1
  64. package/dist/pdf-reader.js +1 -1
  65. package/dist/print.js +1 -2
  66. package/dist/{public-BYsrhyPW.js → public-D8JGp3PH.js} +823 -822
  67. package/dist/{rich-code-block-WJxDWPPY.js → rich-code-block-0R3bidsM.js} +5 -5
  68. package/dist/{sortable-provider-oPjlhlZo.js → sortable-provider-B75v53sB.js} +2 -2
  69. package/dist/{spinner-C5lObWXT.js → spinner-CAnpF3RK.js} +1 -1
  70. package/dist/theme-picker.js +2 -2
  71. package/dist/{theme-toggle-CPEq5JG-.js → theme-toggle-pQ5F602V.js} +1 -1
  72. package/dist/{toolbar-items-ByvXcMHm.js → toolbar-items-_ezD5B0r.js} +4 -4
  73. package/dist/translate-material-CvZK8_S6.js +96 -0
  74. package/dist/{stored-language-B-tiWZU2.js → translator-Caf-d96e.js} +2497 -8
  75. package/dist/{escape-owner-r57kuPjn.js → typography-CEK19bj8.js} +17 -34
  76. package/dist/{use-chat-composer-CzOVaIed.js → use-chat-composer-Dpvi_-CR.js} +5164 -5338
  77. package/dist/{use-heading-spy-BOtygPFu.js → use-heading-spy-mmcyEGmm.js} +186 -71
  78. package/dist/{use-material-translation-B5j3jZs7.js → use-material-translation-C_1cWRzW.js} +1 -2
  79. package/dist/use-return-focus-1ZIchrBM.js +269 -0
  80. package/docs/components/feedback-and-status.md +23 -0
  81. package/docs/components/navigation-and-layout.md +4 -3
  82. package/docs/components/rich-widgets.md +73 -1
  83. package/docs/guides/helpers.md +63 -0
  84. package/docs/guides/theming.md +13 -3
  85. package/docs/previews/feedback-status-unsaveddialog.webp +0 -0
  86. package/lib/components/ui/layout/sidebar/sidebar-tokens.css +13 -1
  87. package/package.json +1 -1
  88. package/dist/dialog-yfPkLqUQ.js +0 -482
  89. package/dist/hooks-CGnqFngB.js +0 -187
  90. package/dist/i18n-provider-BehR6cU7.js +0 -79
  91. package/dist/translate-material-tUaLZUmT.js +0 -20
  92. package/dist/translator-B9XjUDRu.js +0 -2451
@@ -0,0 +1,269 @@
1
+ import { o as e, r as t } from "./dist-DVRQkaTK.js";
2
+ import { a as n, c as r, i, l as a, o, r as s, s as c } from "./dist-lmHmz_bu.js";
3
+ import { C as l, S as u, w as d } from "./spinner-CAnpF3RK.js";
4
+ import { c as f, i as p, l as m, o as h, r as g, s as ee } from "./in-dialog-Vwkyp-M5.js";
5
+ import { r as _ } from "./escape-owner-DpaP0lm1.js";
6
+ import * as v from "react";
7
+ import { useCallback as y, useContext as te, useRef as b } from "react";
8
+ import { Fragment as x, jsx as S } from "react/jsx-runtime";
9
+ import { forwardEscape as C, keepListEscape as w } from "@nxgt/material-core/escape-forward";
10
+ //#region ../../node_modules/@radix-ui/react-dialog/dist/index.mjs
11
+ var T = Object.defineProperty, E = (e, t) => T(e, "name", {
12
+ value: t,
13
+ configurable: !0
14
+ }), D = "Dialog", [O, ne] = r(D), [re, k] = O(D), A = /* @__PURE__ */ E((e) => {
15
+ let { __scopeDialog: t, children: r, open: i, defaultOpen: a, onOpenChange: o, modal: c = !0 } = e, l = v.useRef(null), u = v.useRef(null), [d, f] = n({
16
+ prop: i,
17
+ defaultProp: a ?? !1,
18
+ onChange: o,
19
+ caller: D
20
+ }), [p, m] = v.useState(0), [h, g] = v.useState(0);
21
+ return /* @__PURE__ */ S(re, {
22
+ scope: t,
23
+ triggerRef: l,
24
+ contentRef: u,
25
+ contentId: s(),
26
+ titleId: s(),
27
+ descriptionId: s(),
28
+ titlePresent: p > 0,
29
+ descriptionPresent: h > 0,
30
+ setTitleCount: m,
31
+ setDescriptionCount: g,
32
+ open: d,
33
+ onOpenChange: f,
34
+ onOpenToggle: v.useCallback(() => f((e) => !e), [f]),
35
+ modal: c,
36
+ children: r
37
+ });
38
+ }, "Dialog"), j = "DialogTrigger", M = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(t, n) {
39
+ let { __scopeDialog: r, ...i } = t, o = k(j, r), s = e(n, o.triggerRef);
40
+ return /* @__PURE__ */ S(a.button, {
41
+ type: "button",
42
+ "aria-haspopup": "dialog",
43
+ "aria-expanded": o.open,
44
+ "aria-controls": o.open ? o.contentId : void 0,
45
+ "data-state": Z(o.open),
46
+ ...i,
47
+ ref: s,
48
+ onClick: c(t.onClick, o.onOpenToggle)
49
+ });
50
+ }, "DialogTrigger")), N = "DialogPortal", [P, F] = O(N, { forceMount: void 0 }), I = /* @__PURE__ */ E((e) => {
51
+ let { __scopeDialog: t, forceMount: n, children: r, container: a } = e, o = k(N, t);
52
+ return /* @__PURE__ */ S(P, {
53
+ scope: t,
54
+ forceMount: n,
55
+ children: v.Children.map(r, (e) => /* @__PURE__ */ S(i, {
56
+ present: n || o.open,
57
+ children: /* @__PURE__ */ S(u, {
58
+ asChild: !0,
59
+ container: a,
60
+ children: e
61
+ })
62
+ }))
63
+ });
64
+ }, "DialogPortal"), L = "DialogOverlay", R = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(e, t) {
65
+ let n = F(L, e.__scopeDialog), { forceMount: r = n.forceMount, ...a } = e, o = k(L, e.__scopeDialog);
66
+ return o.modal ? /* @__PURE__ */ S(i, {
67
+ present: r || o.open,
68
+ children: /* @__PURE__ */ S(B, {
69
+ ...a,
70
+ ref: t
71
+ })
72
+ }) : null;
73
+ }, "DialogOverlay")), z = t("DialogOverlay.RemoveScroll"), B = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(t, n) {
74
+ let { __scopeDialog: r, ...i } = t, o = k(L, r), s = e(n, d());
75
+ return /* @__PURE__ */ S(ee, {
76
+ as: z,
77
+ allowPinchZoom: !0,
78
+ shards: [o.contentRef],
79
+ children: /* @__PURE__ */ S(a.div, {
80
+ "data-state": Z(o.open),
81
+ ...i,
82
+ ref: s,
83
+ style: {
84
+ pointerEvents: "auto",
85
+ ...i.style
86
+ }
87
+ })
88
+ });
89
+ }, "DialogOverlayImpl")), V = "DialogContent", H = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(e, t) {
90
+ let n = F(V, e.__scopeDialog), { forceMount: r = n.forceMount, ...a } = e, o = k(V, e.__scopeDialog);
91
+ return /* @__PURE__ */ S(i, {
92
+ present: r || o.open,
93
+ children: o.modal ? /* @__PURE__ */ S(U, {
94
+ ...a,
95
+ ref: t
96
+ }) : /* @__PURE__ */ S(W, {
97
+ ...a,
98
+ ref: t
99
+ })
100
+ });
101
+ }, "DialogContent")), U = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(t, n) {
102
+ let r = k(V, t.__scopeDialog), i = v.useRef(null), a = e(n, r.contentRef, i);
103
+ return v.useEffect(() => {
104
+ let e = i.current;
105
+ if (e) return h(e);
106
+ }, []), /* @__PURE__ */ S(G, {
107
+ ...t,
108
+ ref: a,
109
+ trapFocus: r.open,
110
+ disableOutsidePointerEvents: r.open,
111
+ onCloseAutoFocus: c(t.onCloseAutoFocus, (e) => {
112
+ e.preventDefault(), r.triggerRef.current?.focus();
113
+ }),
114
+ onPointerDownOutside: c(t.onPointerDownOutside, (e) => {
115
+ let t = e.detail.originalEvent, n = t.button === 0 && t.ctrlKey === !0;
116
+ (t.button === 2 || n) && e.preventDefault();
117
+ }),
118
+ onFocusOutside: c(t.onFocusOutside, (e) => e.preventDefault())
119
+ });
120
+ }, "DialogContentModal")), W = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(e, t) {
121
+ let n = k(V, e.__scopeDialog), r = v.useRef(!1), i = v.useRef(!1);
122
+ return /* @__PURE__ */ S(G, {
123
+ ...e,
124
+ ref: t,
125
+ trapFocus: !1,
126
+ disableOutsidePointerEvents: !1,
127
+ onCloseAutoFocus: (t) => {
128
+ e.onCloseAutoFocus?.(t), t.defaultPrevented || (r.current || n.triggerRef.current?.focus(), t.preventDefault()), r.current = !1, i.current = !1;
129
+ },
130
+ onInteractOutside: (t) => {
131
+ e.onInteractOutside?.(t), t.defaultPrevented || (r.current = !0, t.detail.originalEvent.type === "pointerdown" && (i.current = !0));
132
+ let a = t.target;
133
+ n.triggerRef.current?.contains(a) && t.preventDefault(), t.detail.originalEvent.type === "focusin" && i.current && t.preventDefault();
134
+ }
135
+ });
136
+ }, "DialogContentNonModal")), G = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(e, t) {
137
+ let { __scopeDialog: n, trapFocus: r, onOpenAutoFocus: i, onCloseAutoFocus: a, ...o } = e, s = k(V, n);
138
+ return f(), /* @__PURE__ */ S(x, { children: /* @__PURE__ */ S(m, {
139
+ asChild: !0,
140
+ loop: !0,
141
+ trapped: r,
142
+ onMountAutoFocus: i,
143
+ onUnmountAutoFocus: a,
144
+ children: /* @__PURE__ */ S(l, {
145
+ role: "dialog",
146
+ id: s.contentId,
147
+ "aria-describedby": s.descriptionPresent ? s.descriptionId : void 0,
148
+ "aria-labelledby": s.titlePresent ? s.titleId : void 0,
149
+ "data-state": Z(s.open),
150
+ ...o,
151
+ ref: t,
152
+ deferPointerDownOutside: !0,
153
+ onDismiss: () => s.onOpenChange(!1)
154
+ })
155
+ }) });
156
+ }, "DialogContentImpl")), K = "DialogTitle", q = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(e, t) {
157
+ let { __scopeDialog: n, ...r } = e, i = k(K, n), { setTitleCount: s } = i;
158
+ return o(() => (s((e) => e + 1), () => s((e) => e - 1)), [s]), /* @__PURE__ */ S(a.h2, {
159
+ id: i.titleId,
160
+ ...r,
161
+ ref: t
162
+ });
163
+ }, "DialogTitle")), J = "DialogDescription", Y = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(e, t) {
164
+ let { __scopeDialog: n, ...r } = e, i = k(J, n), { setDescriptionCount: s } = i;
165
+ return o(() => (s((e) => e + 1), () => s((e) => e - 1)), [s]), /* @__PURE__ */ S(a.p, {
166
+ id: i.descriptionId,
167
+ ...r,
168
+ ref: t
169
+ });
170
+ }, "DialogDescription")), X = "DialogClose", ie = /* @__PURE__ */ v.forwardRef(/* @__PURE__ */ E(function(e, t) {
171
+ let { __scopeDialog: n, ...r } = e, i = k(X, n);
172
+ return /* @__PURE__ */ S(a.button, {
173
+ type: "button",
174
+ ...r,
175
+ ref: t,
176
+ onClick: c(e.onClick, () => i.onOpenChange(!1))
177
+ });
178
+ }, "DialogClose"));
179
+ function Z(e) {
180
+ return e ? "open" : "closed";
181
+ }
182
+ E(Z, "getState");
183
+ //#endregion
184
+ //#region lib/components/ui/overlays/dialog/use-escape-forward.ts
185
+ function ae(t, n) {
186
+ let r = b(null);
187
+ return {
188
+ ref: e(r, n),
189
+ onEscapeKeyDown(e) {
190
+ w(e), _(e, r.current), t?.(e), C(r.current, e);
191
+ }
192
+ };
193
+ }
194
+ //#endregion
195
+ //#region lib/components/ui/overlays/dialog/use-overlay-order.ts
196
+ function oe(t) {
197
+ let n = b(null);
198
+ return {
199
+ overlayRef: y((e) => {
200
+ let t = n.current;
201
+ !e || !t?.isConnected || t.parentNode === e.parentNode && t.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_FOLLOWING && t.before(e);
202
+ }, []),
203
+ contentRef: e(n, t)
204
+ };
205
+ }
206
+ //#endregion
207
+ //#region lib/components/ui/overlays/dialog/use-modal-content.ts
208
+ function se({ ref: e, onEscapeKeyDown: t, ...n }, r) {
209
+ let i = te(g) ?? !0, a = ae(t, p(r ?? i, e)), o = oe(a.ref);
210
+ return {
211
+ ref: o.contentRef,
212
+ overlayRef: o.overlayRef,
213
+ onEscapeKeyDown: a.onEscapeKeyDown,
214
+ outside: {
215
+ onPointerDownOutside: Q(n.onPointerDownOutside),
216
+ onFocusOutside: Q(n.onFocusOutside),
217
+ onInteractOutside: Q(n.onInteractOutside)
218
+ }
219
+ };
220
+ }
221
+ function Q(e) {
222
+ return (t) => {
223
+ e?.(t);
224
+ let n = t.target;
225
+ n instanceof Element && n.closest("[data-sonner-toaster]") && t.preventDefault();
226
+ };
227
+ }
228
+ //#endregion
229
+ //#region lib/components/ui/overlays/dialog/menu-trigger.ts
230
+ var $ = "[role=\"menu\"]", ce = 8;
231
+ function le(e) {
232
+ let t = null, n = e?.closest($) ?? null;
233
+ for (let e = 0; n && e < ce; e++) {
234
+ let e = n.getAttribute("data-return-focus"), r = e ? n.ownerDocument.getElementById(e) : null;
235
+ if (r) return r;
236
+ let [i] = (n.getAttribute("aria-labelledby") ?? "").split(/\s+/), a = i ? n.ownerDocument.getElementById(i) : null;
237
+ if (!a) break;
238
+ t = a, n = a.closest($);
239
+ }
240
+ return t;
241
+ }
242
+ //#endregion
243
+ //#region lib/components/ui/overlays/dialog/use-return-focus.ts
244
+ var ue = "button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"]), [contenteditable=\"true\"]";
245
+ function de(e) {
246
+ ([...e.querySelectorAll(ue)].find((e) => e.tabIndex >= 0 && e.getClientRects().length > 0) ?? e).focus({ preventScroll: !0 });
247
+ }
248
+ function fe(e, { focusFallback: t = !1 } = {}) {
249
+ let n = b(null), r = b(null);
250
+ return {
251
+ onOpenAutoFocus(i) {
252
+ let a = document.activeElement;
253
+ n.current = a instanceof HTMLElement && a !== document.body ? a : null, r.current = le(n.current), e.onOpenAutoFocus?.(i);
254
+ let o = i.target;
255
+ !t || i.defaultPrevented || !(o instanceof HTMLElement) || queueMicrotask(() => {
256
+ o.isConnected && !o.contains(document.activeElement) && de(o);
257
+ });
258
+ },
259
+ onCloseAutoFocus(t) {
260
+ e.onCloseAutoFocus?.(t);
261
+ let i = n.current?.isConnected ? n.current : r.current;
262
+ if (n.current = null, r.current = null, t.defaultPrevented || !i?.isConnected) return;
263
+ let a = document.activeElement;
264
+ a && a !== document.body || (t.preventDefault(), i.focus());
265
+ }
266
+ };
267
+ }
268
+ //#endregion
269
+ export { H as a, I as c, ne as d, ie as i, q as l, se as n, Y as o, A as r, R as s, fe as t, M as u };
@@ -12,6 +12,7 @@ centre.
12
12
  | <img src="../previews/feedback-status-confirmdialog.webp" width="96" alt=""> | [ConfirmDialog](#confirmdialog) | Confirm / cancel dialog, `ConfirmationDialog`, `CustomAlertDialog` |
13
13
  | <img src="../previews/feedback-status-customdialog.webp" width="96" alt=""> | [CustomDialog](#customdialog) | Ready-made dialog with trigger, icon, title, body and footer |
14
14
  | <img src="../previews/feedback-status-stepupdialog.webp" width="96" alt=""> | [StepUpDialog](#stepupdialog) | "Confirm it's you": a dialog that asks for a one-time code, driven by props |
15
+ | <img src="../previews/feedback-status-unsaveddialog.webp" width="96" alt=""> | [UnsavedDialog](#unsaveddialog) | "Leave without saving?": a destructive confirm with Stay focused, used by the unsaved-changes guards |
15
16
  | <img src="../previews/feedback-status-emptystate.webp" width="96" alt=""> | [EmptyState](#emptystate) | Illustration, title, description and action, by kind (empty, search, not-found, unauthorized) |
16
17
  | <img src="../previews/feedback-status-pageloadfailed.webp" width="96" alt=""> | [PageLoadFailed](#pageloadfailed) | An empty state for a page opened at a cursor that failed, with a link back to the start |
17
18
  | <img src="../previews/feedback-status-errorstate.webp" width="96" alt=""> | [ErrorState](#errorstate) | Failure block with Retry and technical details |
@@ -468,6 +469,28 @@ dialog by `getByRole('dialog', { name: "Confirm it's you" })` and the lock by
468
469
 
469
470
  ---
470
471
 
472
+ ## UnsavedDialog
473
+
474
+ ![UnsavedDialog](../previews/feedback-status-unsaveddialog.webp)
475
+
476
+ "Leave without saving?": a destructive `ConfirmDialog` whose default focus is **Stay**, so Enter keeps the work. [`useUnsavedGuard` and `useDiscardGuard`](../guides/helpers.md#useunsavedguard) render it; use it directly for a guard of your own.
477
+
478
+ ```tsx
479
+ import { UnsavedDialog } from '@nxgt/material/components';
480
+
481
+ <UnsavedDialog open={open} onOpenChange={setOpen} onLeave={() => navigate('/items')} />;
482
+ ```
483
+
484
+ | Prop | Type | Default | Effect |
485
+ | --- | --- | --- | --- |
486
+ | `open`, `onOpenChange` | `boolean`, `(open) => void` | — | controlled state |
487
+ | `onLeave` | `() => void \| Promise<void>` | — | runs on "Leave"; the dialog closes after it |
488
+ | `title`, `description`, `stayLabel`, `leaveLabel` | `string` | translated | texts (`material.unsaved.*`) |
489
+
490
+ **Storybook:** `Feedback & status/UnsavedDialog` · **Vue:** not yet (`vue` issue #2019)
491
+
492
+ ---
493
+
471
494
  ## EmptyState
472
495
 
473
496
  ![EmptyState](../previews/feedback-status-emptystate.webp)
@@ -304,9 +304,10 @@ icon-only entry (no label) falls back to its `aria-label`:
304
304
  | `withToggleIcon` | `boolean` | `false` | built-in expand / collapse button, shown on hover or while focus is inside the sidebar, named "Toggle sidebar" (`material.sidebar.toggle`) with `aria-expanded` |
305
305
 
306
306
  The selected entry's shape (`--nxgt-item-radius`, 6px, in the expanded row; the collapsed rail keeps its pill icon), its hover and its label and icon colour are the
307
- tokens `--sidebar-active`, `--sidebar-active-hover` and
308
- `--sidebar-active-foreground` (light and dark); a theme sets them to recolour
309
- the rail. See [Theming](../guides/theming.md#the-active-entry-of-the-sidebar-and-the-rail).
307
+ tokens `--sidebar-active`, `--sidebar-active-hover`,
308
+ `--sidebar-active-foreground` and `--sidebar-active-edge` (the start bar,
309
+ defaulting to the foreground), in light and dark; a theme sets any of them to
310
+ recolour the rail. See [Theming](../guides/theming.md#the-active-entry-of-the-sidebar-and-the-rail).
310
311
 
311
312
  **Storybook:** `Navigation & layout/Sidebar` · **Vue:** `Sidebar` (same parts)
312
313
 
@@ -2434,7 +2434,7 @@ French bundle — see [i18n](../guides/i18n.md).
2434
2434
 
2435
2435
  | Prop | Type | Default | Effect |
2436
2436
  | --- | --- | --- | --- |
2437
- | `questions` | `Question[]` | — | `kind`: `'choice'`, `'multi'`, `'boolean'`, `'text'` or `'number'` |
2437
+ | `questions` | `Question[]` | — | `kind`: `'choice'`, `'multi'`, `'boolean'`, `'text'` or `'number'` (`'open'` and `'code'` are for `QuestionCard`: `Quiz` grades in the browser against `correct`, so an `open` or `code` question counts as wrong there; use `QuestionCard` on its own and pass the server's `verdict`) |
2438
2438
  | `mode` | `'exam' \| 'practice'` | `'exam'` | verdicts at the end, or after each Check |
2439
2439
  | `layout` | `'paged' \| 'list'` | `'paged'` | one question at a time, or all on one page |
2440
2440
  | `density` | `'comfortable' \| 'compact'` | `'comfortable'` | spacing and type size |
@@ -2451,8 +2451,44 @@ French bundle — see [i18n](../guides/i18n.md).
2451
2451
  | `QuestionCard` `points` / `feedback` | `{ awarded, possible }` / `string` | — | points earned and a grader's remark, in the verdict banner (with `verdict`) |
2452
2452
  | `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 |
2453
2453
  | `QuestionCard` `value` / `onValueChange` | `QuestionAnswer` | — | controlled answer |
2454
+ | `Question` `kind: 'open'` | `maxLength?` | — | a multi-line answer in a textarea named by the prompt; a counter shows while editable when `maxLength` is set. No `correct`: pass the grader's `verdict` |
2455
+ | `Question` `kind: 'code'` | `language?`, `maxLength?` | — | source code: drawn by `renderCode`, else a monospace textarea |
2456
+ | `QuestionCard` `renderCode` | `(field: QuestionCodeField) => ReactNode` | — | the code field of a `code` question; `field` is `{ value, onChange, label, labelledBy, language, maxLength, readOnly, disabled }`. Keeps Monaco out of the card's bundle |
2457
+ | `QuestionCard` `readOnly` / `busy` | `boolean` | `false` | `readOnly`: an answer already submitted, shown without the revealed marks; `busy`: read-only and `aria-busy` while it is saved; every kind honours both |
2458
+ | new strings | `material.education.kind-open`, `kind-code`, `code-placeholder` | — | English fallbacks and a French bundle |
2454
2459
  | `QuestionCard` `variant` / `density` / `showShortcuts` / `number` / `showFeedback` | — | `'card'`, `'comfortable'`, `false`, —, `true` | framed or plain, spacing, key badges, "Question 2", the verdict banner |
2455
2460
 
2461
+ An open answer and a code answer, with the code editor from its own subpath
2462
+ (it is client-only, so load it lazily in an SSR app):
2463
+
2464
+ ```tsx
2465
+ import { CodeEditor } from '@nxgt/material/code-editor';
2466
+
2467
+ <QuestionCard
2468
+ number={3}
2469
+ question={{ id: 'q3', kind: 'open', prompt: 'Explain X', maxLength: 20000 }}
2470
+ value={text}
2471
+ onValueChange={setText}
2472
+ busy={saving}
2473
+ />
2474
+
2475
+ <QuestionCard
2476
+ question={{ id: 'q4', kind: 'code', prompt: 'Add two numbers', language: 'python' }}
2477
+ value={source}
2478
+ onValueChange={setSource}
2479
+ renderCode={(f) => (
2480
+ <CodeEditor
2481
+ label={f.label}
2482
+ language={f.language}
2483
+ value={f.value}
2484
+ onChange={(v) => f.onChange(v ?? '')}
2485
+ readOnly={f.readOnly}
2486
+ disabled={f.disabled}
2487
+ />
2488
+ )}
2489
+ />
2490
+ ```
2491
+
2456
2492
  A server-graded answer, shown as given (no comparison in the browser):
2457
2493
 
2458
2494
  ```tsx
@@ -2467,6 +2503,42 @@ A server-graded answer, shown as given (no comparison in the browser):
2467
2503
 
2468
2504
  **Storybook:** `Rich widgets/Quiz`, `Rich widgets/QuestionCard` · **Vue:** `Quiz`, `QuestionCard` (the modes, results, number kind and shortcuts are React only for now, #890)
2469
2505
 
2506
+ ### QuestionMediaView
2507
+
2508
+ A question's file, picked by its type, with an error state for links that
2509
+ expire. It takes the shape of `Question.media` plus `size`, so
2510
+ `<QuestionMediaView media={question.media} />` works. It is named
2511
+ `QuestionMediaView` because `QuestionMedia` is the type of `Question.media`;
2512
+ `QuestionCard` still draws its own `media` without an error state and will
2513
+ reuse this view (#2021).
2514
+
2515
+ ```tsx
2516
+ import { QuestionMediaView } from '@nxgt/material/components';
2517
+
2518
+ <QuestionMediaView
2519
+ media={{ src, type: 'audio/mpeg', size: 4096 }}
2520
+ onReload={refetch}
2521
+ reloading={isRefetching}
2522
+ />
2523
+ ```
2524
+
2525
+ | Prop | Type | Default | Effect |
2526
+ | --- | --- | --- | --- |
2527
+ | `media` | `QuestionMedia & { size? }` | — | `kind`, else `type` (case-insensitive): `image/*` (also with no type) is an `<img>` named by `name` or `alt`, else "Image for this question"; `audio/*` an `AudioPlayer`; `video/*` a `<video controls>`; any other type a document |
2528
+ | `documents` | `'link' \| 'preview'` | `'link'` | `link`: an open-in-a-new-tab button showing the type and `size`, which fetches nothing (no `connect-src` or bucket CORS); `preview`: a `FilePreview`, which fetches the file |
2529
+ | `captions` | `{ src, srclang, label, default? }[]` | — | caption tracks of a video. A video with speech needs captions or a transcript next to it |
2530
+ | `onReload` | `() => void` | — | the Reload button of the error alert, shown when an image, audio or video fails to load; without it the alert has no button |
2531
+ | `reloading` | `boolean` | `false` | the Reload button is busy while the fresh link is fetched |
2532
+
2533
+ A failure is forgotten when `src` changes, so reloading a presigned link and
2534
+ passing the new `src` brings the file back, and focus moves to the file when it
2535
+ was on the Reload button (the wrapper, `data-slot="question-media"`, stays
2536
+ mounted). A reload that returns the same `src` keeps the error. A document link cannot
2537
+ report an expired link; reload it from your own signal. There is no
2538
+ `kind: 'video'`: a video is picked by its `type` (`video/*`).
2539
+
2540
+ **Storybook:** `Rich widgets/QuestionMediaView` · **Vue:** none (the `vue` issue #2020)
2541
+
2470
2542
  ### QuestionNavigator
2471
2543
 
2472
2544
  The numbered questions of a quiz or exam player: jump to one, see the current
@@ -140,6 +140,7 @@ Vue: the Redux helpers are Pinia store factories: `defineLocalStore`
140
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). |
141
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). |
142
142
  | `useDrafts<T>(storageKey, { serialize?, parse?, storage? })`, `clearDrafts(storageKey, storage?)` | Unsent values kept by key in `sessionStorage`: `{ get, set, drop, clear, keys, loaded }`. Read after the first render (no hydration mismatch), storage errors swallowed, `drop(key)` once saved; `clearDrafts` empties a store key without a hook (sign-out). See [below](#usedrafts). |
143
+ | `useUnsavedGuard(dirty, { block, … })`, `useDiscardGuard(dirty)`, `useReactRouterBlock` | Stop losing a dirty form: a "Leave without saving?" dialog on in-app navigation and on closing a dialog form, plus the browser's own `beforeunload` prompt. See [below](#useunsavedguard). |
143
144
  | `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. |
144
145
 
145
146
  ```tsx
@@ -350,6 +351,68 @@ export function Answer({ save }: { save: (text: string) => Promise<void> }) {
350
351
 
351
352
  Storybook: `Guides/Hooks/useDrafts` (remount restores, `drop`, key change).
352
353
 
354
+ ### `useUnsavedGuard`
355
+
356
+ Blocks in-app navigation while a form is dirty (an `UnsavedDialog`: "Leave
357
+ without saving?", **Stay** focused so Enter keeps the work) and, while dirty,
358
+ asks the browser to confirm closing or reloading the tab. The core is
359
+ router-agnostic: the router comes in through a `block` adapter, a hook that
360
+ takes `shouldBlock({ currentLocation, nextLocation })` and returns
361
+ `{ state, proceed, reset }`. `useReactRouterBlock` (React Router's
362
+ `useBlocker`) is the ready one. It needs a data router: under `<BrowserRouter>`
363
+ it throws, so use `createBrowserRouter` + `RouterProvider`.
364
+
365
+ ```tsx
366
+ import { useUnsavedGuard, useReactRouterBlock } from '@nxgt/material/hooks';
367
+
368
+ const { release, dialog } = useUnsavedGuard(form.formState.isDirty, {
369
+ block: useReactRouterBlock,
370
+ });
371
+ async function onSubmit(values: Values) {
372
+ await save(values);
373
+ release(); // synchronous: the navigation below is not blocked
374
+ navigate('/items');
375
+ }
376
+ return <form onSubmit={form.handleSubmit(onSubmit)}>{/* fields */}{dialog}</form>;
377
+ ```
378
+
379
+ | Option | Effect |
380
+ | --- | --- |
381
+ | `block` | the router adapter (required) |
382
+ | `leaves(current, next)` | what counts as leaving; default: the pathname **or the query string** differs (a filter or period param remounts most pages). A hash-only change is never a leave |
383
+ | `ignoreSearch` | only a pathname change counts: for a multi-step form whose steps are `?step=2` |
384
+ | `onBlock()` | `boolean \| Promise<boolean>` run before the dialog: resolve `true` when nothing is left to lose (pending saves flushed) and the navigation goes through silently; `false` or a throw asks |
385
+ | `title`, `description`, `stayLabel`, `leaveLabel` | the dialog's texts (`material.unsaved.*`, en and fr) |
386
+
387
+ - **`dirty` is a boolean or a function.** A function, such as
388
+ `() => grid.hasPendingChanges()`, is read when the navigation or the unload
389
+ happens, so a keystroke just before the click is not missed.
390
+ - **`release()`** is one-shot: the next navigation that really leaves (a
391
+ hash-only change, or a query change under `ignoreSearch`, does not count)
392
+ passes, and the tab unload prompt stays quiet until then; the guard then
393
+ re-arms by itself. A release no navigation consumed expires when the current
394
+ task ends, so call it right before `navigate()`; a later edit is guarded
395
+ again. A boolean `dirty` going true, false, true between `release()` and
396
+ `navigate()` re-arms it too. A submit on the same page is not a navigation
397
+ and is never blocked.
398
+ - **Stay** resets the blocker; **Leave** proceeds.
399
+ - `isLeaving(current, next, { ignoreSearch? })` and
400
+ `shouldBlockNavigation(…)` are the pure comparisons, for your own predicate
401
+ or a test.
402
+ - **Another router.** Write the adapter in a few lines, for example
403
+ `const useMyBlock: BlockAdapter = (shouldBlock) => { const b = useMyRouterBlocker({ shouldBlockFn: ({ current, next }) => shouldBlock({ currentLocation: current.location, nextLocation: next.location }) }); return { state: b.status, proceed: b.proceed, reset: b.reset }; }`.
404
+ - **A one-time secret** (shown once, never saved) does not fit a dialog: ask
405
+ for an explicit "I saved them" step and call `release()` after it.
406
+
407
+ `useDiscardGuard(dirty)` is the twin for a form inside a dialog or a sheet:
408
+ it returns `{ close(action), dialog }`; route the close button, Escape and the
409
+ overlay click through `close(() => setOpen(false))` and it runs `action` at once
410
+ when clean, after **Leave** when dirty. Compare with [`useDrafts`](#usedrafts):
411
+ drafts keep what was typed, the guard asks before it is dropped; they combine.
412
+
413
+ Storybook: `Guides/Hooks/useUnsavedGuard` (leave, stay, release after save,
414
+ query change, flush, dialog form). No Vue twin yet (`vue` issue #2019).
415
+
353
416
  ## `@nxgt/material/i18n`
354
417
 
355
418
  | Export | What it does |
@@ -620,15 +620,16 @@ descendant of it: an element that reads `--nxgt-tone-container` needs its own
620
620
  ## The active entry of the sidebar and the rail
621
621
 
622
622
  The selected `SidebarMenuItem` (and so the `AppSidebar` and `ActivityLayout`
623
- rail and menu sheet) is drawn with three tokens, in light and in dark:
623
+ rail and menu sheet) is drawn with four tokens, in light and in dark:
624
624
 
625
625
  | Token | Tailwind | Default, light | Default, dark |
626
626
  | --- | --- | --- | --- |
627
627
  | `--sidebar-active` | `bg-sidebar-active` | primary at 15 % | primary at 30 % |
628
628
  | `--sidebar-active-hover` | `bg-sidebar-active-hover` | primary at 20 % | primary at 35 % |
629
629
  | `--sidebar-active-foreground` | `text-sidebar-active-foreground` | `oklch(from var(--primary) calc(l * 0.85) c h)`, 4.5:1 or more on the pill for every theme preset | `oklch(from var(--primary) 0.72 0.15 h)`, 6.3:1 or more on the pill |
630
+ | `--sidebar-active-edge` | `bg-sidebar-active-edge` | the value of `--sidebar-active-foreground` | the value of `--sidebar-active-foreground` |
630
631
 
631
- The pill is the icon's (collapsed rail) or the whole row (expanded), a pill in the collapsed rail and, in the expanded row, an `--nxgt-item-radius` (6px) shape that carries the 3px start bar every chosen row has, drawn in `--sidebar-active-foreground` (a `Highlight` bar in forced colours); the
632
+ The pill is the icon's (collapsed rail) or the whole row (expanded), a pill in the collapsed rail and, in the expanded row, an `--nxgt-item-radius` (6px) shape that carries the 3px start bar every chosen row has, drawn in `--sidebar-active-edge` (a `Highlight` bar in forced colours); the
632
633
  foreground is the label and the icon, whose duotone layers both follow it (in
633
634
  dark too: the icon of `AppSidebar` is the lifted lavender like its label). The
634
635
  label is read on the pill (expanded) or on the sidebar (collapsed rail); the
@@ -639,9 +640,18 @@ itself in light and dark, as both the light darkening and the dark lift need
639
640
 
640
641
  The defaults are derived where the entry is drawn, from the `--primary` it
641
642
  sits under, so a `--primary` set on a wrapper recolours the rail inside it.
642
- The three tokens are not declared on `:root`: setting one replaces its
643
+ The four tokens are not declared on `:root`: setting one replaces its
643
644
  default wherever it is set.
644
645
 
646
+ `--sidebar-active-edge` recolours the start bar alone; unset, the bar follows
647
+ the foreground:
648
+
649
+ ```css
650
+ :root {
651
+ --sidebar-active-edge: oklch(0.8 0.15 90);
652
+ }
653
+ ```
654
+
645
655
  Set them after importing the stylesheet, under the same selectors as the
646
656
  theme, for example a rail in the `maths` colour:
647
657
 
@@ -8,7 +8,9 @@
8
8
  * follow a `--primary` set below :root. To theme the rail set
9
9
  * `--sidebar-active`, `--sidebar-active-hover` and
10
10
  * `--sidebar-active-foreground` (all three, or the first two and the
11
- * foreground); the derived colours are not declared here, on purpose. */
11
+ * foreground); the derived colours are not declared here, on purpose.
12
+ * `--sidebar-active-edge` recolours the start bar alone; unset, the bar is
13
+ * the foreground (`--sidebar-active-edge: gold` changes nothing else). */
12
14
  --nxgt-sidebar-active-share: 15%;
13
15
  /* Light: primary itself reads 4.5:1 on the page but not on its own 15 % pill
14
16
  * for every preset (corail 3.7:1), so the label is primary darkened to 85 %
@@ -71,4 +73,14 @@
71
73
  var(--nxgt-sidebar-active-c, c) h
72
74
  )
73
75
  );
76
+ --color-sidebar-active-edge: var(
77
+ --sidebar-active-edge,
78
+ var(
79
+ --sidebar-active-foreground,
80
+ oklch(
81
+ from var(--primary) var(--nxgt-sidebar-active-l, l)
82
+ var(--nxgt-sidebar-active-c, c) h
83
+ )
84
+ )
85
+ );
74
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/material",
3
- "version": "3.62.0",
3
+ "version": "3.63.0",
4
4
  "license": "MIT",
5
5
  "bugs": {
6
6
  "email": "stevegires@gmail.com"