@ghub/identity-chrome 0.1.0 → 0.2.0

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.
@@ -0,0 +1,689 @@
1
+ "use strict";
2
+ /**
3
+ * `<gp-apps>`, the apps picker (GPlatform Identity, sheet 31, option B).
4
+ *
5
+ * A nine dot trigger and, under it, the products this person has and the ones
6
+ * open to join: flat marks with nothing behind them until a pointer or the
7
+ * focus arrives, the current product on a resting plate in the selected wash,
8
+ * an invitation carrying a small plus.
9
+ *
10
+ * **What it is to assistive technology, which is most of the work:**
11
+ *
12
+ * - The trigger is a `button` with `aria-haspopup="dialog"`, named "Products".
13
+ * - The popup is a `dialog`, not modal and never a `menu`: the tiles are links
14
+ * that navigate, and a menu would take the browser's link behaviour away
15
+ * (middle click, copy the address). Below 600 wide it is a bottom sheet, and
16
+ * there it is modal, with a scrim, a close button first and a contained
17
+ * focus loop, because a sheet covers the page.
18
+ * - Focus opens on the current product, else the first tile. Each group is one
19
+ * tab stop with a roving tabindex; the arrows move in two dimensions without
20
+ * wrapping a row, Down from the last row of one group lands in the same
21
+ * column of the next, Home and End go to the group's ends, and Tab from the
22
+ * last group returns to the trigger and closes.
23
+ * - Each tile is named with its state: "GAdvisory, you are here", "GOpenCDR,
24
+ * open to join", "DAnalytics, cannot be opened while offline". The current
25
+ * one carries `aria-current="page"`; an offline one is `aria-disabled` and
26
+ * stays focusable.
27
+ *
28
+ * **It draws the kept list at once.** The core holds the last answer in local
29
+ * storage; opening draws it with no network wait and asks again in the
30
+ * background, and a changed list swaps in without moving the focus. The
31
+ * skeleton is only for a first visit with nothing kept.
32
+ *
33
+ * The words are English by default and every one can be replaced (`labels`).
34
+ */
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.APPS_CSS = exports.APPS_LABELS = void 0;
37
+ exports.moveInGrid = moveInGrid;
38
+ exports.defineAppsElement = defineAppsElement;
39
+ exports.createAppsElementClass = createAppsElementClass;
40
+ const contract_js_1 = require("./contract.js");
41
+ const dom_js_1 = require("./dom.js");
42
+ exports.APPS_LABELS = {
43
+ trigger: "Products",
44
+ yours: "Your products",
45
+ more: "More from Gelhaus Solutions",
46
+ empty: "Nothing here yet. Products you use appear here after you open them.",
47
+ stale: (time) => `Showing the list from ${time}.`,
48
+ retry: "Try again",
49
+ failed: "The list could not be loaded.",
50
+ here: (name) => `${name}, you are here`,
51
+ join: (name) => `${name}, open to join`,
52
+ offline: (name) => `${name}, cannot be opened while offline`,
53
+ offlineShort: "Offline",
54
+ moreBelow: (count) => `${String(count)} more below`,
55
+ close: "Close",
56
+ };
57
+ /** A list asked again on opening when it is older than this. */
58
+ const FRESH_MS = 60_000;
59
+ const SHEET_QUERY = "(max-width: 599px)";
60
+ const SKELETON_TILES = 6;
61
+ exports.APPS_CSS = `${contract_js_1.CONTRACT_CSS}
62
+ .trigger {
63
+ width: 40px; height: 40px; padding: 0; margin: 0; border: 0; border-radius: 9999px;
64
+ display: grid; place-items: center; background: transparent; color: inherit; cursor: pointer;
65
+ }
66
+ .trigger:hover { background: var(--_hover); }
67
+ .trigger:active { background: var(--_active); }
68
+ .trigger[aria-expanded="true"] { background: var(--_selected); color: var(--_accent-text); }
69
+ .trigger:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
70
+ @media (pointer: coarse) { .trigger { box-sizing: content-box; padding: 2px; } }
71
+
72
+ .popup {
73
+ position: absolute; top: calc(100% + 8px); right: 0; z-index: 2147483000;
74
+ box-sizing: border-box; width: 328px; max-width: calc(100vw - 32px);
75
+ max-height: calc(100dvh - 96px); display: flex; flex-direction: column;
76
+ padding: 10px 8px 12px; border-radius: 20px;
77
+ background: var(--_overlay); -webkit-backdrop-filter: var(--_backdrop); backdrop-filter: var(--_backdrop);
78
+ box-shadow: var(--_shadow); color: var(--_ink); container-type: inline-size;
79
+ animation: rise 120ms ease-out;
80
+ }
81
+ :host([align="start"]) .popup { right: auto; left: 0; }
82
+ .popup[hidden], .scrim[hidden] { display: none; }
83
+ .scroller { overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; gap: 4px; }
84
+ .popup.sheet {
85
+ position: fixed; top: auto; right: 0; bottom: 0; left: 0; width: 100%; max-width: none;
86
+ max-height: calc(100dvh - 48px); border-radius: 20px 20px 0 0; padding: 22px 8px 28px;
87
+ animation: slide 200ms ease-out;
88
+ }
89
+ .scrim { position: fixed; inset: 0; z-index: 2147482999; background: oklch(0 0 0 / 0.45); }
90
+ .handle {
91
+ display: block; position: absolute; top: 8px; left: 50%; width: 36px; height: 4px;
92
+ margin-left: -18px; border-radius: 9999px; background: var(--_control);
93
+ }
94
+ .close {
95
+ display: grid; place-items: center; position: absolute; top: 10px; right: 10px; width: 36px; height: 36px;
96
+ border: 0; border-radius: 9999px; background: transparent; color: var(--_ink-2); cursor: pointer;
97
+ }
98
+ .close:hover { background: var(--_hover); }
99
+ .close:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
100
+ @keyframes rise { from { opacity: 0; transform: translateY(4px); } }
101
+ @keyframes slide { from { transform: translateY(100%); } }
102
+ @media (prefers-reduced-motion: reduce) { .popup, .popup.sheet { animation: none; } }
103
+ @media (prefers-reduced-transparency: reduce) {
104
+ .popup { background: var(--_overlay-solid); -webkit-backdrop-filter: none; backdrop-filter: none; }
105
+ }
106
+
107
+ .heading {
108
+ margin: 0; padding: 4px 10px 2px; font-size: 12.5px; line-height: 18px; font-weight: 500; color: var(--_ink-2);
109
+ }
110
+ .note { margin: 0; padding: 2px 10px 8px; font-size: 13.5px; line-height: 21px; color: var(--_ink-2); }
111
+ .divider { height: 1px; margin: 6px 10px; background: var(--_hairline); }
112
+ .stale {
113
+ display: flex; align-items: center; gap: 10px; padding: 4px 10px; font-size: 12.5px; line-height: 18px; color: var(--_ink-3);
114
+ }
115
+ .stale span { flex: 1; }
116
+ .link {
117
+ padding: 0; border: 0; background: transparent; font: inherit; font-weight: 500;
118
+ color: var(--_accent-text); cursor: pointer; border-radius: 4px;
119
+ }
120
+ .failed { display: flex; flex-direction: column; gap: 12px; padding: 12px 10px; }
121
+ .failed p { margin: 0; font-size: 14px; line-height: 21px; }
122
+ .pill {
123
+ align-self: flex-start; height: 36px; padding: 0 14px; border: 1px solid var(--_control); border-radius: 9999px;
124
+ background: transparent; color: var(--_ink); font: inherit; font-size: 14px; font-weight: 500; cursor: pointer;
125
+ }
126
+ .pill:hover { background: var(--_hover); }
127
+ .link:focus-visible, .pill:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
128
+
129
+ .grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; margin: 0; padding: 0; list-style: none; }
130
+ @container (max-width: 319px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
131
+ .tile {
132
+ position: relative; box-sizing: border-box; min-height: 100px; padding: 14px 4px 12px; border-radius: 14px;
133
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
134
+ color: var(--_ink); text-decoration: none; -webkit-tap-highlight-color: transparent;
135
+ }
136
+ .tile:hover { background: var(--_hover); }
137
+ .tile:active { background: var(--_active); }
138
+ .tile[aria-current="page"] { background: var(--_selected); }
139
+ .tile:focus-visible { outline: 2px solid var(--_ring); outline-offset: -2px; }
140
+ .tile[aria-disabled="true"] { cursor: default; }
141
+ .tile[aria-disabled="true"] img { filter: grayscale(1); }
142
+ .mark { position: relative; display: block; width: 44px; height: 44px; flex-shrink: 0; }
143
+ .mark img { display: block; width: 44px; height: 44px; }
144
+ .initial {
145
+ box-sizing: border-box; width: 44px; height: 44px; border-radius: 9999px; border: 1.5px solid var(--_ink-3);
146
+ display: grid; place-items: center; font-size: 16px; font-weight: 600; color: var(--_ink-2);
147
+ }
148
+ .plus {
149
+ position: absolute; right: -5px; bottom: -4px; box-sizing: border-box; width: 18px; height: 18px; border-radius: 9999px;
150
+ background: var(--_overlay-solid); box-shadow: inset 0 0 0 1.5px var(--_ink-2); color: var(--_ink);
151
+ display: grid; place-items: center;
152
+ }
153
+ .name {
154
+ max-width: 100%; font-size: 12.5px; line-height: 16px; font-weight: 500; text-align: center; overflow-wrap: anywhere;
155
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
156
+ }
157
+ .tile[aria-current="page"] .name { font-weight: 600; }
158
+ .tile[aria-disabled="true"] .name { color: var(--_ink-3); }
159
+ .sub { font-size: 11.5px; line-height: 14px; font-weight: 500; color: var(--_ink-2); }
160
+ .skeleton { min-height: 100px; padding: 14px 4px 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
161
+ .skeleton span:first-child { width: 44px; height: 44px; border-radius: 9999px; background: var(--_active); }
162
+ .skeleton span:last-child { width: 56%; height: 10px; margin: 3px 0; border-radius: 5px; background: var(--_active); }
163
+ .fade {
164
+ position: absolute; left: 0; right: 0; bottom: 0; height: 72px; border-radius: 0 0 20px 20px; pointer-events: none;
165
+ background: linear-gradient(180deg, transparent, var(--_overlay-solid) 78%);
166
+ display: flex; align-items: flex-end; justify-content: center; padding-bottom: 10px; box-sizing: border-box;
167
+ }
168
+ .fade[hidden] { display: none; }
169
+ .fade span {
170
+ font-size: 12px; line-height: 16px; font-weight: 500; color: var(--_ink-2); padding: 3px 10px;
171
+ border-radius: 9999px; background: var(--_overlay-solid); border: 1px solid var(--_hairline);
172
+ }
173
+
174
+ @media (forced-colors: active) {
175
+ .popup { border: 1px solid CanvasText; }
176
+ .tile { color: LinkText; }
177
+ .tile[aria-current="page"] { outline: 2px solid Highlight; outline-offset: -2px; }
178
+ .plus { outline: 1px solid CanvasText; }
179
+ }
180
+ `;
181
+ /** The nine dots, in the host's own ink. */
182
+ function nineDots() {
183
+ const svg = document.createElementNS(dom_js_1.SVG_NS, "svg");
184
+ svg.setAttribute("width", "20");
185
+ svg.setAttribute("height", "20");
186
+ svg.setAttribute("viewBox", "0 0 20 20");
187
+ svg.setAttribute("aria-hidden", "true");
188
+ for (let row = 0; row < 3; row += 1) {
189
+ for (let column = 0; column < 3; column += 1) {
190
+ const dot = document.createElementNS(dom_js_1.SVG_NS, "circle");
191
+ dot.setAttribute("cx", String(2 + column * 8));
192
+ dot.setAttribute("cy", String(2 + row * 8));
193
+ dot.setAttribute("r", "2");
194
+ dot.setAttribute("fill", "currentColor");
195
+ svg.append(dot);
196
+ }
197
+ }
198
+ return svg;
199
+ }
200
+ function plusMark() {
201
+ const svg = document.createElementNS(dom_js_1.SVG_NS, "svg");
202
+ svg.setAttribute("width", "8");
203
+ svg.setAttribute("height", "8");
204
+ svg.setAttribute("viewBox", "0 0 8 8");
205
+ const path = document.createElementNS(dom_js_1.SVG_NS, "path");
206
+ path.setAttribute("d", "M4 0v8M0 4h8");
207
+ path.setAttribute("stroke", "currentColor");
208
+ path.setAttribute("stroke-width", "1.5");
209
+ svg.append(path);
210
+ return svg;
211
+ }
212
+ /**
213
+ * Where an arrow key goes in a grid of `count` tiles, `columns` wide, without
214
+ * wrapping a row. Null where the key leaves the grid downwards or upwards, so
215
+ * the caller can carry the column into the next group.
216
+ */
217
+ function moveInGrid(index, count, columns, key) {
218
+ const column = index % columns;
219
+ switch (key) {
220
+ case "ArrowRight":
221
+ return column < columns - 1 && index + 1 < count ? index + 1 : index;
222
+ case "ArrowLeft":
223
+ return column > 0 ? index - 1 : index;
224
+ case "ArrowDown":
225
+ return index + columns < count ? index + columns : "below";
226
+ case "ArrowUp":
227
+ return index - columns >= 0 ? index - columns : "above";
228
+ case "Home":
229
+ return 0;
230
+ case "End":
231
+ return count - 1;
232
+ default:
233
+ return null;
234
+ }
235
+ }
236
+ /**
237
+ * Defines `<gp-apps>` once, where custom elements exist. Safe to import on a
238
+ * server: nothing touches the DOM until this is called in a browser.
239
+ */
240
+ function defineAppsElement(name = "gp-apps") {
241
+ if (typeof customElements === "undefined" || customElements.get(name) !== undefined)
242
+ return;
243
+ customElements.define(name, createAppsElementClass());
244
+ }
245
+ /** The class itself, for a caller that registers it under its own name or in a scoped registry. */
246
+ function createAppsElementClass() {
247
+ return class GpAppsElement extends HTMLElement {
248
+ #core = null;
249
+ #unsubscribe = null;
250
+ #labels = exports.APPS_LABELS;
251
+ #open = false;
252
+ #sheet = false;
253
+ #roving = { yours: null, more: null };
254
+ #root;
255
+ #trigger;
256
+ #popup;
257
+ #scrim;
258
+ #scroller;
259
+ #fade;
260
+ constructor() {
261
+ super();
262
+ this.#root = this.attachShadow({ mode: "open" });
263
+ const style = (0, dom_js_1.element)("style");
264
+ style.textContent = exports.APPS_CSS;
265
+ this.#trigger = (0, dom_js_1.element)("button", {
266
+ type: "button",
267
+ class: "trigger",
268
+ part: "trigger",
269
+ "aria-haspopup": "dialog",
270
+ "aria-expanded": "false",
271
+ "aria-controls": "popup",
272
+ });
273
+ this.#trigger.append(nineDots());
274
+ this.#scrim = (0, dom_js_1.element)("div", { class: "scrim", hidden: "" });
275
+ this.#popup = (0, dom_js_1.element)("div", {
276
+ id: "popup",
277
+ class: "popup",
278
+ part: "popup",
279
+ role: "dialog",
280
+ "aria-labelledby": "heading",
281
+ hidden: "",
282
+ });
283
+ this.#scroller = (0, dom_js_1.element)("div", { class: "scroller" });
284
+ this.#fade = (0, dom_js_1.element)("div", { class: "fade", "aria-hidden": "true", hidden: "" });
285
+ this.#root.append(style, this.#trigger, this.#scrim, this.#popup);
286
+ this.#trigger.addEventListener("click", () => {
287
+ if (this.#open)
288
+ this.close(true);
289
+ else
290
+ this.show();
291
+ });
292
+ this.#scrim.addEventListener("click", () => this.close(true));
293
+ this.#popup.addEventListener("keydown", (event) => this.#keyed(event));
294
+ this.#scroller.addEventListener("scroll", () => this.#countBelow(), { passive: true });
295
+ }
296
+ /** The core that holds the list. Nothing renders until one is set and enabled. */
297
+ get core() {
298
+ return this.#core;
299
+ }
300
+ set core(next) {
301
+ this.#unsubscribe?.();
302
+ this.#unsubscribe = null;
303
+ this.#core = next;
304
+ if (next !== null) {
305
+ this.#unsubscribe = next.subscribe(() => this.#render());
306
+ if (this.isConnected && next.state().status === "idle")
307
+ void next.load();
308
+ }
309
+ this.#render();
310
+ }
311
+ get labels() {
312
+ return this.#labels;
313
+ }
314
+ /** Any of the words, replaced; the rest stay English. */
315
+ set labels(next) {
316
+ this.#labels = { ...exports.APPS_LABELS, ...next };
317
+ this.#render();
318
+ }
319
+ get open() {
320
+ return this.#open;
321
+ }
322
+ connectedCallback() {
323
+ // A property set before the element was defined sits on the instance; move it through the setters.
324
+ for (const key of ["core", "labels"]) {
325
+ if (Object.prototype.hasOwnProperty.call(this, key)) {
326
+ const value = this[key];
327
+ delete this[key];
328
+ this[key] = value;
329
+ }
330
+ }
331
+ if (this.#core?.state().status === "idle")
332
+ void this.#core.load();
333
+ this.#render();
334
+ window.addEventListener("online", this.#onNetwork);
335
+ window.addEventListener("offline", this.#onNetwork);
336
+ }
337
+ disconnectedCallback() {
338
+ this.close(false);
339
+ window.removeEventListener("online", this.#onNetwork);
340
+ window.removeEventListener("offline", this.#onNetwork);
341
+ }
342
+ #onNetwork = () => {
343
+ this.#render();
344
+ };
345
+ #outside = (event) => {
346
+ if (event.composedPath().includes(this))
347
+ return;
348
+ this.close(false);
349
+ // Focus returns to the trigger unless the click landed on something that takes it.
350
+ setTimeout(() => {
351
+ if (document.activeElement === document.body || document.activeElement === null) {
352
+ this.#trigger.focus();
353
+ }
354
+ }, 0);
355
+ };
356
+ #documentKey = (event) => {
357
+ if (event.key === "Escape" && this.#open) {
358
+ event.stopPropagation();
359
+ this.close(true);
360
+ }
361
+ };
362
+ #mediaChanged = () => {
363
+ if (!this.#open)
364
+ return;
365
+ this.#layout();
366
+ this.#render();
367
+ };
368
+ /** Opens the popup and puts focus on the current product, else the first tile. */
369
+ show() {
370
+ const core = this.#core;
371
+ if (core === null || core.state().status === "off" || this.#open)
372
+ return;
373
+ this.#open = true;
374
+ const state = core.state();
375
+ if (state.status !== "loading" &&
376
+ (state.updatedAt === null || Date.now() - state.updatedAt > FRESH_MS)) {
377
+ void core.load();
378
+ }
379
+ this.#layout();
380
+ this.#trigger.setAttribute("aria-expanded", "true");
381
+ this.#popup.hidden = false;
382
+ document.addEventListener("pointerdown", this.#outside, true);
383
+ document.addEventListener("keydown", this.#documentKey);
384
+ window.matchMedia?.(SHEET_QUERY).addEventListener?.("change", this.#mediaChanged);
385
+ this.#render();
386
+ this.#focusStart();
387
+ }
388
+ /** Closes it, and gives focus back to the trigger when asked to. */
389
+ close(returnFocus) {
390
+ if (!this.#open)
391
+ return;
392
+ this.#open = false;
393
+ this.#trigger.setAttribute("aria-expanded", "false");
394
+ this.#popup.hidden = true;
395
+ this.#scrim.hidden = true;
396
+ document.removeEventListener("pointerdown", this.#outside, true);
397
+ document.removeEventListener("keydown", this.#documentKey);
398
+ window.matchMedia?.(SHEET_QUERY).removeEventListener?.("change", this.#mediaChanged);
399
+ if (returnFocus)
400
+ this.#trigger.focus();
401
+ }
402
+ #layout() {
403
+ this.#sheet = window.matchMedia?.(SHEET_QUERY).matches ?? false;
404
+ this.#popup.classList.toggle("sheet", this.#sheet);
405
+ this.#scrim.hidden = !this.#sheet;
406
+ if (this.#sheet)
407
+ this.#popup.setAttribute("aria-modal", "true");
408
+ else
409
+ this.#popup.removeAttribute("aria-modal");
410
+ }
411
+ #focusStart() {
412
+ const tiles = this.#tiles();
413
+ const start = tiles.find((tile) => tile.getAttribute("aria-current") === "page") ??
414
+ tiles[0] ??
415
+ this.#popup.querySelector(".pill, .link") ??
416
+ null;
417
+ if (start !== null) {
418
+ this.#roveTo(start);
419
+ start.focus();
420
+ }
421
+ else {
422
+ this.#popup.setAttribute("tabindex", "-1");
423
+ this.#popup.focus();
424
+ }
425
+ }
426
+ #tiles(group) {
427
+ const selector = group === undefined ? "a.tile" : `a.tile[data-group="${group}"]`;
428
+ return [...this.#popup.querySelectorAll(selector)];
429
+ }
430
+ #roveTo(tile) {
431
+ const group = tile.dataset["group"];
432
+ if (group === undefined)
433
+ return;
434
+ for (const other of this.#tiles(group))
435
+ other.tabIndex = other === tile ? 0 : -1;
436
+ this.#roving[group] = tile.dataset["slug"] ?? null;
437
+ }
438
+ #columns(group) {
439
+ const grid = this.#popup.querySelector(`.grid[data-group="${group}"]`);
440
+ if (grid === null)
441
+ return 3;
442
+ const template = getComputedStyle(grid).gridTemplateColumns;
443
+ const count = template === "" || template === "none" ? 0 : template.split(" ").length;
444
+ return count > 0 ? count : 3;
445
+ }
446
+ #keyed(event) {
447
+ if (event.key === "Escape") {
448
+ event.preventDefault();
449
+ event.stopPropagation();
450
+ this.close(true);
451
+ return;
452
+ }
453
+ if (event.key === "Tab") {
454
+ this.#tabbed(event);
455
+ return;
456
+ }
457
+ const tile = event.composedPath()[0]?.closest?.("a.tile");
458
+ if (!(tile instanceof HTMLAnchorElement))
459
+ return;
460
+ const group = tile.dataset["group"];
461
+ const tiles = this.#tiles(group);
462
+ const index = tiles.indexOf(tile);
463
+ const columns = this.#columns(group);
464
+ const next = moveInGrid(index, tiles.length, columns, event.key);
465
+ if (next === null)
466
+ return;
467
+ event.preventDefault();
468
+ let target;
469
+ if (next === "below" || next === "above") {
470
+ const other = next === "below" ? "more" : "yours";
471
+ if (other === group)
472
+ return;
473
+ const there = this.#tiles(other);
474
+ if (there.length === 0)
475
+ return;
476
+ const column = index % columns;
477
+ const otherColumns = this.#columns(other);
478
+ if (next === "below") {
479
+ target = there[Math.min(column, there.length - 1, otherColumns - 1)];
480
+ }
481
+ else {
482
+ const lastRowStart = Math.floor((there.length - 1) / otherColumns) * otherColumns;
483
+ target = there[Math.min(lastRowStart + column, there.length - 1)];
484
+ }
485
+ }
486
+ else {
487
+ target = tiles[next];
488
+ }
489
+ if (target === undefined)
490
+ return;
491
+ this.#roveTo(target);
492
+ target.focus();
493
+ }
494
+ /** Tab between the stops; from the last one it closes, or in a sheet it loops. */
495
+ #tabbed(event) {
496
+ const stops = [...this.#popup.querySelectorAll("button, a.tile[tabindex='0']")];
497
+ if (stops.length === 0)
498
+ return;
499
+ const here = stops.indexOf(this.#root.activeElement);
500
+ const last = stops.length - 1;
501
+ if (!event.shiftKey && here === last) {
502
+ event.preventDefault();
503
+ if (this.#sheet)
504
+ stops[0]?.focus();
505
+ else
506
+ this.close(true);
507
+ }
508
+ else if (event.shiftKey && here === 0 && this.#sheet) {
509
+ event.preventDefault();
510
+ stops[last]?.focus();
511
+ }
512
+ }
513
+ #countBelow() {
514
+ const box = this.#scroller.getBoundingClientRect();
515
+ if (box.height === 0) {
516
+ this.#fade.hidden = true;
517
+ return;
518
+ }
519
+ const hidden = this.#tiles().filter((tile) => tile.getBoundingClientRect().top >= box.bottom - 24).length;
520
+ this.#fade.hidden = hidden === 0;
521
+ const words = this.#fade.firstElementChild;
522
+ if (words !== null)
523
+ words.textContent = this.#labels.moreBelow(hidden);
524
+ }
525
+ #render() {
526
+ const state = this.#core?.state() ?? null;
527
+ this.hidden = state === null || state.status === "off";
528
+ this.#trigger.setAttribute("aria-label", this.#labels.trigger);
529
+ if (this.hidden) {
530
+ this.close(false);
531
+ return;
532
+ }
533
+ if (!this.#open)
534
+ return;
535
+ // Which tile had focus, so a list that swaps in underneath keeps it.
536
+ const focused = this.#root.activeElement;
537
+ const keep = focused?.classList.contains("tile") === true
538
+ ? { group: focused.dataset["group"], slug: focused.dataset["slug"] }
539
+ : null;
540
+ this.#popup.replaceChildren(this.#popupContent(state));
541
+ this.#countBelow();
542
+ for (const group of ["yours", "more"]) {
543
+ const tiles = this.#tiles(group);
544
+ const remembered = tiles.find((tile) => tile.dataset["slug"] === this.#roving[group]);
545
+ const current = tiles.find((tile) => tile.getAttribute("aria-current") === "page");
546
+ const first = remembered ?? current ?? tiles[0];
547
+ if (first !== undefined)
548
+ this.#roveTo(first);
549
+ }
550
+ if (keep !== null) {
551
+ const again = this.#tiles().find((tile) => tile.dataset["group"] === keep.group && tile.dataset["slug"] === keep.slug);
552
+ if (again !== undefined) {
553
+ this.#roveTo(again);
554
+ again.focus();
555
+ }
556
+ }
557
+ }
558
+ #popupContent(state) {
559
+ const labels = this.#labels;
560
+ const out = document.createDocumentFragment();
561
+ if (this.#sheet) {
562
+ // The sheet's grab handle, and a close button as the first stop in its loop.
563
+ out.append((0, dom_js_1.element)("span", { class: "handle", "aria-hidden": "true" }));
564
+ const close = (0, dom_js_1.element)("button", {
565
+ type: "button",
566
+ class: "close",
567
+ "aria-label": labels.close,
568
+ });
569
+ close.append((0, dom_js_1.crossMark)());
570
+ close.addEventListener("click", () => this.close(true));
571
+ out.append(close);
572
+ }
573
+ out.append((0, dom_js_1.element)("h2", { id: "heading", class: "sr-only" }, labels.trigger));
574
+ const view = state.view;
575
+ const scroller = this.#scroller;
576
+ scroller.replaceChildren();
577
+ if (state.status === "stale" && state.updatedAt !== null) {
578
+ const line = (0, dom_js_1.element)("div", { class: "stale", role: "status" });
579
+ line.append((0, dom_js_1.element)("span", {}, labels.stale(clock(state.updatedAt))));
580
+ const retry = (0, dom_js_1.element)("button", { type: "button", class: "link" }, labels.retry);
581
+ retry.addEventListener("click", () => void this.#core?.load());
582
+ line.append(retry);
583
+ scroller.append(line);
584
+ }
585
+ if (view === null && state.status === "error") {
586
+ const failed = (0, dom_js_1.element)("div", { class: "failed", role: "alert" });
587
+ failed.append((0, dom_js_1.element)("p", {}, labels.failed));
588
+ const retry = (0, dom_js_1.element)("button", { type: "button", class: "pill" }, labels.retry);
589
+ retry.addEventListener("click", () => void this.#core?.load());
590
+ failed.append(retry);
591
+ scroller.append(failed);
592
+ out.append(scroller, this.#fade);
593
+ return out;
594
+ }
595
+ if (view === null) {
596
+ // A first visit with nothing kept: the only time the skeleton shows.
597
+ scroller.append((0, dom_js_1.element)("p", { class: "heading" }, labels.yours));
598
+ const grid = (0, dom_js_1.element)("div", { class: "grid", "aria-busy": "true" });
599
+ for (let i = 0; i < SKELETON_TILES; i += 1) {
600
+ const ghost = (0, dom_js_1.element)("div", { class: "skeleton", "aria-hidden": "true" });
601
+ ghost.append((0, dom_js_1.element)("span"), (0, dom_js_1.element)("span"));
602
+ grid.append(ghost);
603
+ }
604
+ scroller.append(grid);
605
+ out.append(scroller, this.#fade);
606
+ return out;
607
+ }
608
+ const offline = typeof navigator !== "undefined" && navigator.onLine === false;
609
+ scroller.append((0, dom_js_1.element)("p", { class: "heading", id: "yours" }, labels.yours));
610
+ if (view.yours.length === 0)
611
+ scroller.append((0, dom_js_1.element)("p", { class: "note" }, labels.empty));
612
+ else
613
+ scroller.append(this.#grid("yours", view.yours, offline));
614
+ if (view.more.length > 0) {
615
+ scroller.append((0, dom_js_1.element)("div", { class: "divider", role: "presentation" }));
616
+ scroller.append((0, dom_js_1.element)("p", { class: "heading", id: "more" }, labels.more));
617
+ scroller.append(this.#grid("more", view.more, offline));
618
+ }
619
+ this.#fade.replaceChildren((0, dom_js_1.element)("span"));
620
+ out.append(scroller, this.#fade);
621
+ return out;
622
+ }
623
+ #grid(group, tiles, offline) {
624
+ const labels = this.#labels;
625
+ const grid = (0, dom_js_1.element)("div", {
626
+ class: "grid",
627
+ "data-group": group,
628
+ role: "group",
629
+ "aria-labelledby": group,
630
+ });
631
+ for (const tile of tiles) {
632
+ const current = group === "yours" && tile.current;
633
+ const name = offline
634
+ ? labels.offline(tile.displayName)
635
+ : current
636
+ ? labels.here(tile.displayName)
637
+ : group === "more"
638
+ ? labels.join(tile.displayName)
639
+ : tile.displayName;
640
+ const link = (0, dom_js_1.element)("a", {
641
+ class: "tile",
642
+ href: tile.homeUrl,
643
+ "data-group": group,
644
+ "data-slug": tile.slug,
645
+ tabindex: "-1",
646
+ title: tile.displayName,
647
+ "aria-label": name,
648
+ });
649
+ if (current)
650
+ link.setAttribute("aria-current", "page");
651
+ if (offline) {
652
+ link.setAttribute("aria-disabled", "true");
653
+ link.addEventListener("click", (event) => event.preventDefault());
654
+ }
655
+ const mark = (0, dom_js_1.element)("span", { class: "mark" });
656
+ if (tile.iconUrl === null) {
657
+ mark.append((0, dom_js_1.element)("span", { class: "initial", "aria-hidden": "true" }, tile.displayName.trim().charAt(0).toUpperCase() || "?"));
658
+ }
659
+ else {
660
+ mark.append((0, dom_js_1.element)("img", {
661
+ src: tile.iconUrl,
662
+ alt: "",
663
+ width: "44",
664
+ height: "44",
665
+ loading: "lazy",
666
+ decoding: "async",
667
+ referrerpolicy: "no-referrer",
668
+ }));
669
+ }
670
+ if (group === "more") {
671
+ const plus = (0, dom_js_1.element)("span", { class: "plus", "aria-hidden": "true" });
672
+ plus.append(plusMark());
673
+ mark.append(plus);
674
+ }
675
+ link.append(mark, (0, dom_js_1.element)("span", { class: "name", "aria-hidden": "true" }, tile.displayName));
676
+ if (offline)
677
+ link.append((0, dom_js_1.element)("span", { class: "sub", "aria-hidden": "true" }, labels.offlineShort));
678
+ grid.append(link);
679
+ }
680
+ return grid;
681
+ }
682
+ };
683
+ }
684
+ /** The time a kept list was made, as the reader's clock writes it. */
685
+ function clock(at) {
686
+ const lang = typeof document === "undefined" ? undefined : document.documentElement.lang || undefined;
687
+ return new Intl.DateTimeFormat(lang, { hour: "2-digit", minute: "2-digit" }).format(new Date(at));
688
+ }
689
+ //# sourceMappingURL=apps.js.map