@mdbase-dev/ui 0.1.0-beta.121 → 0.1.0-beta.124
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/README.md +66 -1
- package/css/brand.css +27 -24
- package/css/feedback.css +62 -0
- package/css/motion.css +238 -130
- package/css/screens.css +1 -1
- package/dist/AppSwitcher.d.ts +3 -0
- package/dist/AppSwitcher.js +14 -1
- package/dist/Dialog.js +1 -1
- package/dist/Feedback.d.ts +27 -0
- package/dist/Feedback.js +247 -0
- package/dist/FeedbackMarkup.d.ts +18 -0
- package/dist/FeedbackMarkup.js +136 -0
- package/dist/FeedbackVerification.d.ts +21 -0
- package/dist/FeedbackVerification.js +76 -0
- package/dist/SaveNotice.d.ts +7 -1
- package/dist/SaveNotice.js +32 -0
- package/dist/brand.d.ts +32 -23
- package/dist/brand.js +80 -34
- package/dist/feedback-data.d.ts +75 -0
- package/dist/feedback-data.js +200 -0
- package/dist/mark-activity.d.ts +39 -0
- package/dist/mark-activity.js +121 -0
- package/dist/screens.d.ts +3 -2
- package/dist/screens.js +6 -4
- package/package.json +10 -1
package/dist/AppSwitcher.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { type MdbaseMarkMotion } from "./brand.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* The product wordmark as a menu for opening the current collection in the other mdbase apps.
|
|
6
6
|
* Each opens in a new tab so the current app stays where it is.
|
|
7
|
+
*
|
|
8
|
+
* Its mark shows the page's activity (see `mark-activity`): progress, then a held loop,
|
|
9
|
+
* then `motion`. Without a `motion` it plays `keys-first` the first time it appears.
|
|
7
10
|
*/
|
|
8
11
|
export declare function AppSwitcher({ current, urls, motion }: {
|
|
9
12
|
readonly current: MdbaseAppId;
|
package/dist/AppSwitcher.js
CHANGED
|
@@ -2,15 +2,28 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useId, useRef, useState } from "react";
|
|
3
3
|
import { mdbaseAppHref, withAppUrls } from "./apps.js";
|
|
4
4
|
import { MdbaseAppMark, Wordmark } from "./brand.js";
|
|
5
|
+
import { useMdbaseMarkActivity } from "./mark-activity.js";
|
|
5
6
|
import { moveMenuFocus, useMenuPopover } from "./popover.js";
|
|
7
|
+
/** The switcher plays its entrance once per page, not every time it remounts. */
|
|
8
|
+
let entrancePlayed = false;
|
|
6
9
|
/**
|
|
7
10
|
* The product wordmark as a menu for opening the current collection in the other mdbase apps.
|
|
8
11
|
* Each opens in a new tab so the current app stays where it is.
|
|
12
|
+
*
|
|
13
|
+
* Its mark shows the page's activity (see `mark-activity`): progress, then a held loop,
|
|
14
|
+
* then `motion`. Without a `motion` it plays `keys-first` the first time it appears.
|
|
9
15
|
*/
|
|
10
16
|
export function AppSwitcher({ current, urls = {}, motion }) {
|
|
11
17
|
const menuId = useId();
|
|
12
18
|
const triggerRef = useRef(null);
|
|
13
19
|
const [open, setOpen] = useState(false);
|
|
20
|
+
const [entrance] = useState(() => {
|
|
21
|
+
if (motion || entrancePlayed || typeof window === "undefined")
|
|
22
|
+
return undefined;
|
|
23
|
+
entrancePlayed = true;
|
|
24
|
+
return "keys-first";
|
|
25
|
+
});
|
|
26
|
+
const activity = useMdbaseMarkActivity();
|
|
14
27
|
const close = (refocus) => {
|
|
15
28
|
setOpen(false);
|
|
16
29
|
if (refocus)
|
|
@@ -21,7 +34,7 @@ export function AppSwitcher({ current, urls = {}, motion }) {
|
|
|
21
34
|
event.preventDefault();
|
|
22
35
|
setOpen(true);
|
|
23
36
|
}
|
|
24
|
-
}, children: [_jsx(Wordmark, { app: current, motion: motion }), _jsx("svg", { className: "mdbase-app-switcher-chevron", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "m7 10 5 5 5-5" }) })] }), open ? _jsx(AppMenu, { id: menuId, apps: withAppUrls(urls), current: current, triggerRef: triggerRef, onClose: close }) : null] });
|
|
37
|
+
}, children: [_jsx(Wordmark, { app: current, motion: activity.busy ?? motion ?? entrance, signal: activity.signal, progress: activity.progress }), _jsx("svg", { className: "mdbase-app-switcher-chevron", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "m7 10 5 5 5-5" }) })] }), open ? _jsx(AppMenu, { id: menuId, apps: withAppUrls(urls), current: current, triggerRef: triggerRef, onClose: close }) : null] });
|
|
25
38
|
}
|
|
26
39
|
function AppMenu({ id, apps, current, triggerRef, onClose }) {
|
|
27
40
|
const menuRef = useRef(null);
|
package/dist/Dialog.js
CHANGED
|
@@ -20,7 +20,7 @@ export function Dialog({ open, onClose, title, className, closeDisabled = false,
|
|
|
20
20
|
else if (!open && dialog.open)
|
|
21
21
|
dialog.close();
|
|
22
22
|
}, [open]);
|
|
23
|
-
return _jsx("dialog", { ref: ref, className: ["mdbase-dialog", className].filter(Boolean).join(" "), "aria-label": title, onCancel: (event) => {
|
|
23
|
+
return _jsx("dialog", { ref: ref, className: ["mdbase-dialog", className].filter(Boolean).join(" "), "aria-label": title, onKeyDown: (event) => event.stopPropagation(), onCancel: (event) => {
|
|
24
24
|
event.preventDefault();
|
|
25
25
|
if (!closeDisabled)
|
|
26
26
|
onClose();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type FeedbackApplication, type FeedbackFailure, type FeedbackTopic } from "./feedback-data.js";
|
|
3
|
+
export { feedbackApplication, resolveFeedbackEndpoint } from "./feedback-data.js";
|
|
4
|
+
export type { FeedbackApplication, FeedbackFailure, FeedbackErrorCode, FeedbackTopic } from "./feedback-data.js";
|
|
5
|
+
interface FeedbackControls {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
open: (topic?: FeedbackTopic) => void;
|
|
8
|
+
/** Call only for a user-visible failure, never background retries. Pass codes, not raw exceptions. */
|
|
9
|
+
reportError: (failure: FeedbackFailure) => void;
|
|
10
|
+
wiggle: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function useFeedback(): FeedbackControls;
|
|
13
|
+
export declare function FeedbackBug({ wiggle }: {
|
|
14
|
+
wiggle?: number;
|
|
15
|
+
}): import("react").JSX.Element;
|
|
16
|
+
export declare function FeedbackButton({ className, topic }: {
|
|
17
|
+
className?: string;
|
|
18
|
+
topic?: FeedbackTopic;
|
|
19
|
+
}): import("react").JSX.Element | null;
|
|
20
|
+
/** One per application shell. No Connect/auth dependency; drafts live only in this mounted provider. */
|
|
21
|
+
export declare function FeedbackProvider({ endpoint: configuredEndpoint, application, turnstileSiteKey, collectionName, children }: {
|
|
22
|
+
endpoint: string | null;
|
|
23
|
+
application: FeedbackApplication;
|
|
24
|
+
turnstileSiteKey?: string | null;
|
|
25
|
+
collectionName?: string;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}): import("react").JSX.Element;
|
package/dist/Feedback.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { FeedbackMarkup } from "./FeedbackMarkup.js";
|
|
4
|
+
import { FeedbackVerification } from "./FeedbackVerification.js";
|
|
5
|
+
import { FEEDBACK_ERROR_CODES, FEEDBACK_MAX_MESSAGE_LENGTH, captureFeedbackScreenshot, feedbackDiagnostics, feedbackEvents, feedbackTopics, readFeedbackScreenshot, resolveFeedbackEndpoint, screenshotUrl, sendFeedback } from "./feedback-data.js";
|
|
6
|
+
export { feedbackApplication, resolveFeedbackEndpoint } from "./feedback-data.js";
|
|
7
|
+
const FeedbackContext = createContext(null);
|
|
8
|
+
const disabledFeedback = { enabled: false, open() { }, reportError() { }, wiggle: 0 };
|
|
9
|
+
export function useFeedback() { return useContext(FeedbackContext) ?? disabledFeedback; }
|
|
10
|
+
export function FeedbackBug({ wiggle = 0 }) {
|
|
11
|
+
return _jsxs("svg", { className: `mdbase-feedback-bug${wiggle ? " is-wiggling" : ""}`, viewBox: "0 0 48 48", "aria-hidden": "true", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M16 23 8 19l-3 2m10 8H6l-2 3m12 3-7 4v3m23-19 8-4 3 2m-10 8h9l2 3m-12 3 7 4v3M20 13l-4-6-4 1m16 5 4-6 4 1" }), _jsx("ellipse", { cx: "24", cy: "29", rx: "10", ry: "12" }), _jsx("ellipse", { cx: "24", cy: "16", rx: "7", ry: "6" }), _jsx("path", { d: "M24 22v17" }), _jsx("circle", { cx: "21", cy: "15", r: "1.5", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "27", cy: "15", r: "1.5", fill: "currentColor", stroke: "none" })] }, wiggle);
|
|
12
|
+
}
|
|
13
|
+
export function FeedbackButton({ className, topic }) {
|
|
14
|
+
const feedback = useFeedback();
|
|
15
|
+
if (!feedback.enabled)
|
|
16
|
+
return null;
|
|
17
|
+
return _jsxs("button", { type: "button", className: ["mdbase-feedback-trigger", className].filter(Boolean).join(" "), onClick: () => feedback.open(topic), children: [_jsx(FeedbackBug, { wiggle: feedback.wiggle }), _jsx("span", { children: topic === "problem" ? "Report a problem" : "Send feedback" })] });
|
|
18
|
+
}
|
|
19
|
+
/** One per application shell. No Connect/auth dependency; drafts live only in this mounted provider. */
|
|
20
|
+
export function FeedbackProvider({ endpoint: configuredEndpoint, application, turnstileSiteKey, collectionName, children }) {
|
|
21
|
+
const endpoint = resolveFeedbackEndpoint(configuredEndpoint ?? undefined);
|
|
22
|
+
const dialog = useRef(null);
|
|
23
|
+
const content = useRef(null);
|
|
24
|
+
const messageField = useRef(null);
|
|
25
|
+
const successHeading = useRef(null);
|
|
26
|
+
const body = useRef(null);
|
|
27
|
+
const trigger = useRef(null);
|
|
28
|
+
const events = useRef([]);
|
|
29
|
+
const lastWiggle = useRef(-Infinity);
|
|
30
|
+
const imageOperation = useRef(null);
|
|
31
|
+
const submissionOperation = useRef(null);
|
|
32
|
+
const attempt = useRef(null);
|
|
33
|
+
const live = useRef(true);
|
|
34
|
+
const [open, setOpen] = useState(false);
|
|
35
|
+
const [topic, setTopic] = useState("problem");
|
|
36
|
+
const [message, setMessage] = useState("");
|
|
37
|
+
const [replyEmail, setReplyEmail] = useState("");
|
|
38
|
+
const [includeCollection, setIncludeCollection] = useState(false);
|
|
39
|
+
const [includeDiagnostics, setIncludeDiagnostics] = useState(false);
|
|
40
|
+
const [diagnostics, setDiagnostics] = useState(null);
|
|
41
|
+
const [screenshot, setScreenshot] = useState(null);
|
|
42
|
+
const [includeScreenshot, setIncludeScreenshot] = useState(true);
|
|
43
|
+
const [markup, setMarkup] = useState(false);
|
|
44
|
+
const [imageBusy, setImageBusy] = useState(false);
|
|
45
|
+
const [capturing, setCapturing] = useState(false);
|
|
46
|
+
const [captureStatus, setCaptureStatus] = useState("");
|
|
47
|
+
const [screenshotError, setScreenshotError] = useState("");
|
|
48
|
+
const [submitError, setSubmitError] = useState("");
|
|
49
|
+
const [submitting, setSubmitting] = useState(false);
|
|
50
|
+
const [submitted, setSubmitted] = useState(null);
|
|
51
|
+
const [turnstileToken, setTurnstileToken] = useState("");
|
|
52
|
+
const [verificationAttempt, setVerificationAttempt] = useState(0);
|
|
53
|
+
const [wiggle, setWiggle] = useState(0);
|
|
54
|
+
const id = useId();
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
live.current = true;
|
|
57
|
+
return () => { live.current = false; imageOperation.current?.abort(); submissionOperation.current?.abort(); if (content.current)
|
|
58
|
+
content.current.inert = false; };
|
|
59
|
+
}, []);
|
|
60
|
+
useLayoutEffect(() => { setIncludeCollection(false); }, [collectionName]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!wiggle)
|
|
63
|
+
return;
|
|
64
|
+
const timer = setTimeout(() => setWiggle(0), 600);
|
|
65
|
+
return () => clearTimeout(timer);
|
|
66
|
+
}, [wiggle]);
|
|
67
|
+
const openFeedback = useCallback((next) => {
|
|
68
|
+
if (!endpoint)
|
|
69
|
+
return;
|
|
70
|
+
trigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
71
|
+
if (next)
|
|
72
|
+
setTopic(next);
|
|
73
|
+
setDiagnostics(feedbackDiagnostics(events.current));
|
|
74
|
+
setOpen(true);
|
|
75
|
+
}, [endpoint]);
|
|
76
|
+
const reportError = useCallback((failure) => {
|
|
77
|
+
if (!endpoint || failure.code === "cancelled")
|
|
78
|
+
return;
|
|
79
|
+
if (!FEEDBACK_ERROR_CODES.includes(failure.code) || (failure.status !== undefined && (!Number.isInteger(failure.status) || failure.status < 100 || failure.status > 599)))
|
|
80
|
+
throw new TypeError("Feedback failures must contain a bounded error code and HTTP status only.");
|
|
81
|
+
const event = { at: new Date().toISOString(), code: failure.code, ...(failure.status === undefined ? {} : { status: failure.status }) };
|
|
82
|
+
events.current = feedbackEvents([...events.current, event]);
|
|
83
|
+
if (document.visibilityState === "visible" && performance.now() - lastWiggle.current >= 30_000) {
|
|
84
|
+
lastWiggle.current = performance.now();
|
|
85
|
+
setWiggle((value) => value + 1);
|
|
86
|
+
}
|
|
87
|
+
}, [endpoint]);
|
|
88
|
+
const controls = useMemo(() => ({ enabled: Boolean(endpoint), open: openFeedback, reportError, wiggle }), [endpoint, openFeedback, reportError, wiggle]);
|
|
89
|
+
useLayoutEffect(() => {
|
|
90
|
+
if (!dialog.current)
|
|
91
|
+
return;
|
|
92
|
+
if (open && !capturing) {
|
|
93
|
+
if (!dialog.current.open)
|
|
94
|
+
dialog.current.showModal();
|
|
95
|
+
if (submitted)
|
|
96
|
+
successHeading.current?.focus({ preventScroll: true });
|
|
97
|
+
else
|
|
98
|
+
messageField.current?.focus({ preventScroll: true });
|
|
99
|
+
}
|
|
100
|
+
else if (dialog.current.open)
|
|
101
|
+
dialog.current.close();
|
|
102
|
+
}, [open, capturing, submitted]);
|
|
103
|
+
function resetDraft() {
|
|
104
|
+
setTopic("problem");
|
|
105
|
+
setMessage("");
|
|
106
|
+
setReplyEmail("");
|
|
107
|
+
setIncludeCollection(false);
|
|
108
|
+
setIncludeDiagnostics(false);
|
|
109
|
+
setScreenshot(null);
|
|
110
|
+
setIncludeScreenshot(true);
|
|
111
|
+
setSubmitted(null);
|
|
112
|
+
setSubmitError("");
|
|
113
|
+
setScreenshotError("");
|
|
114
|
+
setCaptureStatus("");
|
|
115
|
+
attempt.current = null;
|
|
116
|
+
}
|
|
117
|
+
function close() {
|
|
118
|
+
if (submitting)
|
|
119
|
+
return;
|
|
120
|
+
imageOperation.current?.abort();
|
|
121
|
+
imageOperation.current = null;
|
|
122
|
+
setCapturing(false);
|
|
123
|
+
setImageBusy(false);
|
|
124
|
+
setMarkup(false);
|
|
125
|
+
setOpen(false);
|
|
126
|
+
setTurnstileToken("");
|
|
127
|
+
setVerificationAttempt((value) => value + 1);
|
|
128
|
+
if (submitted)
|
|
129
|
+
resetDraft();
|
|
130
|
+
dialog.current?.close();
|
|
131
|
+
trigger.current?.focus({ preventScroll: true });
|
|
132
|
+
}
|
|
133
|
+
async function capture() {
|
|
134
|
+
if (imageOperation.current || submitting)
|
|
135
|
+
return;
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
imageOperation.current = controller;
|
|
138
|
+
const scroll = body.current?.scrollTop ?? 0;
|
|
139
|
+
setImageBusy(true);
|
|
140
|
+
setCapturing(true);
|
|
141
|
+
setScreenshotError("");
|
|
142
|
+
setCaptureStatus("");
|
|
143
|
+
// Close synchronously, before either the picker or capture stream sees this native modal.
|
|
144
|
+
dialog.current?.close();
|
|
145
|
+
if (content.current)
|
|
146
|
+
content.current.inert = true;
|
|
147
|
+
try {
|
|
148
|
+
const next = await captureFeedbackScreenshot(controller.signal);
|
|
149
|
+
if (!controller.signal.aborted && live.current) {
|
|
150
|
+
setScreenshot(next);
|
|
151
|
+
setIncludeScreenshot(true);
|
|
152
|
+
setCaptureStatus("Screenshot captured. Sharing has stopped.");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (reason) {
|
|
156
|
+
if (!controller.signal.aborted && live.current) {
|
|
157
|
+
if (reason instanceof DOMException && (reason.name === "NotAllowedError" || reason.name === "AbortError"))
|
|
158
|
+
setCaptureStatus("No screenshot taken. You can still send feedback.");
|
|
159
|
+
else
|
|
160
|
+
setScreenshotError(reason instanceof Error ? reason.message : "The screenshot could not be captured.");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
if (imageOperation.current === controller)
|
|
165
|
+
imageOperation.current = null;
|
|
166
|
+
if (content.current)
|
|
167
|
+
content.current.inert = false;
|
|
168
|
+
if (!controller.signal.aborted && live.current) {
|
|
169
|
+
setCapturing(false);
|
|
170
|
+
setImageBusy(false);
|
|
171
|
+
if (dialog.current && !dialog.current.open)
|
|
172
|
+
dialog.current.showModal();
|
|
173
|
+
if (body.current)
|
|
174
|
+
body.current.scrollTop = scroll;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function upload(file) {
|
|
179
|
+
if (imageOperation.current || submitting)
|
|
180
|
+
return;
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
imageOperation.current = controller;
|
|
183
|
+
setImageBusy(true);
|
|
184
|
+
setScreenshotError("");
|
|
185
|
+
setCaptureStatus("");
|
|
186
|
+
try {
|
|
187
|
+
const next = await readFeedbackScreenshot(file);
|
|
188
|
+
if (!controller.signal.aborted && live.current) {
|
|
189
|
+
setScreenshot(next);
|
|
190
|
+
setIncludeScreenshot(true);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (reason) {
|
|
194
|
+
if (!controller.signal.aborted && live.current)
|
|
195
|
+
setScreenshotError(reason instanceof Error ? reason.message : "The screenshot could not be read.");
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
if (imageOperation.current === controller)
|
|
199
|
+
imageOperation.current = null;
|
|
200
|
+
if (!controller.signal.aborted && live.current)
|
|
201
|
+
setImageBusy(false);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function submit() {
|
|
205
|
+
if (!endpoint || !message.trim() || imageOperation.current || submissionOperation.current || (turnstileSiteKey && !turnstileToken))
|
|
206
|
+
return;
|
|
207
|
+
const payload = {
|
|
208
|
+
schema_version: 2, application, topic, message: message.trim(),
|
|
209
|
+
...(replyEmail.trim() ? { reply_email: replyEmail.trim() } : {}),
|
|
210
|
+
...(includeCollection && collectionName ? { context: { collection_name: collectionName } } : {}),
|
|
211
|
+
...(topic === "problem" && includeDiagnostics && diagnostics ? { diagnostics } : {}),
|
|
212
|
+
...(screenshot && includeScreenshot ? { screenshot } : {})
|
|
213
|
+
};
|
|
214
|
+
const fingerprint = JSON.stringify(payload);
|
|
215
|
+
if (attempt.current?.fingerprint !== fingerprint)
|
|
216
|
+
attempt.current = { fingerprint, requestId: crypto.randomUUID() };
|
|
217
|
+
const submission = { ...payload, request_id: attempt.current.requestId, ...(turnstileToken ? { turnstile_token: turnstileToken } : {}) };
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
submissionOperation.current = controller;
|
|
220
|
+
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
221
|
+
setSubmitting(true);
|
|
222
|
+
setSubmitError("");
|
|
223
|
+
try {
|
|
224
|
+
await sendFeedback(endpoint, submission, controller.signal);
|
|
225
|
+
if (live.current) {
|
|
226
|
+
setSubmitted(topic);
|
|
227
|
+
setScreenshot(null);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (reason) {
|
|
231
|
+
if (live.current)
|
|
232
|
+
setSubmitError(controller.signal.aborted ? "Sending timed out. Please try again." : reason instanceof Error ? reason.message : "Feedback could not be sent. Please try again.");
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
submissionOperation.current = null;
|
|
237
|
+
if (live.current) {
|
|
238
|
+
setSubmitting(false);
|
|
239
|
+
setTurnstileToken("");
|
|
240
|
+
setVerificationAttempt((value) => value + 1);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const config = feedbackTopics[topic];
|
|
245
|
+
return _jsxs(FeedbackContext.Provider, { value: controls, children: [_jsx("div", { className: "mdbase-feedback-app", ref: content, children: children }), _jsx("span", { className: "mdbase-feedback-sr-only", role: "status", children: wiggle ? "A problem occurred. You can use Send feedback to report it." : "" }), endpoint && _jsxs("dialog", { ref: dialog, className: "mdbase-feedback-dialog", "aria-labelledby": `${id}-title`, onKeyDown: (event) => event.stopPropagation(), onCancel: (event) => { event.preventDefault(); close(); }, children: [_jsxs("header", { className: "mdbase-feedback-header", children: [_jsx("h2", { id: `${id}-title`, children: "Send feedback" }), _jsx("button", { type: "button", disabled: submitting, onClick: close, "aria-label": "Close feedback", children: "\u00D7" })] }), submitted ? _jsxs("section", { className: "mdbase-feedback-success", children: [_jsx("h3", { ref: successHeading, tabIndex: -1, children: feedbackTopics[submitted].thanks }), _jsx("p", { children: "Sent privately to the mdbase team." }), _jsx("button", { type: "button", className: "mdbase-feedback-primary", onClick: close, children: "Back to your work" })] }) : _jsxs("form", { onSubmit: (event) => { event.preventDefault(); void submit(); }, children: [_jsxs("div", { className: "mdbase-feedback-body", ref: body, children: [_jsxs("fieldset", { className: "mdbase-feedback-topics", disabled: submitting, children: [_jsx("legend", { className: "mdbase-feedback-sr-only", children: "Feedback type" }), Object.keys(feedbackTopics).map((value) => _jsxs("label", { children: [_jsx("input", { type: "radio", name: `${id}-topic`, checked: topic === value, onChange: () => setTopic(value) }), feedbackTopics[value].label] }, value))] }), _jsxs("label", { className: "mdbase-feedback-field", children: [_jsx("span", { children: config.prompt }), _jsx("textarea", { ref: messageField, required: true, maxLength: FEEDBACK_MAX_MESSAGE_LENGTH, rows: 5, placeholder: config.placeholder, value: message, disabled: submitting, onChange: (event) => setMessage(event.target.value) })] }), _jsxs("label", { className: "mdbase-feedback-field", children: [_jsxs("span", { children: ["Reply email ", _jsx("small", { children: "Optional" })] }), _jsx("input", { type: "email", maxLength: 320, autoComplete: "email", placeholder: "you@example.com", value: replyEmail, disabled: submitting, onChange: (event) => setReplyEmail(event.target.value) })] }), _jsxs("div", { className: "mdbase-feedback-screenshot", role: "group", "aria-label": "Screenshot", children: [screenshot && _jsxs(_Fragment, { children: [_jsxs("button", { className: "mdbase-feedback-preview", type: "button", disabled: imageBusy || submitting, onClick: () => { setIncludeScreenshot(false); setMarkup(true); }, "aria-label": "Mark up screenshot", children: [_jsx("img", { src: screenshotUrl(screenshot), alt: "Screenshot attached to your feedback" }), _jsx("span", { children: "Mark up screenshot" })] }), _jsxs("div", { className: "mdbase-feedback-screenshot-actions", children: [_jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: includeScreenshot, disabled: submitting, onChange: (event) => setIncludeScreenshot(event.target.checked) }), "Include screenshot"] }), _jsx("button", { type: "button", disabled: imageBusy || submitting, onClick: () => { setScreenshot(null); setScreenshotError(""); setCaptureStatus(""); }, children: "Remove" })] })] }), _jsxs("div", { className: "mdbase-feedback-screenshot-actions", children: [_jsx("button", { type: "button", disabled: imageBusy || submitting, onClick: () => void capture(), children: capturing ? "Taking screenshot…" : screenshot ? "Retake screenshot" : "Attach screenshot" }), _jsxs("label", { className: "mdbase-feedback-upload", children: [_jsx("span", { children: "Choose an image" }), _jsx("input", { type: "file", accept: "image/png,image/jpeg", disabled: imageBusy || submitting, onChange: (event) => { const file = event.target.files?.[0]; event.target.value = ""; if (file)
|
|
246
|
+
void upload(file); } })] })] }), screenshot && _jsx("p", { className: "mdbase-feedback-help", children: "Review for private information before sending." }), captureStatus && _jsx("p", { className: "mdbase-feedback-help", role: "status", children: captureStatus }), screenshotError && _jsx("p", { className: "mdbase-feedback-error", role: "alert", children: screenshotError })] }), _jsxs("details", { className: "mdbase-feedback-details", children: [_jsx("summary", { children: "What gets sent" }), _jsx("p", { className: "mdbase-feedback-help", children: "Your message, any reply email and included screenshot, plus these app and build details." }), _jsx("pre", { children: JSON.stringify(application, null, 2) }), collectionName && _jsxs("label", { className: "mdbase-feedback-check", children: [_jsx("input", { type: "checkbox", checked: includeCollection, disabled: submitting, onChange: (event) => setIncludeCollection(event.target.checked) }), "Include collection name: ", collectionName] }), topic === "problem" && _jsxs(_Fragment, { children: [_jsxs("label", { className: "mdbase-feedback-check", children: [_jsx("input", { type: "checkbox", checked: includeDiagnostics, disabled: submitting, onChange: (event) => setIncludeDiagnostics(event.target.checked) }), "Include technical diagnostics"] }), _jsx("p", { className: "mdbase-feedback-help", children: "Browser, OS, viewport and recent error codes. No note contents, paths or credentials." }), includeDiagnostics && _jsx("pre", { children: JSON.stringify(diagnostics, null, 2) })] }), _jsx("p", { className: "mdbase-feedback-help", children: "Images: PNG or JPEG, up to 3 MB. Blackout replaces pixels; only the final image is attached." }), _jsx("p", { className: "mdbase-feedback-help", children: "Replies aren\u2019t guaranteed. Not for security reports or urgent support." })] }), turnstileSiteKey && open && !capturing && _jsx(FeedbackVerification, { siteKey: turnstileSiteKey, onToken: setTurnstileToken }, verificationAttempt), submitError && _jsx("p", { className: "mdbase-feedback-error", role: "alert", children: submitError }), _jsx("p", { className: "mdbase-feedback-help", children: "Private feedback to the mdbase team." })] }), _jsxs("footer", { className: "mdbase-feedback-actions", children: [_jsx("button", { type: "button", disabled: submitting, onClick: close, children: "Cancel" }), _jsx("button", { type: "submit", className: "mdbase-feedback-primary", disabled: submitting || imageBusy || !message.trim() || Boolean(turnstileSiteKey && !turnstileToken), children: submitting ? "Sending…" : "Send feedback" })] })] })] }), markup && screenshot && _jsx(FeedbackMarkup, { screenshot: screenshot, onClose: () => { setMarkup(false); setCaptureStatus("Markup discarded. Screenshot excluded."); }, onApply: (next) => { setScreenshot(next); setIncludeScreenshot(true); setMarkup(false); setCaptureStatus("Markup applied."); } })] });
|
|
247
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type FeedbackScreenshot } from "./feedback-data.js";
|
|
2
|
+
type Point = {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
};
|
|
6
|
+
type Tool = "draw" | "highlight" | "blackout";
|
|
7
|
+
export interface FeedbackMark {
|
|
8
|
+
tool: Tool;
|
|
9
|
+
points: Point[];
|
|
10
|
+
}
|
|
11
|
+
/** Redactions render last and replace whole pixels. Export is a flattened raster, never layers. */
|
|
12
|
+
export declare function paintFeedbackMarks(context: CanvasRenderingContext2D, image: CanvasImageSource, marks: readonly FeedbackMark[]): void;
|
|
13
|
+
export declare function FeedbackMarkup({ screenshot, onApply, onClose }: {
|
|
14
|
+
screenshot: FeedbackScreenshot;
|
|
15
|
+
onApply(screenshot: FeedbackScreenshot): void;
|
|
16
|
+
onClose(): void;
|
|
17
|
+
}): import("react").JSX.Element;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
import { Dialog } from "./Dialog.js";
|
|
4
|
+
import { screenshotFromCanvas, screenshotUrl } from "./feedback-data.js";
|
|
5
|
+
/** Redactions render last and replace whole pixels. Export is a flattened raster, never layers. */
|
|
6
|
+
export function paintFeedbackMarks(context, image, marks) {
|
|
7
|
+
const { width, height } = context.canvas;
|
|
8
|
+
context.clearRect(0, 0, width, height);
|
|
9
|
+
context.drawImage(image, 0, 0);
|
|
10
|
+
for (const mark of [...marks.filter((m) => m.tool !== "blackout"), ...marks.filter((m) => m.tool === "blackout")]) {
|
|
11
|
+
const first = mark.points[0], last = mark.points.at(-1);
|
|
12
|
+
if (!first || !last)
|
|
13
|
+
continue;
|
|
14
|
+
context.save();
|
|
15
|
+
if (mark.tool === "draw") {
|
|
16
|
+
context.strokeStyle = "#df3c32";
|
|
17
|
+
context.lineWidth = Math.max(3, width / 360);
|
|
18
|
+
context.lineCap = "round";
|
|
19
|
+
context.lineJoin = "round";
|
|
20
|
+
context.beginPath();
|
|
21
|
+
context.moveTo(first.x, first.y);
|
|
22
|
+
for (const point of mark.points.slice(1))
|
|
23
|
+
context.lineTo(point.x, point.y);
|
|
24
|
+
context.stroke();
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const x = Math.floor(Math.min(first.x, last.x)), y = Math.floor(Math.min(first.y, last.y));
|
|
28
|
+
context.fillStyle = mark.tool === "blackout" ? "#000" : "rgba(255, 214, 48, .42)";
|
|
29
|
+
context.fillRect(x, y, Math.ceil(Math.max(first.x, last.x)) - x, Math.ceil(Math.max(first.y, last.y)) - y);
|
|
30
|
+
}
|
|
31
|
+
context.restore();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function FeedbackMarkup({ screenshot, onApply, onClose }) {
|
|
35
|
+
const canvas = useRef(null);
|
|
36
|
+
const image = useRef(null);
|
|
37
|
+
const marks = useRef([]);
|
|
38
|
+
const current = useRef(null);
|
|
39
|
+
const cursor = useRef(null);
|
|
40
|
+
const [tool, setTool] = useState("draw");
|
|
41
|
+
const [count, setCount] = useState(0);
|
|
42
|
+
const [ready, setReady] = useState(false);
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
const [error, setError] = useState("");
|
|
45
|
+
const instructions = useId();
|
|
46
|
+
function paint(showCursor = true) {
|
|
47
|
+
const context = canvas.current?.getContext("2d");
|
|
48
|
+
if (!context || !image.current)
|
|
49
|
+
return;
|
|
50
|
+
paintFeedbackMarks(context, image.current, current.current ? [...marks.current, current.current] : marks.current);
|
|
51
|
+
if (showCursor && cursor.current) {
|
|
52
|
+
context.strokeStyle = "#1267ad";
|
|
53
|
+
context.lineWidth = 2;
|
|
54
|
+
context.beginPath();
|
|
55
|
+
context.arc(cursor.current.x, cursor.current.y, 7, 0, Math.PI * 2);
|
|
56
|
+
context.stroke();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
let live = true;
|
|
61
|
+
const decoded = new Image();
|
|
62
|
+
decoded.src = screenshotUrl(screenshot);
|
|
63
|
+
void decoded.decode().then(() => {
|
|
64
|
+
if (!live || !canvas.current)
|
|
65
|
+
return;
|
|
66
|
+
image.current = decoded;
|
|
67
|
+
canvas.current.width = decoded.naturalWidth;
|
|
68
|
+
canvas.current.height = decoded.naturalHeight;
|
|
69
|
+
paint();
|
|
70
|
+
setReady(true);
|
|
71
|
+
}).catch(() => { if (live)
|
|
72
|
+
setError("The screenshot could not be opened for markup."); });
|
|
73
|
+
return () => { live = false; image.current = null; marks.current = []; current.current = null; };
|
|
74
|
+
}, [screenshot]);
|
|
75
|
+
function finish() {
|
|
76
|
+
if (current.current && current.current.points.length > 1 && marks.current.length < 100)
|
|
77
|
+
marks.current.push(current.current);
|
|
78
|
+
current.current = null;
|
|
79
|
+
setCount(marks.current.length);
|
|
80
|
+
paint();
|
|
81
|
+
}
|
|
82
|
+
function coordinate(clientX, clientY) {
|
|
83
|
+
const element = canvas.current;
|
|
84
|
+
const rect = element.getBoundingClientRect();
|
|
85
|
+
return { x: Math.max(0, Math.min(element.width, (clientX - rect.left) * element.width / rect.width)), y: Math.max(0, Math.min(element.height, (clientY - rect.top) * element.height / rect.height)) };
|
|
86
|
+
}
|
|
87
|
+
return _jsxs(Dialog, { open: true, onClose: onClose, closeDisabled: busy, title: "Mark up screenshot", className: "mdbase-feedback-markup", children: [_jsxs("div", { className: "mdbase-feedback-tools", role: "group", "aria-label": "Screenshot tools", children: [["draw", "highlight", "blackout"].map((value) => _jsx("button", { type: "button", "aria-pressed": tool === value, disabled: !ready || busy, onClick: () => { current.current = null; setTool(value); paint(); }, children: value === "draw" ? "Draw" : value === "highlight" ? "Highlight" : "Blackout" }, value)), _jsx("button", { type: "button", disabled: !count || busy, onClick: () => { current.current = null; marks.current.pop(); setCount(marks.current.length); paint(); }, children: "Undo" }), _jsx("button", { type: "button", disabled: !count || busy, onClick: () => { current.current = null; marks.current = []; setCount(0); paint(); }, children: "Reset" })] }), _jsx("div", { className: "mdbase-feedback-canvas", children: _jsx("canvas", { ref: canvas, tabIndex: 0, "aria-label": "Screenshot annotation canvas", "aria-describedby": instructions, onPointerDown: (event) => {
|
|
88
|
+
if (!ready || busy || event.button !== 0)
|
|
89
|
+
return;
|
|
90
|
+
if (marks.current.length >= 100) {
|
|
91
|
+
setError("Up to 100 marks can be applied. Undo or reset to make another mark.");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
cursor.current = null;
|
|
95
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
96
|
+
current.current = { tool, points: [coordinate(event.clientX, event.clientY)] };
|
|
97
|
+
}, onPointerMove: (event) => { if (current.current && event.currentTarget.hasPointerCapture(event.pointerId) && current.current.points.length < 2000) {
|
|
98
|
+
current.current.points.push(coordinate(event.clientX, event.clientY));
|
|
99
|
+
paint();
|
|
100
|
+
} }, onPointerUp: (event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
101
|
+
current.current?.points.push(coordinate(event.clientX, event.clientY));
|
|
102
|
+
finish();
|
|
103
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
104
|
+
} }, onPointerCancel: () => { current.current = null; paint(); }, onKeyDown: (event) => {
|
|
105
|
+
if (!ready || busy || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", " ", "Enter"].includes(event.key))
|
|
106
|
+
return;
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
cursor.current ??= { x: event.currentTarget.width / 2, y: event.currentTarget.height / 2 };
|
|
109
|
+
if (event.key === " " || event.key === "Enter") {
|
|
110
|
+
if (current.current) {
|
|
111
|
+
current.current.points.push({ ...cursor.current });
|
|
112
|
+
finish();
|
|
113
|
+
}
|
|
114
|
+
else if (marks.current.length < 100)
|
|
115
|
+
current.current = { tool, points: [{ ...cursor.current }] };
|
|
116
|
+
else
|
|
117
|
+
setError("Up to 100 marks can be applied. Undo or reset to make another mark.");
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
const step = event.shiftKey ? 50 : 10;
|
|
121
|
+
cursor.current.x = Math.max(0, Math.min(event.currentTarget.width, cursor.current.x + (event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0)));
|
|
122
|
+
cursor.current.y = Math.max(0, Math.min(event.currentTarget.height, cursor.current.y + (event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0)));
|
|
123
|
+
if (current.current && current.current.points.length < 2000)
|
|
124
|
+
current.current.points.push({ ...cursor.current });
|
|
125
|
+
}
|
|
126
|
+
paint();
|
|
127
|
+
} }) }), _jsx("p", { id: instructions, className: "mdbase-feedback-help", children: "Drag to mark. Keyboard: arrows move; Space starts or finishes. Blackout removes pixels." }), _jsxs("span", { className: "mdbase-feedback-sr-only", role: "status", children: [count, " marks applied."] }), error && _jsx("p", { role: "alert", children: error }), _jsxs("div", { className: "mdbase-feedback-actions", children: [_jsx("button", { type: "button", disabled: busy, onClick: onClose, children: "Cancel" }), _jsx("button", { type: "button", className: "mdbase-feedback-primary", disabled: !ready || busy, onClick: () => {
|
|
128
|
+
if (!canvas.current)
|
|
129
|
+
return;
|
|
130
|
+
finish();
|
|
131
|
+
paint(false);
|
|
132
|
+
setBusy(true);
|
|
133
|
+
setError("");
|
|
134
|
+
void screenshotFromCanvas(canvas.current).then(onApply).catch((reason) => { setBusy(false); setError(reason instanceof Error ? reason.message : "The screenshot could not be saved."); });
|
|
135
|
+
}, children: busy ? "Applying…" : "Apply changes" })] })] });
|
|
136
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface TurnstileApi {
|
|
2
|
+
render(container: HTMLElement, options: {
|
|
3
|
+
sitekey: string;
|
|
4
|
+
callback(token: string): void;
|
|
5
|
+
"expired-callback"(): void;
|
|
6
|
+
"error-callback"(): void;
|
|
7
|
+
action: "feedback";
|
|
8
|
+
theme: "auto";
|
|
9
|
+
}): string;
|
|
10
|
+
remove(widgetId: string): void;
|
|
11
|
+
}
|
|
12
|
+
declare global {
|
|
13
|
+
interface Window {
|
|
14
|
+
turnstile?: TurnstileApi;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export declare function FeedbackVerification({ siteKey, onToken }: {
|
|
18
|
+
siteKey: string;
|
|
19
|
+
onToken(token: string): void;
|
|
20
|
+
}): import("react").JSX.Element;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
let turnstileScript;
|
|
4
|
+
export function FeedbackVerification({ siteKey, onToken }) {
|
|
5
|
+
const container = useRef(null);
|
|
6
|
+
const callback = useRef(onToken);
|
|
7
|
+
const [error, setError] = useState("");
|
|
8
|
+
const [attempt, setAttempt] = useState(0);
|
|
9
|
+
callback.current = onToken;
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
callback.current("");
|
|
12
|
+
setError("");
|
|
13
|
+
let active = true;
|
|
14
|
+
let widgetId;
|
|
15
|
+
void loadTurnstile().then(() => {
|
|
16
|
+
if (!active || !container.current || !window.turnstile)
|
|
17
|
+
return;
|
|
18
|
+
widgetId = window.turnstile.render(container.current, {
|
|
19
|
+
sitekey: siteKey,
|
|
20
|
+
callback: (token) => { if (active) {
|
|
21
|
+
setError("");
|
|
22
|
+
callback.current(token);
|
|
23
|
+
} },
|
|
24
|
+
"expired-callback": () => { if (active)
|
|
25
|
+
callback.current(""); },
|
|
26
|
+
"error-callback": () => { if (active) {
|
|
27
|
+
callback.current("");
|
|
28
|
+
setError("Verification could not be completed. Try again.");
|
|
29
|
+
} },
|
|
30
|
+
action: "feedback",
|
|
31
|
+
theme: "auto"
|
|
32
|
+
});
|
|
33
|
+
}).catch(() => {
|
|
34
|
+
if (active)
|
|
35
|
+
setError("Verification could not be loaded. Check your connection and try again.");
|
|
36
|
+
});
|
|
37
|
+
return () => {
|
|
38
|
+
active = false;
|
|
39
|
+
if (widgetId && window.turnstile)
|
|
40
|
+
window.turnstile.remove(widgetId);
|
|
41
|
+
};
|
|
42
|
+
}, [siteKey, attempt]);
|
|
43
|
+
return _jsxs("div", { className: "mdbase-feedback-verification", children: [_jsx("div", { ref: container }), error && _jsxs(_Fragment, { children: [_jsx("p", { className: "mdbase-feedback-error", role: "alert", children: error }), _jsx("button", { type: "button", onClick: () => setAttempt((value) => value + 1), children: "Retry verification" })] })] });
|
|
44
|
+
}
|
|
45
|
+
function loadTurnstile() {
|
|
46
|
+
if (window.turnstile)
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
if (turnstileScript)
|
|
49
|
+
return turnstileScript;
|
|
50
|
+
turnstileScript = new Promise((resolve, reject) => {
|
|
51
|
+
const existing = document.querySelector('script[data-mdbase-turnstile="true"]');
|
|
52
|
+
const script = existing ?? document.createElement("script");
|
|
53
|
+
const cleanup = () => { clearTimeout(timer); script.removeEventListener("load", loaded); script.removeEventListener("error", failed); };
|
|
54
|
+
const loaded = () => { cleanup(); if (window.turnstile)
|
|
55
|
+
resolve();
|
|
56
|
+
else
|
|
57
|
+
reject(new Error("Turnstile API unavailable")); };
|
|
58
|
+
const failed = () => { cleanup(); reject(new Error("Turnstile failed to load")); };
|
|
59
|
+
const timer = setTimeout(failed, 15_000);
|
|
60
|
+
script.addEventListener("load", loaded, { once: true });
|
|
61
|
+
script.addEventListener("error", failed, { once: true });
|
|
62
|
+
if (!existing) {
|
|
63
|
+
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
64
|
+
script.async = true;
|
|
65
|
+
script.defer = true;
|
|
66
|
+
script.dataset.mdbaseTurnstile = "true";
|
|
67
|
+
document.head.append(script);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
turnstileScript = turnstileScript.catch((error) => {
|
|
71
|
+
document.querySelector('script[data-mdbase-turnstile="true"]')?.remove();
|
|
72
|
+
turnstileScript = undefined;
|
|
73
|
+
throw error;
|
|
74
|
+
});
|
|
75
|
+
return turnstileScript;
|
|
76
|
+
}
|
package/dist/SaveNotice.d.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type JSX } from "react";
|
|
2
|
+
import type { MdbaseMarkSignalKind } from "./brand.js";
|
|
2
3
|
/**
|
|
3
4
|
* saved: confirmed, so it settles away (see .mdbase-settle in controls.css).
|
|
4
5
|
* saving: work in progress. pending: changed but not yet saved.
|
|
5
6
|
* attention: a conflict or failure, which never fades.
|
|
6
7
|
*/
|
|
7
8
|
export type SaveTone = "saved" | "saving" | "pending" | "attention";
|
|
9
|
+
/** The mark's reaction when a notice changes from `before` to `tone`; none when it first appears. */
|
|
10
|
+
export declare function saveToneSignal(before: SaveTone | null, tone: SaveTone): MdbaseMarkSignalKind | null;
|
|
8
11
|
/**
|
|
9
12
|
* The minimal save notice every mdbase app shows beside what it edits. Put it inside an
|
|
10
13
|
* element with `mdbase-settle-host` so hovering or focusing that element brings a settled
|
|
11
14
|
* notice back.
|
|
15
|
+
*
|
|
16
|
+
* The notice also drives the app's mark: a save that completes plays `saved`, a newly
|
|
17
|
+
* raised problem plays `error`, and a slow save bounces the mark until it ends.
|
|
12
18
|
*/
|
|
13
19
|
export declare function SaveNotice({ tone, label, className }: {
|
|
14
20
|
readonly tone: SaveTone;
|