@nxgt/material 3.62.0 → 3.64.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 +33 -0
- package/README.md +1 -1
- 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 +5 -5
- 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-TDDUQ1Y0.js → components-2kTVPsMg.js} +15801 -15541
- 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 +3 -3
- 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/lib/components/ui/actions/load-more-footer/load-more-footer.d.ts +11 -2
- package/dist/lib/components/ui/actions/load-more-footer/use-footer-focus.d.ts +15 -0
- package/dist/lib/components/ui/actions/load-more-footer/use-last-page.d.ts +9 -0
- package/dist/lib/components/ui/collections/filter/bar/partials/filter-inline-chips.d.ts +11 -0
- package/dist/lib/components/ui/collections/filter/bar/partials/filter-inline-list.d.ts +6 -0
- package/dist/lib/components/ui/collections/server-table/partials/server-table-grid-paging.d.ts +9 -0
- package/dist/lib/components/ui/collections/server-table/partials/server-table-load-more.d.ts +17 -0
- package/dist/lib/components/ui/collections/server-table/types-paging.d.ts +10 -0
- 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/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 +52 -1
- package/dist/lib/components/ui/widgets/education/questions/use-reload-focus.d.ts +13 -0
- package/dist/lib/hooks/index.d.ts +3 -0
- package/dist/lib/hooks/unsaved-guard-rules.d.ts +30 -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.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/buttons-and-actions.md +2 -2
- package/docs/components/data-table-filter-and-lists.md +96 -0
- package/docs/components/feedback-and-status.md +23 -0
- package/docs/components/navigation-and-layout.md +4 -3
- package/docs/components/rich-widgets.md +75 -1
- package/docs/guides/helpers.md +63 -0
- package/docs/guides/theming.md +13 -3
- package/docs/previews/feedback-status-unsaveddialog.webp +0 -0
- package/docs/roadmap.md +4 -0
- package/lib/components/ui/layout/sidebar/sidebar-tokens.css +13 -1
- package/package.json +2 -2
- package/dist/dialog-yfPkLqUQ.js +0 -482
- package/dist/hooks-CGnqFngB.js +0 -187
- 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 };
|
|
@@ -975,7 +975,7 @@ buttons.
|
|
|
975
975
|
|
|
976
976
|

|
|
977
977
|
|
|
978
|
-
The footer of a paged list: a "Showing N of M" line (a polite live region), the load button and, when the next page failed, an error alert whose "Try again" calls `onLoad`. The rows already shown stay on screen. It renders nothing when there is no next page.
|
|
978
|
+
The footer of a paged list: a "Showing N of M" line (a polite live region), the load button and, when the next page failed, an error alert whose "Try again" calls `onLoad`. The rows already shown stay on screen. It renders nothing when there is no next page. When the last page arrives it keeps the status line (so the final "Showing 85 of 85" is announced) and, if the button had the focus as it went away, takes the focus itself (the footer is `tabIndex={-1}`); a list that never had a next page renders nothing, and a list that restarts smaller (a new filter, `shown` or `total` dropping) drops the line. That is a heuristic: a restart at the same size or larger (a new query whose first page ends the list, `total` unknown) cannot be told from a real end, and `shown === 0` (a list being rebuilt) is ignored. Give a standalone footer a `key` that names the query (the search, filters and sort, not the page), `<LoadMoreFooter key={queryIdentity} … />`, so a new query starts it afresh; `ServerTable` does it for you.
|
|
979
979
|
|
|
980
980
|
```tsx
|
|
981
981
|
import { LoadMoreFooter } from '@nxgt/material/components';
|
|
@@ -994,7 +994,7 @@ import { LoadMoreFooter } from '@nxgt/material/components';
|
|
|
994
994
|
| --- | --- | --- | --- |
|
|
995
995
|
| `shown` | `number` | — | items shown now |
|
|
996
996
|
| `total` | `number` | — | items in all; leave it out when unknown, the line then reads "20 shown" |
|
|
997
|
-
| `hasMore` | `boolean` | — | `false` renders nothing, not even the count |
|
|
997
|
+
| `hasMore` | `boolean` | — | `false` renders nothing, not even the count, unless a next page existed before: the status line then stays after the last page (`showCount={false}` drops it) |
|
|
998
998
|
| `loading` | `boolean` | `false` | the button (or the retry) spins and ignores clicks |
|
|
999
999
|
| `failed` | `boolean` | `false` | an error alert with a tonal "Try again" replaces the button |
|
|
1000
1000
|
| `showCount` | `boolean` | `true` | `false` hides the "Showing N of M" line (keep it for a list whose header already announces the count in its own `role="status"`); the button and the failed alert stay |
|
|
@@ -1027,6 +1027,11 @@ Its props are `ServerTableProps<T>`; `filter` is a `ServerTableFilter`,
|
|
|
1027
1027
|
| `actions` | `ReactNode` | — | the page's actions, at the band's end |
|
|
1028
1028
|
| `bandDensity` | `'comfortable' \| 'compact'` | `'comfortable'` | (the same prop as [`Filter`](#a-compact-band-for-a-list-inside-a-page-section)'s bar) `'compact'` halves the room above the band (16px to 8px) and under it (12px to 6px), for a list under a sticky app bar and a breadcrumb; the controls keep their size (the Filter's own `density` is the controls') |
|
|
1029
1029
|
| `pageControls` | `'labelled' \| 'icon'` | `'labelled'` | the footer's Previous / Next: `'icon'` is icon-only, with a tooltip and the names "Previous page" / "Next page" (`DataTable`'s default is unchanged); on a coarse pointer the icon controls are 44x44 boxes (still painted 32px) |
|
|
1030
|
+
| `footer` | `'paging' \| 'load-more'` | `'paging'` | `'load-more'` replaces Previous / Next with [`LoadMoreFooter`](./feedback-and-status.md): "Showing N of M" (`rows.length` of `totalCount`), the load button while `pageInfo.hasNextPage`, a failed alert with a retry. The page keeps the rows loaded so far in `rows` and fetches the next itself: the cursor params are not used. The selection count stays; `hideOnSinglePage` still hides the footer |
|
|
1031
|
+
| `onLoadMore` | `() => void` | — | with `footer="load-more"` (required): fetches the next page and appends it to `rows`; the retry of a failed page calls it too |
|
|
1032
|
+
| `isLoadingMore` | `boolean` | `false` | with `footer="load-more"`: the next page is being fetched, the button spins and ignores clicks |
|
|
1033
|
+
| `loadMoreFailed` | `boolean` | `false` | with `footer="load-more"`: the next page failed, an alert with "Try again" replaces the button, the rows stay |
|
|
1034
|
+
| `loadMoreLabel` | `string` | "Load more" | with `footer="load-more"`: the button's label |
|
|
1030
1035
|
| `hideOnSinglePage` | `boolean` | `false` | hides the footer's range and Previous / Next when the result is one page (no previous, no next), in the table, cards and tiles layouts; the count is still announced by the live region but no longer shown, so put it in `description` or keep the footer. With `bulkActions` the footer stays for "N rows selected", without its pager |
|
|
1031
1036
|
| `bandActions` | `'stacked' \| 'inline'` | `'stacked'` | with `filter`, where `actions` sit below 768px: `'stacked'` gives them a row above the search; `'inline'` keeps them on the search's row (the search takes the room left), for a lone icon action such as a "+". An action wider than 3.5rem wraps under the search. From 768px both are the same. With a cards or tiles list on a phone, neither applies: [the two-row header](#the-phone-header-of-a-list-in-cards-or-tiles) puts the actions on its second row |
|
|
1032
1037
|
| `filterRef` | `Ref<ServerTableFilterHandle>` | — | with `filter`: `filterRef.current.focus()` focuses the Filter's first control, the search, else the first quick filter, "Add filter" or "Filters (N)" the width shows |
|
|
@@ -1830,6 +1835,97 @@ its lock only. The words are `material.filter.default.*`.
|
|
|
1830
1835
|
<ServerTable filter={{ model: EVENT_FILTERS, defaults: { mine: ['1'] } }} {...table} />
|
|
1831
1836
|
```
|
|
1832
1837
|
|
|
1838
|
+
#### Load more instead of paging
|
|
1839
|
+
|
|
1840
|
+
A catalogue or a history that grows keeps its rows and asks for the next page
|
|
1841
|
+
(`footer="load-more"`). The page owns the accumulated rows: any change of
|
|
1842
|
+
`params` (a search, a filter chip, a sort) is a new query, which **drops the
|
|
1843
|
+
rows, the failure and any request still in flight, and starts again with the
|
|
1844
|
+
first page**; `onLoadMore` **appends only the next page**.
|
|
1845
|
+
|
|
1846
|
+
```tsx
|
|
1847
|
+
function Quizzes() {
|
|
1848
|
+
const [params, setParams] = useSearchParams();
|
|
1849
|
+
const query = params.toString();
|
|
1850
|
+
const [rows, setRows] = useState<Quiz[]>([]);
|
|
1851
|
+
const [page, setPage] = useState({ cursor: null as string | null, hasNext: false, total: 0 });
|
|
1852
|
+
const [isLoadingMore, setLoadingMore] = useState(false);
|
|
1853
|
+
const [failed, setFailed] = useState(false);
|
|
1854
|
+
// Bumped by every new query: an answer of an older one is dropped.
|
|
1855
|
+
const generation = useRef(0);
|
|
1856
|
+
|
|
1857
|
+
useEffect(() => {
|
|
1858
|
+
const mine = ++generation.current;
|
|
1859
|
+
setRows([]); setFailed(false); setLoadingMore(false);
|
|
1860
|
+
fetchQuizzes(new URLSearchParams(query), null).then((first) => {
|
|
1861
|
+
if (mine !== generation.current) return;
|
|
1862
|
+
setRows(first.nodes);
|
|
1863
|
+
setPage({ cursor: first.endCursor, hasNext: first.hasNextPage, total: first.totalCount });
|
|
1864
|
+
});
|
|
1865
|
+
}, [query]);
|
|
1866
|
+
|
|
1867
|
+
const loadMore = async () => {
|
|
1868
|
+
const mine = generation.current;
|
|
1869
|
+
setFailed(false); setLoadingMore(true);
|
|
1870
|
+
try {
|
|
1871
|
+
const next = await fetchQuizzes(new URLSearchParams(query), page.cursor);
|
|
1872
|
+
if (mine !== generation.current) return; // the query changed meanwhile
|
|
1873
|
+
setRows((all) => [...all, ...next.nodes]); // only the next page
|
|
1874
|
+
setPage({ cursor: next.endCursor, hasNext: next.hasNextPage, total: next.totalCount });
|
|
1875
|
+
} catch {
|
|
1876
|
+
if (mine === generation.current) setFailed(true); // the rows stay, "Try again"
|
|
1877
|
+
} finally {
|
|
1878
|
+
if (mine === generation.current) setLoadingMore(false);
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
|
|
1882
|
+
return (
|
|
1883
|
+
<ServerTable
|
|
1884
|
+
params={params} onParamsChange={setParams}
|
|
1885
|
+
rows={rows} totalCount={page.total} pageInfo={{ hasNextPage: page.hasNext }}
|
|
1886
|
+
pageSize={20} getRowId={(q) => q.id} columns={columns}
|
|
1887
|
+
filter={{ model: QUIZ_FILTERS }} // a chips-single field with inline: true
|
|
1888
|
+
empty={{ none: 'No quiz yet', noMatch: 'No quiz matches' }}
|
|
1889
|
+
footer="load-more" onLoadMore={loadMore}
|
|
1890
|
+
isLoadingMore={isLoadingMore} loadMoreFailed={failed}
|
|
1891
|
+
/>
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
```
|
|
1895
|
+
|
|
1896
|
+
The footer is [`LoadMoreFooter`](./buttons-and-actions.md#loadmorefooter): once
|
|
1897
|
+
the last page is in, its status line stays ("Showing 42 of 42", announced) and,
|
|
1898
|
+
if the button had the focus as it went away, the footer takes it
|
|
1899
|
+
(`tabIndex={-1}`). `ServerTable` restarts the footer by itself whenever `params` change (the search, filters and sort, not a page), so a new query never keeps the old list's last line.
|
|
1900
|
+
`footer="load-more"` without `onLoadMore` warns in development.
|
|
1901
|
+
|
|
1902
|
+
#### An inline chip group
|
|
1903
|
+
|
|
1904
|
+
A `chips-single` or `chips-multiple` field declared `inline: true` shows its
|
|
1905
|
+
options as a chip group in the bar itself, at every width, instead of a quick
|
|
1906
|
+
pill that opens an editor. It suits a short list of choices that is the main
|
|
1907
|
+
way to narrow a list (the topics of a catalogue).
|
|
1908
|
+
|
|
1909
|
+
```ts
|
|
1910
|
+
{ id: 'topic', type: 'chips-single', label: 'Topic', options: TOPICS, inline: true }
|
|
1911
|
+
```
|
|
1912
|
+
|
|
1913
|
+
- **Pressing** a chip sets the condition in one query change (back on the
|
|
1914
|
+
first page); pressing the chosen chip of a single group clears it. A
|
|
1915
|
+
multiple group toggles each chip. The group is a `radiogroup` (or a `group`
|
|
1916
|
+
of checkboxes) named by the field's label.
|
|
1917
|
+
- **A plain filter.** It is counted, in the URL like any field, and cleared by
|
|
1918
|
+
"Clear all". It has no chip of its own (the group shows it) and is not in
|
|
1919
|
+
"Add filter" nor among the quick pills; the phone sheet still lists it.
|
|
1920
|
+
- **Rules.** `defineFilterModel` throws without `options`, with another type,
|
|
1921
|
+
with a `default`, with `scope` or with `addable: false`. A condition the
|
|
1922
|
+
group cannot draw (another operator, or a value that is not an
|
|
1923
|
+
option; the URL codec already drops both, so this guards a query written by
|
|
1924
|
+
code) keeps its normal chip with its ✕, so an active filter
|
|
1925
|
+
is never hidden; `inlineShows(field, condition)` is the rule.
|
|
1926
|
+
- **Core.** `isInline(field)`, `inlineShows(field, condition)`; `bandQuickFields` and the bar's `quickFields`
|
|
1927
|
+
list inline fields after the scope ones.
|
|
1928
|
+
|
|
1833
1929
|
#### A scope field
|
|
1834
1930
|
|
|
1835
1931
|
A list scoped by a value the user changes but never clears (the period or the
|
|
@@ -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
|

|
|
@@ -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,46 @@ 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, describedBy, language, maxLength, readOnly, disabled }`. Keeps Monaco out of the card's bundle |
|
|
2457
|
+
| `Question` `description` | `string` | — | `open`, `code`: a persistent helper line under the answer field ("A person will mark this answer"), linked to it by `aria-describedby` and passed to `renderCode` as `field.describedBy`; always shown, unlike `hint` (a reveal button). Its id is `${promptId}-description` |
|
|
2458
|
+
| `QuestionCard` `promptId` | `string` | generated | the id of the prompt text: point a wrapping section's `aria-labelledby` at it (and `aria-describedby` at `${promptId}-description`) |
|
|
2459
|
+
| `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 |
|
|
2460
|
+
| new strings | `material.education.kind-open`, `kind-code`, `code-placeholder` | — | English fallbacks and a French bundle |
|
|
2454
2461
|
| `QuestionCard` `variant` / `density` / `showShortcuts` / `number` / `showFeedback` | — | `'card'`, `'comfortable'`, `false`, —, `true` | framed or plain, spacing, key badges, "Question 2", the verdict banner |
|
|
2455
2462
|
|
|
2463
|
+
An open answer and a code answer, with the code editor from its own subpath
|
|
2464
|
+
(it is client-only, so load it lazily in an SSR app):
|
|
2465
|
+
|
|
2466
|
+
```tsx
|
|
2467
|
+
import { CodeEditor } from '@nxgt/material/code-editor';
|
|
2468
|
+
|
|
2469
|
+
<QuestionCard
|
|
2470
|
+
number={3}
|
|
2471
|
+
question={{ id: 'q3', kind: 'open', prompt: 'Explain X', maxLength: 20000 }}
|
|
2472
|
+
value={text}
|
|
2473
|
+
onValueChange={setText}
|
|
2474
|
+
busy={saving}
|
|
2475
|
+
/>
|
|
2476
|
+
|
|
2477
|
+
<QuestionCard
|
|
2478
|
+
question={{ id: 'q4', kind: 'code', prompt: 'Add two numbers', language: 'python' }}
|
|
2479
|
+
value={source}
|
|
2480
|
+
onValueChange={setSource}
|
|
2481
|
+
renderCode={(f) => (
|
|
2482
|
+
<CodeEditor
|
|
2483
|
+
label={f.label}
|
|
2484
|
+
language={f.language}
|
|
2485
|
+
value={f.value}
|
|
2486
|
+
onChange={(v) => f.onChange(v ?? '')}
|
|
2487
|
+
readOnly={f.readOnly}
|
|
2488
|
+
disabled={f.disabled}
|
|
2489
|
+
/>
|
|
2490
|
+
)}
|
|
2491
|
+
/>
|
|
2492
|
+
```
|
|
2493
|
+
|
|
2456
2494
|
A server-graded answer, shown as given (no comparison in the browser):
|
|
2457
2495
|
|
|
2458
2496
|
```tsx
|
|
@@ -2467,6 +2505,42 @@ A server-graded answer, shown as given (no comparison in the browser):
|
|
|
2467
2505
|
|
|
2468
2506
|
**Storybook:** `Rich widgets/Quiz`, `Rich widgets/QuestionCard` · **Vue:** `Quiz`, `QuestionCard` (the modes, results, number kind and shortcuts are React only for now, #890)
|
|
2469
2507
|
|
|
2508
|
+
### QuestionMediaView
|
|
2509
|
+
|
|
2510
|
+
A question's file, picked by its type, with an error state for links that
|
|
2511
|
+
expire. It takes the shape of `Question.media` plus `size`, so
|
|
2512
|
+
`<QuestionMediaView media={question.media} />` works. It is named
|
|
2513
|
+
`QuestionMediaView` because `QuestionMedia` is the type of `Question.media`;
|
|
2514
|
+
`QuestionCard` still draws its own `media` without an error state and will
|
|
2515
|
+
reuse this view (#2021).
|
|
2516
|
+
|
|
2517
|
+
```tsx
|
|
2518
|
+
import { QuestionMediaView } from '@nxgt/material/components';
|
|
2519
|
+
|
|
2520
|
+
<QuestionMediaView
|
|
2521
|
+
media={{ src, type: 'audio/mpeg', size: 4096 }}
|
|
2522
|
+
onReload={refetch}
|
|
2523
|
+
reloading={isRefetching}
|
|
2524
|
+
/>
|
|
2525
|
+
```
|
|
2526
|
+
|
|
2527
|
+
| Prop | Type | Default | Effect |
|
|
2528
|
+
| --- | --- | --- | --- |
|
|
2529
|
+
| `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 |
|
|
2530
|
+
| `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 |
|
|
2531
|
+
| `captions` | `{ src, srclang, label, default? }[]` | — | caption tracks of a video. A video with speech needs captions or a transcript next to it |
|
|
2532
|
+
| `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 |
|
|
2533
|
+
| `reloading` | `boolean` | `false` | the Reload button is busy while the fresh link is fetched |
|
|
2534
|
+
|
|
2535
|
+
A failure is forgotten when `src` changes, so reloading a presigned link and
|
|
2536
|
+
passing the new `src` brings the file back, and focus moves to the file when it
|
|
2537
|
+
was on the Reload button (the wrapper, `data-slot="question-media"`, stays
|
|
2538
|
+
mounted). A reload that returns the same `src` keeps the error. A document link cannot
|
|
2539
|
+
report an expired link; reload it from your own signal. There is no
|
|
2540
|
+
`kind: 'video'`: a video is picked by its `type` (`video/*`).
|
|
2541
|
+
|
|
2542
|
+
**Storybook:** `Rich widgets/QuestionMediaView` · **Vue:** none (the `vue` issue #2020)
|
|
2543
|
+
|
|
2470
2544
|
### QuestionNavigator
|
|
2471
2545
|
|
|
2472
2546
|
The numbered questions of a quiz or exam player: jump to one, see the current
|
package/docs/guides/helpers.md
CHANGED
|
@@ -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 |
|