-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwidget.js
More file actions
302 lines (283 loc) · 15.7 KB
/
Copy pathwidget.js
File metadata and controls
302 lines (283 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
/* Jamdesk "What's New" widget. Plain ES2022, no dependencies.
Loaded by customers as <script src="https://<slug>.jamdesk.app/_jd/widget.js" ...>. */
(function () {
'use strict';
function originFromSrc(src) {
try { return new URL(src).origin; } catch (_) { return ''; }
}
function projectFromBase(base) {
try { return new URL(base).hostname.split('.')[0]; } catch (_) { return ''; }
}
function trimSlash(s) { return s.replace(/\/+$/, ''); }
function withLeadingSlash(s) { return s.startsWith('/') ? s : '/' + s; }
// Sanitize a customer-supplied CSS length (data-width/height). Accepts a bare
// number (→px) or a px/vw/vh/rem/em/% value; anything else returns '' so the
// caller falls back to the default. The pattern admits no ';'/':' — safe to
// splice into cssText.
function cssLen(v) {
if (!v) return '';
v = String(v).trim();
if (/^\d+(\.\d+)?$/.test(v)) return v + 'px';
return /^\d+(\.\d+)?(px|vw|vh|rem|em|%)$/.test(v) ? v : '';
}
// Sanitize a customer-supplied CSS color (data-unread-color, data-button-color,
// data-button-text-color). Accepts #hex or a CSS named color (letters only);
// else the given fallback. No ';'/':' admitted — safe to splice into cssText.
function cssColor(v, d) {
if (!v) return d;
v = String(v).trim();
return (/^#[0-9a-fA-F]{3,8}$/.test(v) || /^[a-zA-Z]+$/.test(v)) ? v : d;
}
// Compose the modal width/height from optional customer values, each capped
// responsive (min(value, 92vw/86vh)) so a custom size never overflows a phone.
// Defaults match the original fixed sizing.
function modalSize(width, height) {
return {
width: width ? 'min(' + width + ',92vw)' : 'min(560px,92vw)',
height: height ? 'min(' + height + ',86vh)' : 'min(680px,86vh)',
};
}
function parseConfig(dataset, scriptSrc) {
const origin = originFromSrc(scriptSrc);
const base = trimSlash(dataset.base || origin);
return {
base: base,
project: dataset.project || projectFromBase(base),
page: dataset.page || '/changelog',
theme: dataset.theme || 'auto',
trigger: dataset.trigger || '',
position: dataset.position || 'bottom-right',
label: dataset.label || "What's new",
width: cssLen(dataset.width),
height: cssLen(dataset.height),
radius: cssLen(dataset.radius),
// Unread dot is on by default; data-unread="off" (or "false") disables it.
unread: dataset.unread !== 'off' && dataset.unread !== 'false',
unreadColor: cssColor(dataset.unreadColor, '#e5484d'),
// Floating launcher colors (data-button-color/-text-color). Bound triggers
// use the host element's own styling, so these only affect the floating pill.
buttonColor: cssColor(dataset.buttonColor, '#111'),
buttonTextColor: cssColor(dataset.buttonTextColor, '#fff'),
};
}
function buildEmbedUrl(base, page, theme) {
var path = trimSlash(base) + withLeadingSlash(page);
// A data-page that already carries a query string (e.g. "/a?x=1") must keep
// embed/theme as separate params — otherwise the second "?" folds embed=1
// into the prior param's value and the modal silently renders full chrome.
var sep = path.indexOf('?') === -1 ? '?' : '&';
return path + sep + 'embed=1&theme=' + (theme || 'auto');
}
function buildMetaUrl(base) { return trimSlash(base) + '/changelog.json'; }
function storageKey(project) { return 'jd_seen_' + project; }
function isUnread(latestId, seenId) { return !!latestId && latestId !== seenId; }
var exported = { parseConfig: parseConfig, buildEmbedUrl: buildEmbedUrl, buildMetaUrl: buildMetaUrl, storageKey: storageKey, isUnread: isUnread, modalSize: modalSize };
// Inject the stylesheet once. Pseudo-classes (:hover/:focus-visible),
// ::backdrop, @keyframes, and prefers-reduced-motion can't live in inline
// styles — so the launcher + modal interactive states go here. Animations
// use transform/opacity only (compositor-friendly) and are disabled under
// prefers-reduced-motion.
function ensureStyles(doc) {
if (doc.getElementById('jd-widget-styles')) return;
var s = doc.createElement('style');
s.id = 'jd-widget-styles';
s.textContent =
'[data-jd-widget-launcher]{box-shadow:0 6px 20px rgba(15,23,42,.18);transition:transform .15s ease,box-shadow .15s ease}' +
'[data-jd-widget-launcher]:hover{transform:translateY(-1px);box-shadow:0 10px 28px rgba(15,23,42,.26)}' +
'[data-jd-widget-launcher]:active{transform:translateY(0)}' +
'[data-jd-widget-launcher]:focus-visible{outline:2px solid #2563eb;outline-offset:3px}' +
'[data-jd-widget-overlay]::backdrop{background:rgba(15,23,42,.45);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}' +
'[data-jd-widget-panel]{overscroll-behavior:contain}' +
'[data-jd-widget-close]{transition:background-color .15s ease,color .15s ease,transform .12s ease}' +
'[data-jd-widget-close]:hover{background:rgba(15,23,42,.10);color:#0f172a}' +
'[data-jd-widget-close]:active{transform:scale(.92)}' +
'[data-jd-widget-close]:focus-visible{outline:2px solid #2563eb;outline-offset:2px}' +
'@keyframes jd-widget-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}' +
'[data-jd-widget-overlay][open]{animation:jd-widget-in .18s ease-out}' +
'@media (prefers-reduced-motion:reduce){[data-jd-widget-overlay][open]{animation:none}' +
'[data-jd-widget-launcher],[data-jd-widget-close]{transition:none}}';
(doc.head || doc.documentElement).appendChild(s);
}
function createWidget(config, doc) {
doc = doc || document;
ensureStyles(doc);
var state = { latestId: null };
// Trigger: bind to an existing element, or render a floating launcher.
// An invalid customer-supplied selector must not abort init — querySelector
// throws a SyntaxError on a malformed selector, so degrade to the floating
// launcher (the null path below) instead of killing the whole widget.
var launcher = null;
if (config.trigger) {
try { launcher = doc.querySelector(config.trigger); } catch (_) { launcher = null; }
}
var floating = false;
if (!launcher) {
floating = true;
launcher = doc.createElement('button');
launcher.type = 'button';
launcher.textContent = config.label;
// Position: any of bottom-right (default), bottom-left, top-right, top-left.
// Horizontal + vertical edges are derived independently from the keyword so
// safe-area insets always match the corner the pill actually sits in.
var pos = config.position || 'bottom-right';
var horiz = pos.indexOf('left') !== -1
? 'left:calc(20px + env(safe-area-inset-left,0px));'
: 'right:calc(20px + env(safe-area-inset-right,0px));';
var vert = pos.indexOf('top') !== -1
? 'top:calc(20px + env(safe-area-inset-top,0px));'
: 'bottom:calc(20px + env(safe-area-inset-bottom,0px));';
launcher.style.cssText =
'position:fixed;z-index:2147483000;' + horiz + vert +
// padding + 18px line-height give a ~46px-tall pill, clearing the 44px
// minimum touch target (iOS HIG); safe-area insets keep it off notches.
// Shadow + hover/focus states live in ensureStyles().
'padding:14px 18px;border:0;border-radius:9999px;' +
'background:' + (config.buttonColor || '#111') + ';color:' + (config.buttonTextColor || '#fff') + ';' +
'font:600 14px/18px system-ui;cursor:pointer;' +
'display:none;'; // hidden until metadata confirms there's a changelog
doc.body.appendChild(launcher);
}
launcher.setAttribute('data-jd-widget-launcher', '');
launcher.setAttribute('aria-haspopup', 'dialog');
if (!launcher.getAttribute('aria-label')) launcher.setAttribute('aria-label', config.label);
function setUnread(on) {
var existing = launcher.querySelector('[data-jd-unread]');
if (on && !existing) {
var dot = doc.createElement('span');
dot.setAttribute('data-jd-unread', '');
dot.style.cssText = 'position:absolute;top:-2px;right:-2px;width:10px;height:10px;border-radius:9999px;background:' + (config.unreadColor || '#e5484d') + ';';
if (getComputedStyle(launcher).position === 'static') launcher.style.position = 'relative';
launcher.appendChild(dot);
} else if (!on && existing) { existing.remove(); }
}
function markSeen() {
try { if (state.latestId) localStorage.setItem(storageKey(config.project), state.latestId); } catch (_) {}
setUnread(false);
}
var dialog = null;
var prevFocus = null; // restore focus on close
var prevOverflow = ''; // restore body scroll on close
// ESC fallback only. The Tab focus-trap is intentionally NOT hand-rolled:
// showModal() gives spec-compliant focus containment, and the modal body is
// a cross-origin iframe a JS querySelectorAll trap can't see into anyway —
// the browser handles the iframe boundary natively, a JS trap can't.
function onKey(e) { if (e.key === 'Escape') close(); }
function close() {
if (!dialog) return;
try { dialog.close(); } catch (_) {} // no-op if never showModal'd
dialog.remove();
dialog = null;
doc.removeEventListener('keydown', onKey);
doc.body.style.overflow = prevOverflow; // unlock host-page scroll
if (prevFocus && prevFocus.focus) prevFocus.focus(); // restore focus (belt + braces; native <dialog> also restores)
}
function open() {
if (dialog) return;
prevFocus = doc.activeElement;
// Native <dialog> + showModal(): top-layer stacking (no z-index war with
// host UI), inert background, native focus containment, ESC, and focus
// restore — far more robust than a hand-rolled overlay div.
dialog = doc.createElement('dialog');
dialog.setAttribute('data-jd-widget-overlay', '');
dialog.setAttribute('aria-modal', 'true'); // redundant w/ showModal, explicit for older AT
dialog.setAttribute('aria-label', config.label);
// Modal size: data-width/height, capped responsive so a custom size can
// never overflow a phone (see modalSize).
var size = modalSize(config.width, config.height);
dialog.style.cssText = 'margin:auto;padding:0;border:0;background:transparent;width:' + size.width + ';height:' + size.height + ';overflow:visible;';
// Corner radius (data-radius, default 12px). The panel clips the iframe to
// these corners via overflow:hidden, so the embedded page's gradient and
// scrollbar reach the modal edge (no inner gutter — a gutter framed the page
// in white and pushed the scrollbar inward). Breathing room comes from the
// embed page's own content padding instead.
var radius = config.radius || '12px';
var panel = doc.createElement('div');
panel.setAttribute('data-jd-widget-panel', '');
panel.style.cssText = 'position:relative;width:100%;height:100%;background:#fff;border-radius:' + radius + ';overflow:hidden;box-shadow:0 24px 64px -12px rgba(15,23,42,.35);';
var closeBtn = doc.createElement('button');
closeBtn.type = 'button';
closeBtn.setAttribute('data-jd-widget-close', '');
closeBtn.setAttribute('aria-label', 'Close');
// A stroked SVG × reads cleaner than the "×" glyph at small sizes; states
// (hover/active/focus-visible) live in ensureStyles(). Built via DOM
// (createElementNS) rather than innerHTML — no markup parsing, no XSS surface.
var SVGNS = 'http://www.w3.org/2000/svg';
var svg = doc.createElementNS(SVGNS, 'svg');
svg.setAttribute('width', '14');
svg.setAttribute('height', '14');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('fill', 'none');
svg.setAttribute('aria-hidden', 'true');
var xPath = doc.createElementNS(SVGNS, 'path');
xPath.setAttribute('d', 'M4 4l8 8M12 4l-8 8');
xPath.setAttribute('stroke', 'currentColor');
xPath.setAttribute('stroke-width', '1.6');
xPath.setAttribute('stroke-linecap', 'round');
svg.appendChild(xPath);
closeBtn.appendChild(svg);
closeBtn.style.cssText = 'position:absolute;top:12px;right:12px;z-index:1;' +
'display:flex;align-items:center;justify-content:center;width:28px;height:28px;' +
'border:0;border-radius:9999px;background:rgba(15,23,42,.06);color:#475569;cursor:pointer;';
closeBtn.addEventListener('click', close);
var frame = doc.createElement('iframe');
frame.setAttribute('data-jd-widget-frame', '');
frame.setAttribute('title', config.label);
frame.src = buildEmbedUrl(config.base, config.page, config.theme);
// Fills the panel edge-to-edge; the panel's overflow:hidden clips the
// iframe's corners to the modal radius, so the page's gradient and scrollbar
// stay flush to the modal edge.
frame.style.cssText = 'width:100%;height:100%;border:0;display:block;';
panel.appendChild(closeBtn);
panel.appendChild(frame);
dialog.appendChild(panel);
// Light-dismiss: a backdrop click forwards its target to the <dialog>
// element itself; clicks on the panel/iframe/close button have a deeper
// target and don't close.
dialog.addEventListener('click', function (e) { if (e.target === dialog) close(); });
doc.body.appendChild(dialog);
prevOverflow = doc.body.style.overflow;
doc.body.style.overflow = 'hidden'; // lock host-page scroll behind modal
if (typeof dialog.showModal === 'function') {
try { dialog.showModal(); } catch (_) { dialog.setAttribute('open', ''); }
} else {
dialog.setAttribute('open', ''); // jsdom / very old UA fallback
}
doc.addEventListener('keydown', onKey); // ESC: jsdom (and the fallback path) don't dispatch the native cancel
closeBtn.focus(); // guarantee focus starts inside the dialog
markSeen();
}
launcher.addEventListener('click', function (e) { e.preventDefault(); open(); });
var ready = fetch(buildMetaUrl(config.base))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (meta) {
state.latestId = meta && meta.latest ? meta.latest.id : null;
var count = meta && typeof meta.count === 'number' ? meta.count : 0;
// Reveal the floating launcher only once metadata confirms there's a
// changelog. A customer-placed (bound) trigger is never hidden.
if (floating && count > 0) launcher.style.display = '';
var seen = null;
try { seen = localStorage.getItem(storageKey(config.project)); } catch (_) {}
// Show the dot unless explicitly disabled (config built by hand in tests
// / by integrators may omit `unread`; parseConfig always sets it true).
if (config.unread !== false) setUnread(isUnread(state.latestId, seen));
})
.catch(function () {
// Transient fetch failure: reveal the floating launcher anyway so a
// network blip doesn't permanently hide the trigger (no dot — unknown state).
if (floating) launcher.style.display = '';
});
return { ready: ready, open: open, close: close, destroy: close };
}
exported.createWidget = createWidget;
// Export for tests (browsers have no `module`); init only in a real page.
if (typeof module !== 'undefined' && module.exports) { module.exports = exported; }
// Auto-init: only in a real browser <script> context. Guarded so test
// imports (node/jsdom without currentScript) never run DOM code.
if (typeof document !== 'undefined' && document.currentScript) {
var el = document.currentScript;
if (!el.__jdWidgetInit) {
el.__jdWidgetInit = true;
createWidget(parseConfig(el.dataset || {}, el.src), document);
}
}
})();