@panphora/clayjs 1.12.0 → 1.12.2
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 +1 -1
- package/dist/clay.standalone.js +509 -501
- package/entries/clay-data.js +1 -1
- package/package.json +12 -1
- package/src/loader-logic.js +5 -1
- package/src/loader.js +2 -0
- package/src/page/model.js +213 -0
- package/src/page/schema.js +157 -0
- package/src/plugins/message.js +159 -0
- package/src/plugins/visitor-drafts/bottom-ui.js +31 -0
- package/src/plugins/visitor-drafts/intro-style.js +17 -154
- package/src/plugins/visitor-drafts/intro.js +56 -169
- package/src/plugins/visitor-drafts/owner-chip.js +46 -19
- package/src/plugins/visitor-drafts/prompt-style.js +27 -13
- package/src/plugins/visitor-drafts/prompt.js +21 -30
- package/src/plugins/visitor-drafts/source.js +0 -5
- package/src/plugins/visitor-drafts.js +2 -2
- package/src/vendor/hyper-html-api-data.vendor.js +1 -1
- package/src/vendor/prosemirror.vendor.js +12 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* message.js — send a visitor's form to the document's owner (the host's `/_/message`).
|
|
3
|
+
*
|
|
4
|
+
* <form message-form="Thanks, I'll reply soon"> ... </form>
|
|
5
|
+
* const result = await clay.message.send({ email, note }); // { ok, error }
|
|
6
|
+
*
|
|
7
|
+
* Any form carrying `message-form`, or whose `action` is `/_/message`, is sent by
|
|
8
|
+
* script on submit: its fields as JSON, with the `Document-URL` header so the host
|
|
9
|
+
* knows whose document it came from. The form resets, the attribute's text shows
|
|
10
|
+
* as a toast, and `clay:message-sent` or `clay:message-failed` fires on the form.
|
|
11
|
+
* Cancel that event to draw your own confirmation instead of the toast.
|
|
12
|
+
*
|
|
13
|
+
* The bot check. The host refuses a message that carries no token from
|
|
14
|
+
* `GET /_/form-token`, so a bot that posts without running the page sends nothing.
|
|
15
|
+
* The token is fetched the first time the visitor focuses or types inside a
|
|
16
|
+
* message form (an untouched form costs no request), and again at submit when it
|
|
17
|
+
* is missing or old. Each token is used once. `formSignals` counts trusted
|
|
18
|
+
* events and reads `navigator.webdriver`; the host only logs it.
|
|
19
|
+
*
|
|
20
|
+
* `send` never rejects, matching upload: one result shape from every exit.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import getDataFromForm from "../dom/form-data.js";
|
|
24
|
+
|
|
25
|
+
const MESSAGE_PATH = "/_/message";
|
|
26
|
+
const TOKEN_PATH = "/_/form-token";
|
|
27
|
+
// The host accepts a token for two hours; refetch well before that.
|
|
28
|
+
const TOKEN_MAX_AGE_MS = 90 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
const TRUSTED_EVENTS = ["click", "keydown", "pointerdown", "touchstart", "input", "change"];
|
|
31
|
+
const trusted = Object.fromEntries(TRUSTED_EVENTS.map((type) => [type, 0]));
|
|
32
|
+
|
|
33
|
+
let token = null;
|
|
34
|
+
let tokenAt = 0;
|
|
35
|
+
let tokenRequest = null;
|
|
36
|
+
const sending = new WeakSet();
|
|
37
|
+
|
|
38
|
+
function fetchToken() {
|
|
39
|
+
if (!tokenRequest) {
|
|
40
|
+
tokenRequest = fetch(TOKEN_PATH, { credentials: "omit", cache: "no-store" })
|
|
41
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
42
|
+
.then((body) => {
|
|
43
|
+
if (body && typeof body.token === "string") {
|
|
44
|
+
token = body.token;
|
|
45
|
+
tokenAt = Date.now();
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.catch(() => {})
|
|
49
|
+
.finally(() => {
|
|
50
|
+
tokenRequest = null;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return tokenRequest;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function takeToken() {
|
|
57
|
+
if (!token || Date.now() - tokenAt > TOKEN_MAX_AGE_MS) await fetchToken();
|
|
58
|
+
const taken = token;
|
|
59
|
+
token = null;
|
|
60
|
+
return taken;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function signals() {
|
|
64
|
+
return { webdriver: navigator.webdriver === true, trusted: { ...trusted } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isMessageForm(form) {
|
|
68
|
+
if (!(form instanceof HTMLFormElement)) return false;
|
|
69
|
+
if (form.hasAttribute("message-form")) return true;
|
|
70
|
+
const action = form.getAttribute("action");
|
|
71
|
+
if (!action) return false;
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(action, window.location.href);
|
|
74
|
+
return url.origin === window.location.origin && url.pathname === MESSAGE_PATH;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Send one message to the document's owner.
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} data - the fields, as the owner should read them
|
|
84
|
+
* @returns {Promise<{ok: boolean, error: ?string}>}
|
|
85
|
+
*/
|
|
86
|
+
export async function send(data = {}) {
|
|
87
|
+
const result = await post(data);
|
|
88
|
+
// The host refused the token itself (it restarted, or the token expired): one fresh token,
|
|
89
|
+
// one more try, and the visitor never knows.
|
|
90
|
+
if (result.code === "form-token") return post(data);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function post(data) {
|
|
95
|
+
const formToken = await takeToken();
|
|
96
|
+
let res;
|
|
97
|
+
try {
|
|
98
|
+
res = await fetch(MESSAGE_PATH, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
// A signed-in owner testing their own form must not trip the host's CSRF
|
|
101
|
+
// guard, and the message never needs the visitor's cookies.
|
|
102
|
+
credentials: "omit",
|
|
103
|
+
headers: { "Content-Type": "application/json", "Document-URL": window.location.href },
|
|
104
|
+
body: JSON.stringify({ ...data, formToken, formSignals: signals() }),
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
return { ok: false, error: "The message couldn't be sent. Check your connection and try again." };
|
|
108
|
+
}
|
|
109
|
+
const body = await res.json().catch(() => null);
|
|
110
|
+
// A host without the message endpoint can answer any path with its HTML page at 200.
|
|
111
|
+
if (res.ok) return body ? { ok: true, error: null } : { ok: false, error: "This page can't send messages." };
|
|
112
|
+
if (!body) return { ok: false, error: `The message couldn't be sent (${res.status}).` };
|
|
113
|
+
const error = body.error || body.msg || `The message couldn't be sent (${res.status}).`;
|
|
114
|
+
return body.code === "form-token" ? { ok: false, error, code: "form-token" } : { ok: false, error };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function onSubmit(event) {
|
|
118
|
+
const form = event.target;
|
|
119
|
+
if (event.defaultPrevented || !isMessageForm(form)) return;
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
if (sending.has(form)) return;
|
|
122
|
+
sending.add(form);
|
|
123
|
+
try {
|
|
124
|
+
const result = await send(getDataFromForm(form));
|
|
125
|
+
if (result.ok) form.reset();
|
|
126
|
+
const outcome = new CustomEvent(result.ok ? "clay:message-sent" : "clay:message-failed", {
|
|
127
|
+
bubbles: true,
|
|
128
|
+
cancelable: true,
|
|
129
|
+
detail: result,
|
|
130
|
+
});
|
|
131
|
+
if (!form.dispatchEvent(outcome)) return;
|
|
132
|
+
const toast = window.clay?.toast;
|
|
133
|
+
if (!toast) return;
|
|
134
|
+
if (result.ok) toast(form.getAttribute("message-form") || "Sent", "success");
|
|
135
|
+
else toast(result.error, "error");
|
|
136
|
+
} finally {
|
|
137
|
+
sending.delete(form);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function warmToken(event) {
|
|
142
|
+
if (token || tokenRequest) return;
|
|
143
|
+
const form = event.target?.closest?.("form");
|
|
144
|
+
if (form && isMessageForm(form)) fetchToken();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (typeof document !== "undefined") {
|
|
148
|
+
for (const type of TRUSTED_EVENTS) {
|
|
149
|
+
document.addEventListener(type, (event) => {
|
|
150
|
+
if (event.isTrusted) trusted[type]++;
|
|
151
|
+
}, { capture: true, passive: true });
|
|
152
|
+
}
|
|
153
|
+
document.addEventListener("focusin", warmToken, true);
|
|
154
|
+
document.addEventListener("input", warmToken, true);
|
|
155
|
+
document.addEventListener("submit", onSubmit);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const message = { send };
|
|
159
|
+
export default message;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const BOTTOM_UI_CHANGED = "clay:bottom-ui-changed";
|
|
2
|
+
|
|
3
|
+
export function announceBottomUi() {
|
|
4
|
+
document.dispatchEvent(new CustomEvent(BOTTOM_UI_CHANGED));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function bottomBarHeight(x, skip) {
|
|
8
|
+
const h = window.innerHeight;
|
|
9
|
+
let raise = 0;
|
|
10
|
+
const hits = document.elementsFromPoint ? document.elementsFromPoint(x, h - 3) : [];
|
|
11
|
+
for (const hit of hits) {
|
|
12
|
+
for (let el = hit; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
|
|
13
|
+
if (skip(el)) break;
|
|
14
|
+
const position = getComputedStyle(el).position;
|
|
15
|
+
if (position === "fixed" || position === "sticky") {
|
|
16
|
+
const height = Math.ceil(h - el.getBoundingClientRect().top);
|
|
17
|
+
if (height < h / 3) raise = Math.max(raise, height);
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return raise;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function promptPanel(box) {
|
|
26
|
+
const panel = document.querySelector("#hcp:not([hidden])");
|
|
27
|
+
if (!panel) return 0;
|
|
28
|
+
const r = panel.getBoundingClientRect();
|
|
29
|
+
if (!r.width || !r.height || r.right <= box.left || r.left >= box.right) return 0;
|
|
30
|
+
return Math.ceil(window.innerHeight - r.top);
|
|
31
|
+
}
|
|
@@ -1,156 +1,19 @@
|
|
|
1
|
-
export const INTRO_CSS =
|
|
2
|
-
|
|
3
|
-
--hcp-surface: #FFFCF6;
|
|
4
|
-
--hcp-sunk: #F4ECDF;
|
|
5
|
-
--hcp-ink: #2B241B;
|
|
6
|
-
--hcp-ink-2: #4A4033;
|
|
7
|
-
--hcp-muted: #6B5942;
|
|
8
|
-
--hcp-line: #E8DCC6;
|
|
9
|
-
--hcp-line-2: #D8C8AF;
|
|
10
|
-
--hcp-brass: #8F6310;
|
|
11
|
-
--hcp-brass-soft: #F0E4CC;
|
|
12
|
-
--hcp-face: #F1E7D4;
|
|
13
|
-
--hcp-edge-hi: #FFF9ED;
|
|
14
|
-
--hcp-edge-lo: #D6C3A5;
|
|
15
|
-
--hcp-shadow: 0 1px 2px rgba(23, 28, 36, .06), 0 14px 34px -14px rgba(43, 36, 27, .32);
|
|
16
|
-
--hcp-sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
17
|
-
--hcp-serif: Newsreader, Georgia, "Times New Roman", serif;
|
|
18
|
-
--hcp-mono: "IBM Plex Mono", ui-monospace, Menlo, monospace;
|
|
19
|
-
color-scheme: light;
|
|
20
|
-
}
|
|
21
|
-
@media (prefers-color-scheme: dark) {
|
|
22
|
-
.clay-intro, .clay-about {
|
|
23
|
-
--hcp-ground: #0B0C13; --hcp-surface: #11131E; --hcp-sunk: #1A1D2C;
|
|
24
|
-
--hcp-ink: #ECEAF2; --hcp-ink-2: #C9CDE0; --hcp-muted: #8A90AB;
|
|
25
|
-
--hcp-line: #262B42; --hcp-line-2: #353B52;
|
|
26
|
-
--hcp-brass: #D9A445; --hcp-brass-soft: #3A2F17;
|
|
27
|
-
--hcp-face: #22273E; --hcp-edge-hi: #3C4260; --hcp-edge-lo: #1B2033;
|
|
28
|
-
--hcp-shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 16px 38px -14px rgba(0, 0, 0, .7);
|
|
29
|
-
color-scheme: dark;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
html:has(> body > .clay-intro), body:has(> .clay-intro) { height: auto !important; min-height: 100%; overflow-y: auto !important; }
|
|
34
|
-
|
|
35
|
-
.clay-intro, .clay-intro *, .clay-intro *::before, .clay-intro *::after,
|
|
36
|
-
.clay-about, .clay-about *::before, .clay-about *::after {
|
|
37
|
-
box-sizing: border-box; margin: 0; padding: 0; border: 0;
|
|
38
|
-
font: inherit; letter-spacing: normal; text-transform: none; text-shadow: none;
|
|
39
|
-
line-height: inherit; color: inherit; background: none; float: none; max-width: none;
|
|
40
|
-
}
|
|
1
|
+
export const INTRO_CSS = `html:has(> body > .clay-intro) { height: auto !important; overflow-y: auto !important; }
|
|
2
|
+
body:has(> .clay-intro) { height: auto !important; overflow: visible !important; }
|
|
41
3
|
.clay-intro {
|
|
42
|
-
position: relative;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
.clay-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
.clay-intro__name {
|
|
59
|
-
font-family: var(--hcp-serif); font-size: clamp(30px, 7vw, 44px); font-weight: 500; line-height: 1.12;
|
|
60
|
-
color: var(--hcp-ink); font-variation-settings: "opsz" 48;
|
|
61
|
-
}
|
|
62
|
-
.clay-intro__inner > p { font-size: 18px; line-height: 1.55; color: var(--hcp-ink-2); }
|
|
63
|
-
.clay-intro__inner > a { color: var(--hcp-brass); text-decoration: underline; text-underline-offset: 2px; }
|
|
64
|
-
.clay-intro__inner > video, .clay-intro__inner > img {
|
|
65
|
-
display: block; width: 100%; height: auto; aspect-ratio: 16 / 9;
|
|
66
|
-
background: var(--hcp-sunk); border: 1px solid var(--hcp-line);
|
|
67
|
-
}
|
|
68
|
-
.clay-intro__inner > ul, .clay-intro__inner > ol { padding-left: 22px; font-size: 18px; line-height: 1.55; color: var(--hcp-ink-2); }
|
|
69
|
-
.clay-intro__inner > ul { list-style: disc; }
|
|
70
|
-
.clay-intro__inner > ol { list-style: decimal; }
|
|
71
|
-
|
|
72
|
-
.clay-intro__actions { display: flex; flex-direction: column; gap: 14px; align-items: stretch; }
|
|
73
|
-
.clay-intro__download {
|
|
74
|
-
align-self: flex-start;
|
|
75
|
-
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
|
76
|
-
min-height: 44px; padding: 0 18px;
|
|
77
|
-
font-family: var(--hcp-sans); font-size: 15px; font-weight: 500; line-height: 1.5;
|
|
78
|
-
white-space: nowrap; text-decoration: none; cursor: pointer; user-select: none;
|
|
79
|
-
color: var(--hcp-ground); background: var(--hcp-ink);
|
|
80
|
-
border: 2px solid;
|
|
81
|
-
border-color: color-mix(in srgb, var(--hcp-ink), white 24%) color-mix(in srgb, var(--hcp-ink), black 32%) color-mix(in srgb, var(--hcp-ink), black 32%) color-mix(in srgb, var(--hcp-ink), white 24%);
|
|
82
|
-
transition: background .2s ease;
|
|
83
|
-
}
|
|
84
|
-
.clay-intro__download:hover { background: color-mix(in srgb, var(--hcp-ink), var(--hcp-ground) 12%); }
|
|
85
|
-
|
|
86
|
-
.clay-intro__signup {
|
|
87
|
-
display: flex; flex-direction: column; gap: 9px;
|
|
88
|
-
padding: 14px 16px;
|
|
89
|
-
background: var(--hcp-surface); border: 1px solid var(--hcp-line); border-left: 3px solid var(--hcp-brass);
|
|
90
|
-
}
|
|
91
|
-
.clay-intro__signup label { font-size: 14px; line-height: 1.45; color: var(--hcp-ink-2); }
|
|
92
|
-
.clay-intro__row { display: flex; gap: 8px; }
|
|
93
|
-
.clay-intro__row input {
|
|
94
|
-
flex: 1 1 auto; min-width: 0; height: 40px; padding: 0 10px;
|
|
95
|
-
font-family: var(--hcp-sans); font-size: 15px; color: var(--hcp-ink);
|
|
96
|
-
background: var(--hcp-ground); border: 1px solid var(--hcp-line-2);
|
|
97
|
-
}
|
|
98
|
-
.clay-intro__row input::placeholder { color: var(--hcp-muted); }
|
|
99
|
-
.clay-intro__row button {
|
|
100
|
-
flex: none; height: 40px; padding: 0 14px;
|
|
101
|
-
font-family: var(--hcp-sans); font-size: 14px; font-weight: 500;
|
|
102
|
-
color: var(--hcp-ink); background: var(--hcp-face); cursor: pointer;
|
|
103
|
-
border: 2px solid; border-color: var(--hcp-edge-hi) var(--hcp-edge-lo) var(--hcp-edge-lo) var(--hcp-edge-hi);
|
|
104
|
-
}
|
|
105
|
-
.clay-intro__row button:hover { background: color-mix(in srgb, var(--hcp-face), var(--hcp-edge-hi) 45%); }
|
|
106
|
-
.clay-intro__hp { position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0; }
|
|
107
|
-
.clay-intro__msg { font-size: 14px; line-height: 1.5; color: var(--hcp-ink-2); }
|
|
108
|
-
.clay-intro__msg[hidden] { display: none; }
|
|
109
|
-
.clay-intro__row[hidden] { display: none; }
|
|
110
|
-
|
|
111
|
-
.clay-intro__start {
|
|
112
|
-
margin-top: auto;
|
|
113
|
-
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
|
114
|
-
width: 100%; min-height: 56px; padding: 0 20px;
|
|
115
|
-
font-family: var(--hcp-sans); font-size: 16px; font-weight: 500;
|
|
116
|
-
color: var(--hcp-ground); background: var(--hcp-ink); cursor: pointer;
|
|
117
|
-
border: 2px solid;
|
|
118
|
-
border-color: color-mix(in srgb, var(--hcp-ink), white 24%) color-mix(in srgb, var(--hcp-ink), black 32%) color-mix(in srgb, var(--hcp-ink), black 32%) color-mix(in srgb, var(--hcp-ink), white 24%);
|
|
119
|
-
transition: background .2s ease;
|
|
120
|
-
}
|
|
121
|
-
.clay-intro__start::after { content: "\\2193"; font-size: 15px; line-height: 1; }
|
|
122
|
-
.clay-intro__start:hover { background: color-mix(in srgb, var(--hcp-ink), var(--hcp-ground) 12%); }
|
|
123
|
-
.clay-intro__download:focus-visible, .clay-intro__start:focus-visible,
|
|
124
|
-
.clay-intro__row button:focus-visible, .clay-intro__row input:focus-visible,
|
|
125
|
-
.clay-about:focus-visible { outline: 2px solid var(--hcp-brass); outline-offset: 2px; }
|
|
126
|
-
|
|
127
|
-
.clay-about {
|
|
128
|
-
position: fixed; top: 12px; right: 12px; z-index: 2147482000;
|
|
129
|
-
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
|
130
|
-
height: 34px; padding: 0 12px;
|
|
131
|
-
font-family: var(--hcp-sans); font-size: 13px; font-weight: 500; line-height: 1.5;
|
|
132
|
-
white-space: nowrap; cursor: pointer; user-select: none;
|
|
133
|
-
color: var(--hcp-ink); background: var(--hcp-face);
|
|
134
|
-
border: 2px solid; border-color: var(--hcp-edge-hi) var(--hcp-edge-lo) var(--hcp-edge-lo) var(--hcp-edge-hi);
|
|
135
|
-
box-shadow: var(--hcp-shadow);
|
|
136
|
-
transition: background .2s ease;
|
|
137
|
-
}
|
|
138
|
-
.clay-about:hover { background: color-mix(in srgb, var(--hcp-face), var(--hcp-edge-hi) 45%); }
|
|
139
|
-
|
|
140
|
-
@media (min-width: 560px) {
|
|
141
|
-
.clay-intro__actions { flex-direction: row; flex-wrap: wrap; align-items: center; }
|
|
142
|
-
.clay-intro__signup { flex: 1 1 320px; max-width: 520px; }
|
|
143
|
-
}
|
|
144
|
-
@media (pointer: coarse) {
|
|
145
|
-
.clay-intro__download, .clay-intro__start, .clay-intro__row input, .clay-intro__row button { min-height: 44px; }
|
|
146
|
-
.clay-about { min-height: 44px; }
|
|
147
|
-
}
|
|
148
|
-
@media (prefers-reduced-motion: reduce) {
|
|
149
|
-
.clay-intro, .clay-intro *, .clay-about { transition: none; animation: none; }
|
|
150
|
-
}
|
|
151
|
-
@media (forced-colors: active) {
|
|
152
|
-
.clay-intro__download, .clay-intro__start, .clay-intro__row button, .clay-about { border: 1px solid ButtonText; }
|
|
153
|
-
.clay-intro__signup { border: 1px solid CanvasText; }
|
|
154
|
-
}
|
|
155
|
-
@media print { .clay-intro, .clay-about { display: none; } }
|
|
4
|
+
position: relative;
|
|
5
|
+
display: block;
|
|
6
|
+
}
|
|
7
|
+
.clay-intro__close {
|
|
8
|
+
position: absolute; top: 10px; right: 10px; z-index: 1;
|
|
9
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
10
|
+
width: 28px; height: 28px; min-height: 0; margin: 0; padding: 0;
|
|
11
|
+
border: 0; border-radius: 4px; background: none; box-shadow: none;
|
|
12
|
+
color: inherit; opacity: .55; cursor: pointer; line-height: 1;
|
|
13
|
+
}
|
|
14
|
+
.clay-intro .clay-intro__close:hover { opacity: 1; background: none; color: inherit; }
|
|
15
|
+
.clay-intro__close:focus-visible { opacity: 1; outline: 2px solid currentColor; outline-offset: 2px; }
|
|
16
|
+
.clay-intro__close svg { display: block; width: 12px; height: 12px; fill: currentColor; stroke: none; }
|
|
17
|
+
@media (pointer: coarse) { .clay-intro__close { width: 44px; height: 44px; top: 4px; right: 4px; } }
|
|
18
|
+
@media print { .clay-intro { display: none; } }
|
|
156
19
|
`;
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { INTRO_CSS } from "./intro-style.js";
|
|
2
|
+
import { announceBottomUi } from "./bottom-ui.js";
|
|
2
3
|
|
|
3
4
|
export const COPY = {
|
|
4
|
-
|
|
5
|
-
download: "Download free",
|
|
6
|
-
signupLabel: "I make one of these about every two weeks. Get the next one by email.",
|
|
7
|
-
emailPlaceholder: "you@example.com",
|
|
8
|
-
signup: "Send me the next one",
|
|
9
|
-
signupThanks: "Thanks. Check your email for a link to confirm.",
|
|
10
|
-
signupFailed: "That didn't go through. Check the address and try again.",
|
|
11
|
-
start: (appName) => `Start using ${appName}`,
|
|
12
|
-
about: "About this app",
|
|
5
|
+
close: "Hide this introduction",
|
|
13
6
|
label: (appName) => `About ${appName}`,
|
|
14
7
|
};
|
|
15
8
|
|
|
16
9
|
const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
|
10
|
+
const TEXT_ENTRY = "textarea, select";
|
|
11
|
+
const NON_TEXT_INPUT = ["checkbox", "radio", "button", "submit", "reset", "file"];
|
|
12
|
+
|
|
13
|
+
function isTextEntry(el) {
|
|
14
|
+
if (el.isContentEditable || el.closest(TEXT_ENTRY)) return true;
|
|
15
|
+
if (el.tagName !== "INPUT") return false;
|
|
16
|
+
return NON_TEXT_INPUT.indexOf(el.type) < 0;
|
|
17
|
+
}
|
|
17
18
|
|
|
18
19
|
function forced() {
|
|
19
20
|
try {
|
|
@@ -34,40 +35,23 @@ function stylesOnce() {
|
|
|
34
35
|
|
|
35
36
|
function focusApp() {
|
|
36
37
|
const next = [...document.querySelectorAll(FOCUSABLE)].find(
|
|
37
|
-
(el) => !el.disabled && !el.closest("[clay~='editor-ui']") && (typeof el.checkVisibility !== "function" || el.checkVisibility())
|
|
38
|
+
(el) => !el.disabled && !isTextEntry(el) && !el.closest("[clay~='editor-ui']") && (typeof el.checkVisibility !== "function" || el.checkVisibility())
|
|
38
39
|
);
|
|
39
|
-
if (next) next.focus();
|
|
40
|
-
else document.body.focus();
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function mountIntro({ memory, template, appName, link, tag, source, hadDraft, prompt, snapshot }) {
|
|
44
|
-
if (!template) return null;
|
|
45
|
-
const opts = { memory, template, appName, link, tag, source, hadDraft, prompt, snapshot };
|
|
46
|
-
if (forced()) return mount(opts);
|
|
47
|
-
if (memory.get().intro.done || hadDraft) return mountAbout(opts);
|
|
48
|
-
if (window.location.hash) return null;
|
|
49
|
-
if (window.matchMedia && window.matchMedia("print").matches) return null;
|
|
50
|
-
return mount(opts);
|
|
40
|
+
if (next) next.focus({ preventScroll: true });
|
|
41
|
+
else document.body.focus({ preventScroll: true });
|
|
51
42
|
}
|
|
52
43
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
button.addEventListener("click", () => {
|
|
62
|
-
button.remove();
|
|
63
|
-
mount({ ...opts, fromAbout: true });
|
|
64
|
-
});
|
|
65
|
-
document.body.appendChild(button);
|
|
66
|
-
return button;
|
|
44
|
+
export function mountIntro({ memory, template, appName, hadDraft }) {
|
|
45
|
+
if (!template || !template.content.textContent.trim() && !template.content.querySelector("img, svg, video, canvas")) return null;
|
|
46
|
+
if (!forced()) {
|
|
47
|
+
if (memory.get().intro.done || hadDraft) return null;
|
|
48
|
+
if (window.location.hash) return null;
|
|
49
|
+
if (window.matchMedia && window.matchMedia("print").matches) return null;
|
|
50
|
+
}
|
|
51
|
+
return mount({ memory, template, appName });
|
|
67
52
|
}
|
|
68
53
|
|
|
69
|
-
function mount(
|
|
70
|
-
const { memory, template, appName, link, tag, source, prompt, snapshot, fromAbout } = opts;
|
|
54
|
+
function mount({ memory, template, appName }) {
|
|
71
55
|
stylesOnce();
|
|
72
56
|
|
|
73
57
|
const section = document.createElement("section");
|
|
@@ -75,157 +59,60 @@ function mount(opts) {
|
|
|
75
59
|
section.setAttribute("clay", "editor-ui");
|
|
76
60
|
section.setAttribute("sap-ignore", "");
|
|
77
61
|
section.setAttribute("aria-label", COPY.label(appName));
|
|
62
|
+
section.appendChild(template.content.cloneNode(true));
|
|
78
63
|
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const name = document.createElement("h1");
|
|
87
|
-
name.className = "clay-intro__name";
|
|
88
|
-
name.textContent = appName;
|
|
89
|
-
|
|
90
|
-
const actions = document.createElement("div");
|
|
91
|
-
actions.className = "clay-intro__actions";
|
|
92
|
-
|
|
93
|
-
const download = document.createElement("a");
|
|
94
|
-
download.className = "clay-intro__download";
|
|
95
|
-
download.href = link;
|
|
96
|
-
download.textContent = COPY.download;
|
|
97
|
-
|
|
98
|
-
const form = document.createElement("form");
|
|
99
|
-
form.className = "clay-intro__signup";
|
|
100
|
-
form.noValidate = true;
|
|
101
|
-
|
|
102
|
-
const label = document.createElement("label");
|
|
103
|
-
label.setAttribute("for", "clay-intro-email");
|
|
104
|
-
label.textContent = COPY.signupLabel;
|
|
105
|
-
|
|
106
|
-
const row = document.createElement("div");
|
|
107
|
-
row.className = "clay-intro__row";
|
|
108
|
-
|
|
109
|
-
const email = document.createElement("input");
|
|
110
|
-
email.id = "clay-intro-email";
|
|
111
|
-
email.type = "email";
|
|
112
|
-
email.autocomplete = "email";
|
|
113
|
-
email.required = true;
|
|
114
|
-
email.placeholder = COPY.emailPlaceholder;
|
|
115
|
-
|
|
116
|
-
const send = document.createElement("button");
|
|
117
|
-
send.type = "submit";
|
|
118
|
-
send.textContent = COPY.signup;
|
|
64
|
+
const close = document.createElement("button");
|
|
65
|
+
close.type = "button";
|
|
66
|
+
close.className = "clay-intro__close";
|
|
67
|
+
close.setAttribute("aria-label", COPY.close);
|
|
68
|
+
close.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><path d="M1.4 0 6 4.6 10.6 0 12 1.4 7.4 6 12 10.6 10.6 12 6 7.4 1.4 12 0 10.6 4.6 6 0 1.4z"/></svg>';
|
|
69
|
+
section.appendChild(close);
|
|
119
70
|
|
|
120
|
-
row.append(email, send);
|
|
121
|
-
|
|
122
|
-
const honey = document.createElement("input");
|
|
123
|
-
honey.className = "clay-intro__hp";
|
|
124
|
-
honey.name = "website";
|
|
125
|
-
honey.tabIndex = -1;
|
|
126
|
-
honey.autocomplete = "off";
|
|
127
|
-
honey.setAttribute("aria-hidden", "true");
|
|
128
|
-
|
|
129
|
-
const message = document.createElement("p");
|
|
130
|
-
message.className = "clay-intro__msg";
|
|
131
|
-
message.setAttribute("role", "status");
|
|
132
|
-
message.hidden = true;
|
|
133
|
-
|
|
134
|
-
form.append(label, row, honey, message);
|
|
135
|
-
actions.append(download, form);
|
|
136
|
-
|
|
137
|
-
const start = document.createElement("button");
|
|
138
|
-
start.type = "button";
|
|
139
|
-
start.className = "clay-intro__start";
|
|
140
|
-
start.textContent = COPY.start(appName);
|
|
141
|
-
|
|
142
|
-
inner.append(kicker, name, template.content.cloneNode(true), actions, start);
|
|
143
|
-
section.appendChild(inner);
|
|
144
|
-
|
|
145
|
-
let observer = null;
|
|
146
71
|
let gone = false;
|
|
72
|
+
let frame = 0;
|
|
147
73
|
|
|
148
|
-
function
|
|
149
|
-
document.removeEventListener("input", onUse, true);
|
|
150
|
-
document.removeEventListener("change", onUse, true);
|
|
151
|
-
document.removeEventListener("clay:dirty", onUse, true);
|
|
152
|
-
if (observer) { observer.disconnect(); observer = null; }
|
|
153
|
-
if (prompt) prompt.hideForIntro(false);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function dismiss(reason, target) {
|
|
74
|
+
function dismiss(reason) {
|
|
157
75
|
if (gone) return;
|
|
158
76
|
gone = true;
|
|
159
|
-
|
|
160
|
-
|
|
77
|
+
removeEventListener("scroll", onScroll);
|
|
78
|
+
if (frame) cancelAnimationFrame(frame);
|
|
79
|
+
const height = section.getBoundingClientRect().height;
|
|
80
|
+
const before = window.scrollY;
|
|
161
81
|
memory.get().intro.done = reason;
|
|
162
82
|
memory.save();
|
|
163
|
-
stop();
|
|
164
83
|
section.remove();
|
|
165
|
-
if (reason === "
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
} else if (anchor && anchor.getBoundingClientRect) {
|
|
169
|
-
window.scrollBy(0, anchor.getBoundingClientRect().top - before);
|
|
84
|
+
if (reason === "scrolled") {
|
|
85
|
+
const locked = /hidden|clip/.test(getComputedStyle(document.body).overflowY + getComputedStyle(document.documentElement).overflowY);
|
|
86
|
+
window.scrollTo(0, locked ? 0 : Math.max(0, before - height));
|
|
170
87
|
}
|
|
171
|
-
|
|
88
|
+
announceBottomUi();
|
|
89
|
+
if (reason !== "scrolled") focusApp();
|
|
172
90
|
}
|
|
173
91
|
|
|
174
|
-
function
|
|
175
|
-
|
|
92
|
+
function onScroll() {
|
|
93
|
+
if (frame) return;
|
|
94
|
+
frame = requestAnimationFrame(() => {
|
|
95
|
+
frame = 0;
|
|
96
|
+
const bottom = section.getBoundingClientRect().bottom;
|
|
97
|
+
const atEnd = scrollY > 0 && scrollY + innerHeight >= document.documentElement.scrollHeight - 1;
|
|
98
|
+
if (bottom < 1 || (atEnd && bottom < innerHeight)) dismiss("scrolled");
|
|
99
|
+
});
|
|
176
100
|
}
|
|
177
101
|
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
if (!unchanged()) dismiss("use", event.target);
|
|
102
|
+
section.addEventListener("click", (event) => {
|
|
103
|
+
if (event.target.closest(".clay-intro__close, [visitor-intro-dismiss]")) {
|
|
104
|
+
dismiss("closed");
|
|
182
105
|
return;
|
|
183
106
|
}
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
107
|
+
if (event.target.closest("[visitor-intro-start]")) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
window.scrollTo({ top: window.scrollY + section.getBoundingClientRect().bottom, behavior: "smooth" });
|
|
187
110
|
}
|
|
188
|
-
const target = event.target;
|
|
189
|
-
setTimeout(() => {
|
|
190
|
-
if (!gone && !unchanged()) dismiss("use", target);
|
|
191
|
-
}, 0);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
form.addEventListener("submit", (event) => {
|
|
195
|
-
event.preventDefault();
|
|
196
|
-
fetch("/_/newsletter/subscribe", {
|
|
197
|
-
method: "POST",
|
|
198
|
-
headers: { "Content-Type": "application/json" },
|
|
199
|
-
credentials: "same-origin",
|
|
200
|
-
body: JSON.stringify({ email: email.value.trim(), tags: [tag], source, website: honey.value }),
|
|
201
|
-
}).then((response) => {
|
|
202
|
-
if (!response.ok) throw new Error("signup failed");
|
|
203
|
-
row.hidden = true;
|
|
204
|
-
message.textContent = COPY.signupThanks;
|
|
205
|
-
message.hidden = false;
|
|
206
|
-
}).catch(() => {
|
|
207
|
-
message.textContent = COPY.signupFailed;
|
|
208
|
-
message.hidden = false;
|
|
209
|
-
});
|
|
210
111
|
});
|
|
211
112
|
|
|
212
|
-
start.addEventListener("click", () => dismiss("button"));
|
|
213
|
-
|
|
214
|
-
const baseline = snapshot ? snapshot() : null;
|
|
215
113
|
document.body.prepend(section);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
document.addEventListener("clay:dirty", onUse, true);
|
|
219
|
-
|
|
220
|
-
if (prompt) {
|
|
221
|
-
prompt.hideForIntro(true);
|
|
222
|
-
if (typeof IntersectionObserver === "function") {
|
|
223
|
-
observer = new IntersectionObserver((entries) => {
|
|
224
|
-
for (const entry of entries) prompt.hideForIntro(entry.intersectionRatio > 0.3);
|
|
225
|
-
});
|
|
226
|
-
observer.observe(section);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
114
|
+
announceBottomUi();
|
|
115
|
+
addEventListener("scroll", onScroll, { passive: true });
|
|
229
116
|
|
|
230
117
|
return section;
|
|
231
118
|
}
|