-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathshell.js
More file actions
571 lines (506 loc) · 26 KB
/
Copy pathshell.js
File metadata and controls
571 lines (506 loc) · 26 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
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
/* ==========================================================================
vivaocean-theme / shell.js
--------------------------------------------------------------------------
The whole shell in one file. Vanilla JS, zero dependencies, and every page
must remain a complete, navigable site without it: everything here is
enhancement. The contract with shell.css is the `vo-live` class on <html>;
every behavior and every piece of ARIA this script adds is added here, at
enhance time, so a document with no script never claims semantics or
controls it cannot honor.
What the script does, in order:
1. flips the document into live mode
2. reveals the ocean
3. injects the title bar tray: the menu button, the scene switch and
the close control
4. injects the launcher, which is what the desk has on it once the
window is closed
5. makes the window body a focusable scroll region
6. installs the click navigator, and the swap half of it if the page is
being served over HTTP
7. binds Escape to the menu
8. keeps the rails' aria-current honest
ON CLOSING, WHICH IS THE OTHER PART WORTH READING.
This shell has two states. Window-open is the site. Desk-clear is the
window closed: the scene with nothing in front of it, the rails still in
the water at 1024 and up, and one launcher at the top left. The close
control in the title bar is what moves between them, and three separate
things bring the window back, each meaning what its own surface has always
meant:
a rail icon opens the window AT THAT PAGE. That is not a new
behavior invented for this state, it is what a rail
item has always done; the only difference is that the
window it lands in was not on the screen a moment ago.
the launcher opens the window AT HOME. It is a launcher, and a
launcher opens an application at its front door.
any arrival opens the window. A swap, a popstate, anything that
puts content in the window opens the window, because
content arriving into something the reader cannot see
is the one outcome none of these paths should produce.
THERE IS NO MINIMIZE, and now that there is a close it is worth saying why
rather than leaving it as a gap. Minimize means "keep this window, put it
somewhere I can get it back from, in the state I left it". That needs a
restore surface, and this shell has none: no taskbar, no window list,
because one window makes a list of one, which is a label. Without it,
minimize and close would leave the reader on the same screen and be undone
by the same gesture, which is two controls with one behavior. And the
launcher deliberately does NOT restore the page you closed: a control that
gave you back exactly what you had would be the minimize this shell does
not have, wearing the launcher's icon.
ON ESCAPE. Escape closes the menu if the menu is open. That is all it
does, in both states, and the arrival of a close button did not change it.
The menu panel is the only transient thing this shell has; the window is
not transient, it is the site. Closing it is a labelled control with a
name on it, not a key readers press to get out of things. On a clear desk
there is nothing left to dismiss, so Escape is a no-op, and that is a
deliberate no-op rather than an unhandled one.
ON NAVIGATION, WHICH IS THE PART WORTH READING.
Every page in this theme is a real static HTML document that already
wears the whole shell: the scene, both rails, the window and its chrome.
Navigation with no script is therefore not a fallback, it is the site:
ordinary links, ordinary page loads, every URL real, crawlable and
linkable.
What this script adds is that a click on a link to another page of the
site fetches that page, lifts its window body out of the response, and
puts it into the window that is already on screen. The water never
reloads, so it never blinks, which is the entire reason the swap exists.
The history entry is pushed properly, popstate is honored, the document
title changes, focus moves into the new content, and a live region says
what arrived, because none of those things happen by themselves when the
browser is not the one navigating.
THE SWAP IS SKIPPED ENTIRELY OVER file://, on purpose. A fetch from a
file:// document is blocked, so a swap there could only ever fail and
fall back. Rather than attempt one and stall on every click, the script
checks the protocol once and, off HTTP, simply does not install the
interceptor: every link is left alone and the site works by page loads.
Open the demo from a local server to see the swap; open it from disk and
you are looking at the same site with a different transport.
========================================================================== */
(function () {
"use strict";
var doc = document;
var root = doc.documentElement;
/* 1. Live mode. Everything shell.css does differently, it does under this
class. If the script fails to run, this line never happens and the page
stays a page. */
root.classList.add("vo-live");
/* 2. The ocean. The ground is a slot; this script's whole involvement is
one attribute on the body. Every visual lives in scene.css under
body[data-scene]. The enhanced default is the animated scene, because a
VivaOcean that opens on a flat gradient has not shown you anything; the
no-JS document gets calm from plain CSS, because this line never runs.
Per-session only, no storage: a demo should greet everyone the same
way. */
var SCENES = ["ocean", "calm"];
doc.body.setAttribute("data-scene", SCENES[0]);
var ICON_MENU =
'<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round">' +
'<path d="M3 5.5h14M3 10h14M3 14.5h14"/></svg>';
var ICON_SCENE =
'<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" ' +
'stroke="currentColor" stroke-width="1.7" stroke-linecap="round" ' +
'stroke-linejoin="round">' +
'<path d="M2 7.4c1.6-1.6 3.2-1.6 4.8 0s3.2 1.6 4.8 0 3.2-1.6 4.4 0"/>' +
'<path d="M2 11.2c1.6-1.6 3.2-1.6 4.8 0s3.2 1.6 4.8 0 3.2-1.6 4.4 0"/>' +
'<path d="M2 15c1.6-1.6 3.2-1.6 4.8 0s3.2 1.6 4.8 0 3.2-1.6 4.4 0"/></svg>';
var ICON_CLOSE =
'<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" ' +
'stroke="currentColor" stroke-width="1.8" stroke-linecap="round">' +
'<path d="M5.5 5.5l9 9M14.5 5.5l-9 9"/></svg>';
/* Three bubbles on their way up, largest last, drifting the way a bubble
drifts rather than rising in a column. The mark for the launcher: this
ocean's own sign that something is coming back to the surface. Filled
rather than stroked, because at 26px a stroked circle of this size is a
ring and a ring is not a bubble; the two small ones are stroked at a
weight that keeps them from turning into dots. */
var ICON_RISE =
'<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" ' +
'stroke="currentColor" stroke-width="1.5">' +
'<circle cx="14" cy="16.5" r="5" fill="currentColor" stroke="none"/>' +
'<circle cx="7.5" cy="9.5" r="2.6"/>' +
'<circle cx="12.5" cy="4.5" r="1.6"/></svg>';
var win = doc.querySelector("[data-vo-window]");
var bodyEl = doc.querySelector("[data-vo-swap]");
var titleEl = doc.querySelector("[data-vo-title]");
var tray = doc.querySelector("[data-vo-tray]");
var rails = doc.querySelector("[data-vo-rails]");
var brand = doc.querySelector(".vo-brand");
/* ------------------------------------------------------------------
3. The tray. Three controls, all real, all injected because none of
them means anything without this script. There is still no minimize and
no maximize; the header above argues both deletions.
Order: menu, water, close. Close is last because that is where a window
has kept it for as long as windows have had one, and because it is the
control with the largest consequence in the strip, which is a reason to
put it at an end rather than between two others.
------------------------------------------------------------------ */
var menuBtn = null;
if (tray && rails) {
if (!rails.id) rails.id = "vo-rails";
/* "Browse by section", not "Menu". Below 1024 this button now sits
directly above a visible menu bar carrying every page as a text link,
and two surfaces competing for the word "menu" is a question the reader
has to answer before either one works. The button opens the rails, and
what the rails have that the strip does not is the two named groups, so
the label says that. Same fix as "Close the window": name the object.
The two surfaces are deliberately kept BOTH, and the reasoning is in
the README beside the launcher ruling. The short version: at 1280 the
rails (seven icons) and the menu bar (nine text links) already coexist
and that redundancy is the whole of class decision 44. Deleting the
rails at 390 because a guarantee layer arrived would leave the width
most readers use with a safety net and no metaphor, which is the theme
inverted. */
menuBtn = button("vo-tray-btn vo-menu-btn", ICON_MENU, "Browse by section");
menuBtn.setAttribute("aria-expanded", "false");
menuBtn.setAttribute("aria-controls", rails.id);
menuBtn.addEventListener("click", function () {
setMenu(menuBtn.getAttribute("aria-expanded") !== "true");
});
tray.appendChild(menuBtn);
}
if (tray) {
var sceneBtn = button("vo-tray-btn", ICON_SCENE, "");
var labelScene = function () {
sceneBtn.setAttribute(
"aria-label",
"Water, currently " +
doc.body.getAttribute("data-scene") +
". Switch it."
);
};
labelScene();
sceneBtn.addEventListener("click", function () {
var cur = doc.body.getAttribute("data-scene");
var next = SCENES[(SCENES.indexOf(cur) + 1) % SCENES.length];
doc.body.setAttribute("data-scene", next);
labelScene();
});
tray.appendChild(sceneBtn);
/* "Close the window", not "Close". The bare verb is the operating
system's word for it and it is the one word this control should not
borrow: on a site with one window, a reader has every reason to read a
bare Close as an offer to close the tab. Naming the object is the whole
fix and it costs eleven characters. */
var closeBtn = button("vo-tray-btn vo-close-btn", ICON_CLOSE, "Close the window");
closeBtn.addEventListener("click", function () {
setDesk(true);
});
tray.appendChild(closeBtn);
}
/* ------------------------------------------------------------------
4. The launcher. Injected, like everything else here, because with no
script there is no close control, so there is no state for it to be the
way out of, so it would be a button that does nothing.
It is put FIRST in the body rather than appended, and that is the whole
of its tab-order guarantee: on a clear desk the skip link is hidden
(its target is inside the closed window), so first in the document is
first in the tab order, with no tabindex bookkeeping and nothing to keep
in sync. It carries no aria-expanded and no aria-controls: it is not a
disclosure, it opens a window.
The label names the site rather than the window, because the site is
what the reader is getting back and "Open VivaOcean" is a sentence a
launcher can say. The name is read from the brand link in the title bar
rather than written here, so a consuming site gets its own name for
free; a shell with no brand link gets the honest generic instead.
------------------------------------------------------------------ */
var siteName = brand ? brand.textContent.trim() : "";
var launcher = doc.createElement("button");
launcher.type = "button";
launcher.className = "vo-launch";
launcher.setAttribute("data-vo-launch", "");
launcher.innerHTML =
'<span class="vo-launch-mark">' + ICON_RISE + "</span><span></span>";
launcher.lastChild.textContent = siteName ? "Open " + siteName : "Open the window";
launcher.addEventListener("click", function () {
/* Home, not the page you closed. The header says why: restoring the page
would be a minimize, and there is no minimize. */
var home = brand ? brand.getAttribute("href") : null;
setDesk(false);
if (home && !samePath(home)) {
if (canSwap) go(new URL(home, location.href).href, true);
else location.href = home;
return;
}
focusWindow();
});
doc.body.insertBefore(launcher, doc.body.firstChild);
function button(cls, svg, label) {
var b = doc.createElement("button");
b.type = "button";
b.className = cls;
if (label) b.setAttribute("aria-label", label);
b.innerHTML = svg;
return b;
}
function setMenu(openIt) {
if (!menuBtn || !rails) return;
var was = menuBtn.getAttribute("aria-expanded") === "true";
menuBtn.setAttribute("aria-expanded", openIt ? "true" : "false");
rails.classList.toggle("vo-open", openIt);
/* Focus comes back to the button that opened the panel, and only when
the panel was actually open; closing something that was already
closed must not move focus. */
if (!openIt && was) menuBtn.focus();
}
/* The two states, and the one function that moves between them.
Focus is the part that has to be right, and it is the same rule in both
directions: focus follows the thing that appeared. Closing makes the
launcher appear, so focus goes to the launcher — which is also the only
control left at the widths with no rails, so leaving focus on a button
that has just been hidden would strand a keyboard reader on a clear
desk with nothing selected. Opening makes the window appear, so focus
goes into the window, and that is exactly what a swap already does, so
the two paths agree by using the same function rather than by matching.
setDesk(false) deliberately does NOT move focus. Every caller knows more
than this function does about where the reader should end up: a
same-page reopen has nowhere else to send them, and a reopen that is
about to swap must not focus a window whose content is still arriving.
The one exception is closing, which has exactly one honest destination
and takes it here.
`vo-desk-open` is a one-shot class rather than a permanent one: it turns
on the settle animation for the window's return and is taken off again
when that animation ends, so the animation cannot fire a second time on
an unrelated style change, and a closed-then-opened window is in exactly
the class list a never-closed one is in. That last property is what
makes the hash-equality proof in the matrix mean something. */
function setDesk(clear) {
if (root.classList.contains("vo-desk") === !!clear) return;
if (clear) {
setMenu(false);
root.classList.remove("vo-desk-open");
root.classList.add("vo-desk");
launcher.focus();
} else {
root.classList.remove("vo-desk");
root.classList.add("vo-desk-open");
if (win) {
/* Not { once: true }. The swap's own settle runs on the window body
and animationend bubbles, so a reopen that is followed by a swap
would spend the one-shot listener on the wrong element's event and
leave the class on. Match the target instead, and unbind by hand. */
win.addEventListener("animationend", function onEnd(e) {
if (e.target !== win) return;
win.removeEventListener("animationend", onEnd);
root.classList.remove("vo-desk-open");
});
}
}
}
function focusWindow() {
if (bodyEl) bodyEl.focus({ preventScroll: true });
}
/* Same document, by path rather than by string: "index.html", "./index.html"
and an absolute path all name the same page and only one of them matches
a naive comparison. */
function samePath(href) {
try {
return new URL(href, location.href).pathname === location.pathname;
} catch (err) {
return false;
}
}
/* ------------------------------------------------------------------
5. The window body is the scroll region, which means it has to be
keyboard-scrollable, which means focusable. It also becomes the
element focus lands on after a swap. Both jobs, one element.
------------------------------------------------------------------ */
if (bodyEl) {
bodyEl.tabIndex = 0;
if (titleEl) bodyEl.setAttribute("aria-label", titleEl.textContent.trim());
}
/* The live region. A swap changes the page without a page load, so
nothing announces it. This does. */
var announce = doc.createElement("p");
announce.className = "vo-announce";
announce.setAttribute("role", "status");
announce.setAttribute("aria-live", "polite");
doc.body.appendChild(announce);
/* ------------------------------------------------------------------
6. The click navigator, and the swap.
ONE LISTENER, TWO JOBS, and they are in this order for a reason. The
swap half only installs over HTTP, because a fetch from a file:// page
is blocked and a swap there could only ever fail; the reopening half
installs always, because closing the window is available over file://
too and a link that could not put the reader back in front of the
content it just loaded would be worse than no close control at all.
The interesting case is a rail click on the page you are already on
while the desk is clear. There is nothing to fetch — worthIntercepting
says so, correctly, and would hand the click to the browser, which would
reload the whole document and take the ocean down with it. That reload
is the exact event this shell's navigation model exists to avoid, and it
would be triggered by the cheapest possible action. So the desk branch
answers it first and returns.
------------------------------------------------------------------ */
var canSwap =
(location.protocol === "http:" || location.protocol === "https:") &&
typeof window.fetch === "function" &&
!!bodyEl;
var pending = 0;
if (canSwap) history.replaceState({ vo: 1 }, "", location.href);
doc.addEventListener("click", function (e) {
var a = e.target.closest ? e.target.closest("a[href]") : null;
if (!a) return;
if (root.classList.contains("vo-desk")) {
var url;
try {
url = new URL(a.getAttribute("href"), location.href);
} catch (err) {
return;
}
/* Somewhere else entirely: leave it alone. A link off this site is not
a request to reopen this site's window. */
if (url.origin !== location.origin || !/\.html$/.test(url.pathname)) return;
/* A rail icon means the page it names, which is what it has always
meant. The window comes back around that page. */
setDesk(false);
if (samePath(a.getAttribute("href"))) {
e.preventDefault();
setMenu(false);
focusWindow();
return;
}
}
if (!canSwap || !worthIntercepting(a, e)) return;
e.preventDefault();
setMenu(false);
go(a.href, true);
});
if (canSwap) {
window.addEventListener("popstate", function () {
go(location.href, false);
});
}
function worthIntercepting(a, e) {
if (e.defaultPrevented || e.button !== 0) return false;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return false;
if (a.hasAttribute("download")) return false;
if (a.target && a.target !== "_self") return false;
var url;
try {
url = new URL(a.getAttribute("href"), location.href);
} catch (err) {
return false;
}
if (url.origin !== location.origin) return false;
/* Only this site's own pages. Anything else, including an in-page
anchor and anything that is not a document, is left to the browser,
which is better at it. */
if (!/\.html$/.test(url.pathname)) return false;
if (url.pathname === location.pathname) return false;
return true;
}
function go(href, push) {
var token = ++pending;
fetch(href, { credentials: "same-origin" })
.then(function (res) {
if (!res.ok) throw new Error(String(res.status));
return res.text();
})
.then(function (html) {
if (token !== pending) return; /* a later click won */
var next = new DOMParser().parseFromString(html, "text/html");
var content = next.querySelector("[data-vo-swap]");
if (!content) throw new Error("no window body");
if (push) history.pushState({ vo: 1 }, "", href);
apply(content, next);
})
.catch(function () {
/* Any failure at all hands the click back to the browser. The page
being asked for is a real document, so a full load always works;
the swap was only ever the fast road to the same place. */
if (token === pending) location.href = href;
});
}
function apply(content, nextDoc) {
var nextTitle = nextDoc.querySelector("[data-vo-title]");
var name = nextTitle ? nextTitle.textContent.trim() : nextDoc.title;
/* Content arriving opens the window, whatever asked for it: a rail click
from a clear desk, the launcher, or a popstate from the reader pressing
Back with the window closed. Putting it here rather than at each caller
is what makes that true of paths nobody has thought of yet, and the
alternative — content landing in something the reader cannot see — is
the one outcome none of them should be able to produce. */
setDesk(false);
bodyEl.innerHTML = content.innerHTML;
if (titleEl && nextTitle) titleEl.textContent = nextTitle.textContent;
doc.title = nextDoc.title;
bodyEl.setAttribute("aria-label", name);
bodyEl.scrollTop = 0;
markCurrent();
/* Restart the settle animation on content that is already in the DOM.
Removing the class, forcing a reflow and adding it back is the whole
trick, and reading offsetWidth is what forces the reflow. */
bodyEl.classList.remove("vo-swapped");
void bodyEl.offsetWidth;
bodyEl.classList.add("vo-swapped");
/* Focus goes into the new content, because that is where the reader
now is. The ring only draws for keyboard users, so a mouse click on
a rail item does not outline the page. Same call the reopen path uses,
so a swap and a same-page reopen cannot drift apart. */
focusWindow();
announce.textContent = name + " loaded.";
/* The hook. Anything on a page that needs to run again after its
markup is replaced listens for this. The demo in this repository
deliberately needs nothing: its one interactive control, the pricing
toggle, is a radio group styled with CSS and therefore survives a
swap, and works with no script at all. The event exists for the
sites that will not be so lucky. */
doc.dispatchEvent(
new CustomEvent("vo:swap", { detail: { url: location.href } })
);
}
/* ------------------------------------------------------------------
7. Escape. One meaning, and only one, in both states.
Window open: closes the menu if the menu is open, and otherwise does
nothing. Desk clear: the menu cannot be open, because the button that
opens it is inside the window that is not on the screen, so this is a
deliberate no-op and the same line of code produces it. Escape does not
close the window and does not reopen it. The close control has a name on
it; a key that did the same job silently would be a second, unlabelled
copy of the largest action in the shell.
------------------------------------------------------------------ */
doc.addEventListener("keydown", function (e) {
if (e.key !== "Escape") return;
if (menuBtn && menuBtn.getAttribute("aria-expanded") === "true") {
setMenu(false);
}
});
/* ------------------------------------------------------------------
8. Where you are. The static markup carries aria-current for the page
it was served as, which is correct on arrival and correct with no
script. After a swap it is stale, so this rewrites it from the URL.
Both navigation surfaces at once: the rails and the menu bar. They are
two ways to the same nine documents, so they must never disagree about
which one you are on, and the way to guarantee that is to write them
from the same source in the same pass rather than to remember.
------------------------------------------------------------------ */
function markCurrent() {
var items = doc.querySelectorAll(".vo-rail-item, .vo-menu-item");
Array.prototype.forEach.call(items, function (a) {
var here = false;
try {
here = new URL(a.getAttribute("href"), location.href).pathname ===
location.pathname;
} catch (err) {
here = false;
}
if (here) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
});
}
/* The window element itself still gets no ARIA. It is a plain container
with no role, deliberately: the <main> inside it is already the landmark
a screen reader user navigates to, and giving the wrapper a label as well
would put a second, emptier stop in front of it. What `win` is for now is
one animationend listener, which is a style concern rather than a
semantic one. Closing the window is not announced either, and that is a
decision rather than an omission: the live region exists because a swap
changes the page under a reader who did not ask the browser for it, and
a reader who presses a button labelled "Close the window" has been told
what is about to happen by the control they pressed.
The one thing worth saying about the markup shape: the window body is a
real <main>, which is why the window wrapper is a <div>. <main> may not
be a descendant of <article>, so the temptation to make the window an
article has to be resisted, or the landmark inside it is invalid. */
markCurrent();
})();