@nxgt/material 3.60.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.
- package/CHANGELOG.md +44 -0
- package/README.md +3 -2
- package/dist/alert-dialog-RHm6WtQj.js +189 -0
- package/dist/{async-slice-HJY_VJjD.js → async-slice-DmX59NyA.js} +3 -4
- package/dist/{button-7BuUCGRm.js → button-DfS_amfQ.js} +1 -1
- package/dist/charts.js +3 -3
- package/dist/{chip-kQOrHlDX.js → chip-Cbndukko.js} +2 -2
- package/dist/{code-editor-Ckc9TTCM.js → code-editor-B0tN8Ha_.js} +11 -11
- package/dist/code-editor.js +54 -37
- package/dist/{code-language-items-rCPB0Xee.js → code-language-items-andgHKrM.js} +2 -2
- package/dist/components/ui/layout/sidebar/sidebar-tokens.css +13 -1
- package/dist/{components-BSM-h5IQ.js → components-Cm2doOJS.js} +13090 -12957
- package/dist/components.js +28 -26
- package/dist/dialog-Bd7Hid2L.js +219 -0
- package/dist/dnd.js +1 -1
- package/dist/{dropdown-menu-CiobN1wi.js → dropdown-menu-_SyVmSuy.js} +1 -1
- package/dist/editor.js +707 -706
- package/dist/{empty-state-DA517HAb.js → empty-state-D-2ZMXXJ.js} +2 -2
- package/dist/escape-owner-DpaP0lm1.js +19 -0
- package/dist/{field-blur-DdvY0NQQ.js → field-blur-DyOByjzw.js} +1 -1
- package/dist/{field-blur-context-DbJQZkEB.js → field-blur-context-DPe5_Nv5.js} +1 -1
- package/dist/hooks-a7_exKWS.js +329 -0
- package/dist/hooks.js +8 -102
- package/dist/{hover-card-DPs9huSs.js → hover-card-D_0smKBQ.js} +2 -2
- package/dist/i18n.js +8 -10
- package/dist/{icon-badge-C646JTOI.js → icon-badge-CBCU3ySF.js} +14 -14
- package/dist/{icon-button-DvP0Gy3j.js → icon-button-hKHsWTAt.js} +1 -1
- package/dist/illustrations-game.js +2 -0
- package/dist/lib/components/ui/display/list-tile/list-tile.d.ts +6 -0
- package/dist/lib/components/ui/display/list-tile/partials/list-tile-classes.d.ts +3 -1
- package/dist/lib/components/ui/display/list-tile/partials/list-tile-line.d.ts +1 -1
- package/dist/lib/components/ui/display/list-tile/partials/list-tile-toggle.d.ts +1 -1
- package/dist/lib/components/ui/feedback/index.d.ts +1 -0
- package/dist/lib/components/ui/feedback/unsaved-dialog/index.d.ts +1 -0
- package/dist/lib/components/ui/feedback/unsaved-dialog/unsaved-dialog.d.ts +20 -0
- package/dist/lib/components/ui/fields/contacts/placeholders.d.ts +1 -1
- package/dist/lib/components/ui/fields/textarea-field/textarea-footer.d.ts +2 -1
- package/dist/lib/components/ui/layout/app-shell/activity-layout.types.d.ts +6 -3
- package/dist/lib/components/ui/layout/app-shell/index.d.ts +1 -1
- package/dist/lib/components/ui/layout/app-shell/route-transition.d.ts +20 -2
- package/dist/lib/components/ui/layout/app-shell/shapes-clear-content.d.ts +2 -1
- package/dist/lib/components/ui/widgets/code-editor/configure-monaco.d.ts +2 -0
- package/dist/lib/components/ui/widgets/education/questions/index.d.ts +3 -1
- package/dist/lib/components/ui/widgets/education/questions/partials/question-content.d.ts +1 -1
- package/dist/lib/components/ui/widgets/education/questions/partials/question-top.d.ts +6 -0
- package/dist/lib/components/ui/widgets/education/questions/question-long-text.d.ts +5 -0
- package/dist/lib/components/ui/widgets/education/questions/question-media-content.d.ts +24 -0
- package/dist/lib/components/ui/widgets/education/questions/question-media-helpers.d.ts +11 -0
- package/dist/lib/components/ui/widgets/education/questions/question-media-view.d.ts +18 -0
- package/dist/lib/components/ui/widgets/education/questions/question-media-view.types.d.ts +32 -0
- package/dist/lib/components/ui/widgets/education/questions/question-options.d.ts +2 -2
- package/dist/lib/components/ui/widgets/education/questions/question-text.d.ts +1 -1
- package/dist/lib/components/ui/widgets/education/questions/question.d.ts +1 -1
- package/dist/lib/components/ui/widgets/education/questions/types.d.ts +38 -1
- package/dist/lib/components/ui/widgets/education/questions/use-reload-focus.d.ts +13 -0
- package/dist/lib/hooks/drafts-store.d.ts +4 -0
- package/dist/lib/hooks/index.d.ts +4 -0
- package/dist/lib/hooks/unsaved-guard-rules.d.ts +30 -0
- package/dist/lib/hooks/use-drafts.d.ts +75 -0
- package/dist/lib/hooks/use-react-router-block.d.ts +11 -0
- package/dist/lib/hooks/use-unsaved-guard/guard-core.d.ts +30 -0
- package/dist/lib/hooks/use-unsaved-guard/index.d.ts +3 -0
- package/dist/lib/hooks/use-unsaved-guard/types.d.ts +63 -0
- package/dist/lib/hooks/use-unsaved-guard/use-discard-guard.d.ts +15 -0
- package/dist/lib/hooks/use-unsaved-guard/use-unsaved-guard.d.ts +19 -0
- package/dist/lib/i18n/resources/en/en.d.ts +20 -0
- package/dist/lib/i18n/resources/en/material.json.d.ts +21 -1
- package/dist/lib/i18n/resources/fr/fr.d.ts +20 -0
- package/dist/lib/i18n/resources/fr/material.json.d.ts +21 -1
- package/dist/lib/i18n/resources/resources.d.ts +40 -0
- package/dist/lib/i18n/translator.d.ts +1 -1
- package/dist/lib/illustrations-game/index.d.ts +17 -0
- package/dist/lib.js +1 -1
- package/dist/main.js +42 -43
- package/dist/map.js +63 -63
- package/dist/material.css +1 -1
- package/dist/pdf-reader.js +1 -1
- package/dist/print.js +1 -2
- package/dist/{public-BYsrhyPW.js → public-D8JGp3PH.js} +823 -822
- package/dist/{rich-code-block-WJxDWPPY.js → rich-code-block-0R3bidsM.js} +5 -5
- package/dist/{sortable-provider-oPjlhlZo.js → sortable-provider-B75v53sB.js} +2 -2
- package/dist/{spinner-C5lObWXT.js → spinner-CAnpF3RK.js} +1 -1
- package/dist/theme-picker.js +2 -2
- package/dist/{theme-toggle-CPEq5JG-.js → theme-toggle-pQ5F602V.js} +1 -1
- package/dist/{toolbar-items-ByvXcMHm.js → toolbar-items-_ezD5B0r.js} +4 -4
- package/dist/translate-material-CvZK8_S6.js +96 -0
- package/dist/{stored-language-B-tiWZU2.js → translator-Caf-d96e.js} +2497 -8
- package/dist/{escape-owner-r57kuPjn.js → typography-CEK19bj8.js} +17 -34
- package/dist/{use-chat-composer-CzOVaIed.js → use-chat-composer-Dpvi_-CR.js} +5164 -5338
- package/dist/{use-heading-spy-BOtygPFu.js → use-heading-spy-mmcyEGmm.js} +186 -71
- package/dist/{use-material-translation-B5j3jZs7.js → use-material-translation-C_1cWRzW.js} +1 -2
- package/dist/use-return-focus-1ZIchrBM.js +269 -0
- package/docs/components/data-display.md +1 -0
- package/docs/components/feedback-and-status.md +23 -0
- package/docs/components/navigation-and-layout.md +5 -4
- package/docs/components/rich-widgets.md +73 -1
- package/docs/guides/app-shell.md +17 -0
- package/docs/guides/code-editor.md +8 -0
- package/docs/guides/csp.md +4 -0
- package/docs/guides/helpers.md +120 -0
- package/docs/guides/icon-motifs.md +3 -1
- package/docs/guides/illustrations.md +33 -1
- package/docs/guides/migrating-to-3.md +4 -2
- package/docs/guides/theming.md +13 -3
- package/docs/previews/feedback-status-illustration-game.webp +0 -0
- package/docs/previews/feedback-status-unsaveddialog.webp +0 -0
- package/docs/roadmap.md +1 -1
- package/docs/troubleshooting.md +9 -6
- package/lib/components/ui/layout/sidebar/sidebar-tokens.css +13 -1
- package/package.json +7 -2
- package/dist/dialog-yfPkLqUQ.js +0 -482
- package/dist/i18n-provider-BehR6cU7.js +0 -79
- package/dist/translate-material-tUaLZUmT.js +0 -20
- 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 };
|
|
@@ -967,6 +967,7 @@ A tile with an `href` ends with a discreet chevron after `trailing`: decorative
|
|
|
967
967
|
| `actions` | `ReactNode` | — | buttons at the end of the tile, outside the link, vertically centred (`data-slot="item-actions"`) |
|
|
968
968
|
| `chips` | `ReactNode` | — | tags after the text, in the compact record row (see below); inside the link, so decoration only; part of the row's accessible name, keep labels short |
|
|
969
969
|
| `variant` | `'tonal' \| 'divided'` | `'tonal'` | `divided`: flat rows with no tonal surface, a hover tint and the record-row layout; the hairline comes with `as="li"` |
|
|
970
|
+
| `inset` | `boolean` | `false` | With `variant="divided"`: 16px of inline padding (M3's list-item inset) in place of 4px, for a list inside a `Card`; the hairlines on the `<li>` still run edge to edge. Ignored by `tonal` |
|
|
970
971
|
| `as` | `'li'` | — | renders the tile inside a `<li>`, for a `<ul>` you own |
|
|
971
972
|
| `disabled` | `boolean` | — | disables the link or button, and the `actions` with it |
|
|
972
973
|
|
|
@@ -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
|
+

|
|
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
|

|
|
@@ -91,7 +91,7 @@ phone's menu sheet pad clear of `env(safe-area-inset-*)`, as classes; see the
|
|
|
91
91
|
| `asideSide` / `asideWidth` / `asideFrom` / `asideSticky` / `asideStack` | `'start' \| 'end'` / `'sm' \| 'md' \| 'lg' \| string` / `'lg' \| 'xl' \| '2xl'` / `boolean` / `'before' \| 'after'` | `'end'` / `'md'` / `'xl'` / `true` / `'after'` | `ActivityContent`: the panel's side, width (16, 20, 24rem), breakpoint, stickiness and stacking order (see the [app shell guide](../guides/app-shell.md#a-side-panel-and-a-readable-width)) |
|
|
92
92
|
| `align` | `'start' \| 'center'` | `'start'` | `ActivityLayout`, `AppSidebar`: the collapsed rail's entries sit right under the brand, or `'center'` for centred in the height below it |
|
|
93
93
|
| `backdrop` | `ReactNode \| false` | — | `ActivityLayout`: an exception to the theme's background (drawn by default; Lavande's is the built-in gradient and blobs); `false` draws a plain page. A node is a decorative background behind the frame: a `Background` preset from `@nxgt/material/backgrounds`. Rendered in an `inert`, `aria-hidden` layer as wide as the content column and one viewport high, fixed in the viewport while the content scrolls; the chrome (app bar, breadcrumb, tabs strip) is lightly translucent and frosted over it (80%, 87% for the sidebar, breadcrumb and tabs strip, 92% and no blur under `prefers-reduced-transparency` or without `backdrop-filter`), and the lists, tables and cards stay opaque; `--nxgt-backdrop-chrome` sets the chrome's fill and `--nxgt-backdrop-surface` the lists'. See [Use a background preset behind the shell](../guides/app-shell.md#use-a-background-preset-behind-the-shell) |
|
|
94
|
-
| `routeTransition` | `boolean \| Omit<ViewTransitionProps, 'children' \| 'ref'>` | `true` | `ActivityLayout`: React's `<ViewTransition>` around the content region, so a navigation cross-fades it. `false` renders none (your app has its own boundary); an object passes `default` / `update` classes, `name`, callbacks through. See [Route transitions](../guides/app-shell.md#route-transitions) |
|
|
94
|
+
| `routeTransition` | `boolean \| Omit<ViewTransitionProps, 'children' \| 'ref'>` | `true` | `ActivityLayout`: React's `<ViewTransition>` around the content region, so a navigation cross-fades it (by default only when the pathname changes, not on a search change, a fetcher or a revalidation). `false` renders none (your app has its own boundary); an object passes `default` / `update` classes, `name`, callbacks through. `ROUTE_TRANSITION_CLASS` (`'nxgt-route'`) is exported for nested boundaries. See [Route transitions](../guides/app-shell.md#route-transitions) |
|
|
95
95
|
|
|
96
96
|
The active entry is the longest match across the whole menu, segment-aware.
|
|
97
97
|
`AppBreadcrumb` reads `handle.breadcrumb` from `useMatches()`, so the shell
|
|
@@ -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
|
|
308
|
-
`--sidebar-active-foreground`
|
|
309
|
-
the
|
|
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
|
package/docs/guides/app-shell.md
CHANGED
|
@@ -768,6 +768,23 @@ pass `viewTransition` or wrap a navigation in `flushSync`.
|
|
|
768
768
|
</ActivityLayout>
|
|
769
769
|
```
|
|
770
770
|
|
|
771
|
+
By default the region fades only when the **pathname** changes. React Router
|
|
772
|
+
runs a `?tab=` change, a list search, a fetcher submit and a revalidation as
|
|
773
|
+
untyped transitions too; they update the page without fading it (and typing in
|
|
774
|
+
a search field is not interrupted). An explicit value is used as is, with no
|
|
775
|
+
gate. To fade on every transition, as before, give the class yourself:
|
|
776
|
+
`routeTransition={{ default: 'nxgt-route' }}`. An app that gated the update on
|
|
777
|
+
the pathname with a ref (`update: pathnameChanged ? 'nxgt-route' : 'none'`) can
|
|
778
|
+
drop it. A nested `<ViewTransition>` takes the same timing from the exported
|
|
779
|
+
`ROUTE_TRANSITION_CLASS` (`'nxgt-route'`):
|
|
780
|
+
|
|
781
|
+
```tsx
|
|
782
|
+
import { ViewTransition } from 'react';
|
|
783
|
+
import { ROUTE_TRANSITION_CLASS } from '@nxgt/material/components';
|
|
784
|
+
|
|
785
|
+
<ViewTransition default={ROUTE_TRANSITION_CLASS}>{tabs}</ViewTransition>
|
|
786
|
+
```
|
|
787
|
+
|
|
771
788
|
Tune it with an object, the props of React's `<ViewTransition>` (`default`,
|
|
772
789
|
`update`, `name`, `onEnter`…), or turn it off:
|
|
773
790
|
|
|
@@ -141,6 +141,14 @@ JSON, TypeScript/JavaScript, HTML and CSS each need their own worker (the
|
|
|
141
141
|
generic one throws "Missing requestHandler" for theirs); a worker you leave
|
|
142
142
|
out only matters if an editor uses that language.
|
|
143
143
|
|
|
144
|
+
`configureMonaco` also gives the `<style>` elements Monaco injects (the
|
|
145
|
+
editor, context menus, widgets) the page's CSP nonce (`getCspNonce()`, read when
|
|
146
|
+
the element is created), so a `style-src-elem 'nonce-…'` policy accepts them
|
|
147
|
+
and you need no `document.createElement` patch of your own. It changes nothing
|
|
148
|
+
when the page has no nonce; see the [CSP guide](csp.md). Bundling the workers
|
|
149
|
+
as above keeps `worker-src 'self'` enough (Vite's `?worker` emits them as
|
|
150
|
+
files); the CDN default needs `worker-src blob:`.
|
|
151
|
+
|
|
144
152
|
`monaco-editor` (0.56 and later: the paths above) is then a dependency of
|
|
145
153
|
your app; this library's own stories run Monaco this way. Or serve Monaco's files from
|
|
146
154
|
your origin: copy its `min/vs` folder to your public files and call
|
package/docs/guides/csp.md
CHANGED
|
@@ -57,6 +57,10 @@ configureMonaco({ paths: { vs: '/monaco/vs' } });
|
|
|
57
57
|
Or bundle Monaco with your app and pass the instance:
|
|
58
58
|
[code editor guide](code-editor.md#self-hosting-monaco).
|
|
59
59
|
|
|
60
|
+
`configureMonaco` also puts the page nonce on the `<style>` elements Monaco
|
|
61
|
+
creates, so `style-src-elem 'self' 'nonce-…'` is enough for the editor's own
|
|
62
|
+
styles. Bundle its workers as the guide shows to keep `worker-src 'self'`.
|
|
63
|
+
|
|
60
64
|
### Packages the app imports itself
|
|
61
65
|
|
|
62
66
|
`zod` and `@monaco-editor/react` are dependencies of the library, and
|
package/docs/guides/helpers.md
CHANGED
|
@@ -139,6 +139,8 @@ Vue: the Redux helpers are Pinia store factories: `defineLocalStore`
|
|
|
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
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
|
+
| `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). |
|
|
142
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. |
|
|
143
145
|
|
|
144
146
|
```tsx
|
|
@@ -293,6 +295,124 @@ Outside React, `createSubmitGuard()` from `@nxgt/material-core/submit-guard`
|
|
|
293
295
|
`busySettler(guard)` returns the function to call with each value of a busy
|
|
294
296
|
flag. Storybook: `Guides/Hooks/useSubmitOnce`.
|
|
295
297
|
|
|
298
|
+
### `useDrafts`
|
|
299
|
+
|
|
300
|
+
Long answers are lost when the reader jumps to another step or reloads. The
|
|
301
|
+
hook keeps unsent values by key in the tab's `sessionStorage`; `storageKey`
|
|
302
|
+
names the store (one per form) and each field has its own key inside it.
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
import { Textarea } from '@nxgt/material/components';
|
|
306
|
+
import { useDrafts } from '@nxgt/material/hooks';
|
|
307
|
+
|
|
308
|
+
export function Answer({ save }: { save: (text: string) => Promise<void> }) {
|
|
309
|
+
const drafts = useDrafts<string>('quiz-1');
|
|
310
|
+
return (
|
|
311
|
+
<form
|
|
312
|
+
onSubmit={async (event) => {
|
|
313
|
+
event.preventDefault();
|
|
314
|
+
await save(drafts.get('q3') ?? '');
|
|
315
|
+
drafts.drop('q3'); // saved: the draft goes
|
|
316
|
+
}}
|
|
317
|
+
>
|
|
318
|
+
<Textarea
|
|
319
|
+
value={drafts.get('q3') ?? ''}
|
|
320
|
+
onChange={(event) => drafts.set('q3', event.target.value)}
|
|
321
|
+
/>
|
|
322
|
+
<p>{drafts.keys.length} unsaved</p>
|
|
323
|
+
</form>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
- **SSR-safe.** The first render, on the server and on the client, has no
|
|
329
|
+
drafts; they are read in an effect and `loaded` turns true.
|
|
330
|
+
- **Storage errors are swallowed.** A private window, a blocked or full
|
|
331
|
+
store, or a damaged entry reads and writes as no draft.
|
|
332
|
+
- **Written at once.** `set`, `drop` and `clear` write the store in the call,
|
|
333
|
+
so a `drop` or `clear` before `loaded` wins over the stored copy. When no
|
|
334
|
+
draft is left the store key is removed. `keys` lists the unsaved ones.
|
|
335
|
+
- **Values that are not strings.** The default serialiser is JSON; pass
|
|
336
|
+
`serialize` and `parse` (throw or return `undefined` to ignore an entry).
|
|
337
|
+
- **Another store.** `storage` takes a getter, preferred because nothing
|
|
338
|
+
touches `window` during render: `useDrafts('quiz-1', { storage: () => localStorage })`.
|
|
339
|
+
A store object is accepted too.
|
|
340
|
+
- **A new `storageKey`** starts from that key's drafts (`loaded` false until
|
|
341
|
+
they are read); the old key's drafts stay in the store under the old key.
|
|
342
|
+
- **Two instances on one key** read at mount and merge on write; they do not
|
|
343
|
+
update each other live. Share one instance (props or context) when both
|
|
344
|
+
must follow.
|
|
345
|
+
- **Sign-out.** `clearDrafts('quiz-1')` (same optional `storage`) empties the
|
|
346
|
+
store key without a mounted hook; call it where the session ends.
|
|
347
|
+
- **Why not `react-use`'s `useSessionStorage`?** It holds one value per hook
|
|
348
|
+
call, reads storage during the first render (a hydration mismatch) and
|
|
349
|
+
throws when storage is blocked. `useDrafts` holds many keys in one store key,
|
|
350
|
+
renders the same on server and client, and never throws.
|
|
351
|
+
|
|
352
|
+
Storybook: `Guides/Hooks/useDrafts` (remount restores, `drop`, key change).
|
|
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
|
+
|
|
296
416
|
## `@nxgt/material/i18n`
|
|
297
417
|
|
|
298
418
|
| Export | What it does |
|
|
@@ -138,7 +138,7 @@ An `Illustration` draws a hero icon on a Material 3 Expressive plate, with two
|
|
|
138
138
|
to four satellites. There are twelve base scenes (`empty`, `no-results`,
|
|
139
139
|
`not-found`, `error`, `offline`, `success`, `welcome`, `forbidden`,
|
|
140
140
|
`maintenance`, `invitation`, `sign-in`, `empty-inbox`) and two domain sets of
|
|
141
|
-
ten, **health** and **education**, each its own subpath so an app that does not
|
|
141
|
+
ten, **health** and **education**, plus **game** (eleven scenes for games and events), each its own subpath so an app that does not
|
|
142
142
|
import one does not carry it:
|
|
143
143
|
|
|
144
144
|
<img src="../previews/feedback-status-illustration.webp" width="480" alt="The twelve base illustration scenes">
|
|
@@ -147,6 +147,8 @@ import one does not carry it:
|
|
|
147
147
|
|
|
148
148
|
<img src="../previews/feedback-status-illustration-education.webp" width="480" alt="The ten education scenes">
|
|
149
149
|
|
|
150
|
+
<img src="../previews/feedback-status-illustration-game.webp" width="480" alt="The eleven game scenes">
|
|
151
|
+
|
|
150
152
|
- `IllustrationDefaults` is opt-in: without it (and without a `hero`) an
|
|
151
153
|
`EmptyState` or `ErrorState` draws the same built-in art as before. An
|
|
152
154
|
`illustration` prop wins over it, `illustration={false}` hides the media, and
|