Repository navigation
Expand file tree
/
Copy pathscript.js
More file actions
230 lines (217 loc) · 11.1 KB
/
Copy pathscript.js
File metadata and controls
230 lines (217 loc) · 11.1 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
/* Progressive enhancements: the scrolled header, mobile menu, product tour,
billing switch, card lighting and screenshot lightbox. index.html,
changelog/index.html and 404.html share this file, so each part first
checks that its elements exist. Content and purchase links also work
without JS. */
// Anonymous IIFE() runs once when the deferred script loads and keeps every
// name out of the global scope.
(function () {
"use strict";
// root is <html>; the tour adds .tabs-ready to it.
var root = document.documentElement;
// Header: switch to the dark glass capsule once the page leaves the top.
var header = document.querySelector(".top");
// Skip a page without the shared header.
if (header) {
// syncHeader() sets .scrolled on the header when the page is more than
// 8px from the top, and clears it otherwise.
function syncHeader() {
header.classList.toggle("scrolled", window.scrollY > 8);
}
// Sync now, on scroll, and on pageshow, since the browser may restore a
// scroll position after this script has run.
syncHeader();
window.addEventListener("scroll", syncHeader, { passive: true });
window.addEventListener("pageshow", syncHeader);
}
// Mobile menu: a <details> disclosure that opens and closes without JS.
// Close it after a link click, an outside click, or Escape.
var menu = document.querySelector(".menu");
// 404.html has no menu.
if (menu) {
// Link click handler() closes the menu, so the open sheet does not cover
// the section an in-page link scrolls to.
menu.querySelectorAll("a").forEach(function (link) {
link.addEventListener("click", function () { menu.open = false; });
});
// Document click handler(event) closes the menu on any click outside it.
// Clicks on the summary are inside, so its native toggle still works.
document.addEventListener("click", function (event) {
if (!menu.contains(event.target)) menu.open = false;
});
// Document keydown handler(event) closes the menu on Escape and returns
// focus to its summary, so keyboard users keep their place.
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && menu.open) {
// Act only while the menu is open; otherwise Escape leaves focus alone.
menu.open = false;
menu.querySelector("summary").focus();
}
});
}
// Product tour. The callback(tour) makes one [data-tabs] block work as
// ARIA tabs. Without JS, .tabs-ready is never set, so CSS hides the tab
// strip and shows every panel in order.
document.querySelectorAll("[data-tabs]").forEach(function (tour) {
// Tabs and panels pair up by index, so the markup must keep them in the
// same order. strip is the scrollable .seg row that also draws the thumb.
var tabs = Array.prototype.slice.call(tour.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(tour.querySelectorAll('[role="tabpanel"]'));
var strip = tabs[0].parentElement;
var current = 0;
// place() moves the sliding thumb (.seg::before) under the selected tab.
// offsetLeft is measured from .seg, the box the thumb is positioned in.
function place() {
var tab = tabs[current];
strip.style.setProperty("--thumb-x", tab.offsetLeft + "px");
strip.style.setProperty("--thumb-w", tab.offsetWidth + "px");
}
// select(index, [focus]) shows the panel at index and marks its tab as
// selected. Only the selected tab is in the tab order (roving tabindex).
// Pass focus as true to also move keyboard focus to that tab.
function select(index, focus) {
current = index;
// Update each tab and panel pair: ARIA state, tab order, visibility.
tabs.forEach(function (tab, i) {
var selected = index === i;
tab.setAttribute("aria-selected", selected ? "true" : "false");
tab.tabIndex = selected ? 0 : -1;
panels[i].classList.toggle("on", selected);
});
// Keyboard selection moves focus with it; clicks leave focus alone.
if (focus) tabs[index].focus();
place();
// Keep the selected tab visible in a strip that scrolls on narrow
// screens. Setting scrollLeft moves only the strip, not the page.
// left and right are measured from the first tab.
var left = tabs[index].offsetLeft - tabs[0].offsetLeft;
var right = left + tabs[index].offsetWidth;
// The tab starts left of the visible area: scroll back to it.
if (left < strip.scrollLeft) strip.scrollLeft = left;
else if (right > strip.scrollLeft + strip.clientWidth) {
// The tab ends past the right edge: scroll until its end shows.
strip.scrollLeft = right - strip.clientWidth;
}
}
// Wire each tab. The callback(tab, index) adds its click and keydown
// handlers.
tabs.forEach(function (tab, index) {
// Click handler() selects the tab; the browser handles focus.
tab.addEventListener("click", function () { select(index, false); });
// Keydown handler(event) moves the selection with the arrow keys,
// wrapping at the ends, and with Home and End, as in the WAI-ARIA tabs
// pattern. The new tab is selected and focused at once.
tab.addEventListener("keydown", function (event) {
var next = index;
// ArrowRight: the next tab, wrapping from the last to the first.
if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
// ArrowLeft: the previous tab, wrapping from the first to the last.
else if (event.key === "ArrowLeft") next = (index + tabs.length - 1) % tabs.length;
// Home: the first tab.
else if (event.key === "Home") next = 0;
// End: the last tab.
else if (event.key === "End") next = tabs.length - 1;
// Any other key, such as Tab or Enter, keeps its default action.
else return;
// Stop the key from scrolling the strip or the page.
event.preventDefault();
select(next, true);
});
});
// Switch the CSS from stacked panels to tabs, then show the first panel.
root.classList.add("tabs-ready");
select(0, false);
// Tab widths change when the layout reflows or web fonts load, so move
// the thumb again then. Browsers without these APIs skip the update.
// Reflows: the strip is as wide as its labels, up to the full width, so
// most width changes resize it.
if (window.ResizeObserver) new ResizeObserver(place).observe(strip);
// Fonts: a strip already at full width keeps its size while its labels
// change width, so the observer above does not fire.
if (document.fonts) document.fonts.ready.then(place);
});
// Pricing billing switch. Without JS the fieldset stays hidden and the
// static .annual-option link offers the yearly plan. The purchase link
// goes to the product page for the matching Virtualmin Pro subscription.
// Prices and URLs here must match the pricing card, FAQ and JSON-LD in
// index.html.
var billing = document.querySelector(".billing");
// Only index.html has the pricing card.
if (billing) {
var price = document.querySelector("[data-price]");
var period = document.querySelector("[data-period]");
var terms = document.querySelector("[data-terms]");
var purchase = document.querySelector("[data-purchase]");
// updateBilling() shows the price, period, terms and purchase link for
// the checked billing period. It reads the radios on each call, so it
// also picks up form state the browser restores.
function updateBilling() {
var annual = billing.querySelector('input:checked').value === "annual";
price.textContent = annual ? "$75" : "$7.50";
period.textContent = annual ? "/ year" : "/ month";
terms.textContent = annual ? "Billed yearly. For 10 managed domains." : "Billed monthly. For 10 managed domains.";
purchase.href = "https://www.virtualmin.com/product/virtualmin-professional-" + (annual ? "annual" : "monthly") + "-subscription/";
}
// Update on change, now, and on pageshow, when restored form state may
// differ from the default monthly choice.
billing.addEventListener("change", updateBilling);
window.addEventListener("pageshow", updateBilling);
updateBilling();
// Show the switch and hide the static yearly link it replaces.
billing.hidden = false;
document.querySelector(".annual-option").hidden = true;
}
// Card lighting, only for a precise pointer that can hover. Touch input
// skips it.
if (window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
document.querySelectorAll(".card").forEach(function (card) {
// Pointermove handler(event) stores the pointer position inside the
// card in --mx and --my, which center the .card::before light.
card.addEventListener("pointermove", function (event) {
var box = card.getBoundingClientRect();
card.style.setProperty("--mx", event.clientX - box.left + "px");
card.style.setProperty("--my", event.clientY - box.top + "px");
});
});
}
// Screenshot lightbox. Each .shot links to its full-size image, so without
// JS or <dialog> support the link opens the file directly. Modified clicks
// keep normal link behavior; the native dialog handles Escape and focus.
var lightbox = document.querySelector("dialog.lightbox");
// Enhance only where the page has the dialog and the browser supports it.
if (lightbox && typeof lightbox.showModal === "function") {
var large = lightbox.querySelector("img");
// Document click handler(event) opens a clicked screenshot in the
// lightbox. It is delegated, so one listener covers every .shot preview
// and "Expand screenshot" caption link.
document.addEventListener("click", function (event) {
var shot = event.target.closest(".shot");
var expand = event.target.closest(".shot-expand");
// Caption links open the same image as the preview itself.
if (!shot && expand) shot = expand.closest("figure").querySelector(".shot");
// Ignore other clicks. Let a non-primary or modified click (for
// example Ctrl or Cmd for a new tab) follow the link as usual.
if (!shot || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
var image = shot.querySelector("img");
// A .shot without an image has nothing to show; let the link work.
if (!image) return;
event.preventDefault();
// image.src is the full 2200px export; srcset only picks the preview
// size, so currentSrc may be the smaller file.
large.src = image.src;
large.alt = image.alt;
lightbox.showModal();
// Open each screenshot at its top-left corner, not where the previous
// one was scrolled to. Reset after showModal; a closed dialog cannot
// scroll.
lightbox.scrollTop = 0;
lightbox.scrollLeft = 0;
});
// Lightbox click handler(event) closes the dialog on a backdrop click
// (the target is the dialog itself) or on the close button. Clicks on
// the image keep it open.
lightbox.addEventListener("click", function (event) {
if (event.target === lightbox || event.target.closest("button")) lightbox.close();
});
}
})();