-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathshell.js
More file actions
378 lines (331 loc) · 14.9 KB
/
Copy pathshell.js
File metadata and controls
378 lines (331 loc) · 14.9 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
/* ==========================================================================
game-console-ui-theme / shell.js
--------------------------------------------------------------------------
The whole shell in one file. Vanilla JS, zero dependencies, and the page
must remain fully readable without it: everything here is enhancement.
The contract with shell.css is the `tv-live` class on <html>; every
behaviour and every piece of ARIA this script adds is added here, at
enhance time, so the no-JS document never claims semantics it cannot
honour.
What the script does, in order:
1. flips the document into live mode
2. registers every [data-tv-view] and gives it dialog semantics
3. injects the back control (a control that does nothing must not
exist, so it cannot be static markup)
4. turns tile and in-content anchors into view openers
5. installs SPATIAL NAVIGATION on the arrow keys
6. keeps the focused tile on screen
7. binds Escape to backing out of an open view
8. rewrites the hint bar, because the true sentence changed
9. injects the ground picker and runs the clock
THE NAVIGATION MODEL, WHICH IS WHAT THIS REPO IS FOR.
Two ways to move, and the second one is complete on its own:
Arrow keys move focus SPATIALLY. Not by index, not by row membership:
by where things actually are on the screen, measured from live
bounding boxes at the moment the key is pressed. Left and right look
along the current line; up and down find the nearest line in that
direction and then the tile closest in x. Nothing wraps. Running into
an edge leaves focus exactly where it was, which is the honest answer
to "there is nothing over there" and the one a reader across a room
can actually feel.
Tab still walks every tile, in document order, exactly as it would if
this script had never run. There is no roving tabindex here, and that
is a deliberate departure from the usual grid-widget advice: roving
tabindex buys a shorter tab cycle by REMOVING everything but one tile
from it, and this shell would rather pay a long tab cycle than hand
the keyboard-only reader a navigation model they have to discover.
Arrows are a faster path over a surface that is already complete
without them.
Because the arrow model is pure geometry, it needs to know nothing about
the layout it is steering. The same function drives the wide dashboard,
where rows scroll sideways, and the narrow two-column grid, where they
do not; and it stays correct when two rows are scrolled to different
offsets, because "below" means below on the screen the reader is looking
at. The text index at the foot of the board is the same claim tested
once more: it carries data-tv-tile like everything else and the arrows
walk it without a line of code knowing it is not a picture.
Focus is managed at exactly two moments: opening a view moves focus into
it, backing out returns focus to the tile that opened it. Nothing is
trapped, ever.
========================================================================== */
(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("tv-live");
/* Read the motion preference live rather than once, so a reader who
changes it mid-session is honoured without a reload. The tokens carry
the CSS half of the same preference; this is the scrolling half. */
var motionQuery =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)");
function scrollStyle() {
return motionQuery && motionQuery.matches ? "auto" : "smooth";
}
var ICON_BACK =
'<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
'stroke-linejoin="round"><path d="M12 4l-6 6 6 6"/></svg>';
var ICON_GROUND =
'<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" ' +
'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" ' +
'stroke-linejoin="round"><rect x="2.5" y="3.5" width="15" height="13" ' +
'rx="2"/><path d="M10 3.5v13M2.5 10h15"/></svg>';
/* ------------------------------------------------------------------
2 + 3. Register views, add dialog semantics, inject the back control.
Non-modal dialogs: role="dialog" names the pattern. aria-modal is
deliberately absent. Nothing is trapped here; what keeps Tab out of
the covered browse surface is that the surface leaves the
accessibility tree with visibility:hidden, which is a fact about the
render rather than a claim in an attribute.
------------------------------------------------------------------ */
var views = [];
var byId = {};
var current = null;
Array.prototype.forEach.call(doc.querySelectorAll("[data-tv-view]"), function (el) {
var title = el.querySelector(".tv-view-title");
if (title && !title.id) title.id = el.id + "-title";
el.setAttribute("role", "dialog");
if (title) el.setAttribute("aria-labelledby", title.id);
el.tabIndex = -1;
var view = { el: el, id: el.id, opener: null };
views.push(view);
byId[el.id] = view;
var slot = el.querySelector("[data-tv-back]");
if (slot) {
var name = title ? title.textContent.trim() : el.id;
var btn = doc.createElement("button");
btn.type = "button";
btn.className = "tv-back";
btn.setAttribute("aria-label", "Back from " + name);
btn.innerHTML = ICON_BACK + "<span>Back</span>";
btn.addEventListener("click", function () {
close();
});
slot.appendChild(btn);
}
});
/* ------------------------------------------------------------------
4. Anchors become openers. Any link to #<view-id>, wherever it is (a
tile, a paragraph inside another view), opens that view.
------------------------------------------------------------------ */
doc.addEventListener("click", function (e) {
var a = e.target.closest ? e.target.closest('a[href^="#"]') : null;
if (!a) return;
var view = byId[a.getAttribute("href").slice(1)];
if (!view) return;
e.preventDefault();
open(view, a);
});
var rows = doc.querySelector("[data-tv-rows]");
var tiles = Array.prototype.slice.call(doc.querySelectorAll("[data-tv-tile]"));
function open(view, opener) {
if (current) {
/* Opening a view from inside another one: the reader's way back is
still the tile they started from, not a link that is about to stop
existing. The journey keeps its origin. */
if (opener && current.el.contains(opener)) opener = current.opener;
current.el.classList.remove("tv-open");
}
view.opener = opener || view.opener;
view.el.classList.add("tv-open");
view.el.scrollTop = 0;
if (rows) rows.setAttribute("data-tv-hidden", "");
current = view;
view.el.focus({ preventScroll: true });
setHint(HINT_VIEW);
}
function close() {
if (!current) return;
var view = current;
view.el.classList.remove("tv-open");
current = null;
/* Un-hide before focusing: you cannot move focus into something that is
still visibility:hidden. */
if (rows) rows.removeAttribute("data-tv-hidden");
setHint(HINT_BROWSE);
if (view.opener && isVisible(view.opener)) {
view.opener.focus();
} else if (tiles.length) {
tiles[0].focus();
}
}
function isVisible(el) {
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
/* ------------------------------------------------------------------
5. Spatial navigation.
centreOf reads the live box every time rather than caching a map at
load. Rows scroll, the layout reflows at 768px, and a cached map would
be wrong after either. Reading N boxes on a key press is cheap at this
scale and correct at every scale.
------------------------------------------------------------------ */
var DIRECTIONS = {
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down",
};
function centreOf(el) {
var r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, h: r.height };
}
/* Two tiles are "on the same line" when their centres are within this
much of each other vertically. Derived from the tile's own height, so
it survives every token change and both layouts. */
function band(from) {
return from.h * 0.6;
}
function neighbour(direction, fromEl) {
var from = centreOf(fromEl);
var horizontal = direction === "left" || direction === "right";
var tolerance = band(from);
var candidates = [];
tiles.forEach(function (el) {
if (el === fromEl) return;
var to = centreOf(el);
var dx = to.x - from.x;
var dy = to.y - from.y;
if (horizontal) {
/* Same line, and actually in the direction asked for. */
if (Math.abs(dy) > tolerance) return;
if (direction === "right" ? dx < 1 : dx > -1) return;
} else {
if (direction === "down" ? dy < 1 : dy > -1) return;
}
candidates.push({ el: el, dx: dx, dy: dy });
});
if (!candidates.length) return null;
if (horizontal) {
candidates.sort(function (a, b) {
return Math.abs(a.dx) - Math.abs(b.dx);
});
return candidates[0].el;
}
/* Vertical is two decisions, in this order, and the order is what makes
uneven rows behave. First: which line is nearest in the direction
asked for. Only then: which tile on that line is nearest in x. Score
the two together and a distant row with a perfectly aligned tile can
beat the adjacent row, which is exactly the bug that makes a
dashboard feel possessed. */
var nearest = Infinity;
candidates.forEach(function (c) {
nearest = Math.min(nearest, Math.abs(c.dy));
});
var line = candidates.filter(function (c) {
return Math.abs(c.dy) - nearest <= tolerance;
});
line.sort(function (a, b) {
return Math.abs(a.dx) - Math.abs(b.dx);
});
return line[0].el;
}
/* ------------------------------------------------------------------
6. Keeping the focused tile on screen. One listener per tile rather
than logic inside the arrow handler, so a tile reached by Tab, by
pointer, or by returning from a view gets the same treatment.
inline:"center" is what makes a row feel driven rather than dragged;
block:"nearest" scrolls the column only when it has to.
------------------------------------------------------------------ */
tiles.forEach(function (el) {
el.addEventListener("focus", function () {
el.scrollIntoView({
block: "nearest",
inline: "center",
behavior: scrollStyle(),
});
});
});
/* ------------------------------------------------------------------
7 + 5b. The key handler. Escape backs out; arrows steer, but only when
focus is on a tile and no view is covering the board. Inside a view the
arrow keys keep their ordinary meaning, which is to scroll the thing
with focus, and the view is the thing with focus.
------------------------------------------------------------------ */
doc.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
if (current) close();
return;
}
var direction = DIRECTIONS[e.key];
if (!direction) return;
if (current) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
var from = doc.activeElement;
if (!from || tiles.indexOf(from) === -1) return;
/* Claimed either way. At an edge there is nowhere to go, and letting
the browser scroll the board out from under a ring that did not move
is worse than doing nothing. */
e.preventDefault();
var next = neighbour(direction, from);
if (next) next.focus();
});
/* ------------------------------------------------------------------
8. The hint bar. The static sentence in the markup is the one that is
true without this script: Tab and Enter, both of which the browser
provides. These two are true only now.
------------------------------------------------------------------ */
var HINT_BROWSE =
'<span class="tv-hint-key">Arrow keys</span> move · ' +
'<span class="tv-hint-key">Enter</span> opens · ' +
'<span class="tv-hint-key">Tab</span> steps through the whole board';
var HINT_VIEW =
'<span class="tv-hint-key">Esc</span> goes back · ' +
'<span class="tv-hint-key">Tab</span> steps through this page';
var hint = doc.querySelector("[data-tv-hint]");
function setHint(html) {
if (hint) hint.innerHTML = html;
}
setHint(HINT_BROWSE);
/* ------------------------------------------------------------------
9a. The ground. The field is a slot, and this script's whole
involvement is one attribute on the body and one button. All of the
styling lives in shell.css under body[data-ground]. The enhanced
dashboard defaults to the panes scene; the no-JS document gets the
flat field from plain CSS because this line never runs. Per session
only, no storage: a demo should greet everyone the same way.
------------------------------------------------------------------ */
var GROUNDS = ["panes", "flat"];
var tray = doc.querySelector("[data-tv-tray]");
doc.body.setAttribute("data-ground", GROUNDS[0]);
if (tray) {
var groundBtn = doc.createElement("button");
groundBtn.type = "button";
groundBtn.className = "tv-tray-btn";
groundBtn.innerHTML = ICON_GROUND;
var labelGround = function () {
groundBtn.setAttribute(
"aria-label",
"Switch the ground, now " + doc.body.getAttribute("data-ground")
);
};
labelGround();
groundBtn.addEventListener("click", function () {
var now = doc.body.getAttribute("data-ground");
var next = GROUNDS[(GROUNDS.indexOf(now) + 1) % GROUNDS.length];
doc.body.setAttribute("data-ground", next);
labelGround();
});
tray.insertBefore(groundBtn, tray.firstChild);
}
/* ------------------------------------------------------------------
9b. The clock. Every dashboard built for a screen in a room has one,
for the same reason every oven does: you look up at it. It is also the
only thing on this page that changes on its own, which is why the
capture commands in README.md pin it.
------------------------------------------------------------------ */
var clock = doc.querySelector("[data-tv-clock]");
if (clock) {
var tick = function () {
var d = new Date();
var h = String(d.getHours());
var m = String(d.getMinutes());
clock.textContent =
(h.length < 2 ? "0" + h : h) + ":" + (m.length < 2 ? "0" + m : m);
};
tick();
setInterval(tick, 30000);
}
})();