@ghub/identity-chrome 0.0.0-stage → 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,1008 @@
1
+ "use strict";
2
+ /**
3
+ * `<gp-account>`, the account popup (GPlatform Identity, sheet 32, option A).
4
+ *
5
+ * An avatar at the right end of a product's top bar and, under it, a card on a
6
+ * tinted ground: who is signed in, where their Identity account is, which
7
+ * organisation they are acting in, and how to sign out of this product and
8
+ * only this product (D21).
9
+ *
10
+ * **Most of what it shows is the product's.** The name, the address, the
11
+ * organisations and both actions are properties the product sets from its own
12
+ * session. Identity adds the picture, the tint, its own two links and the
13
+ * footer, through the same core as `<gp-apps>`, so the popup still signs
14
+ * somebody out while Identity cannot be reached.
15
+ *
16
+ * **What it is to assistive technology:**
17
+ *
18
+ * - The trigger is a `button` with `aria-haspopup="dialog"`, named "Account:
19
+ * Mira Kessler", with the same words as a tooltip on hover and on focus after
20
+ * half a second, which Escape dismisses without closing anything else (1.4.13).
21
+ * - The popup is a `dialog` labelled by the person's name and described by their
22
+ * address, not modal. Below 600 wide it is a modal sheet with a scrim and a
23
+ * contained focus loop, as the picker is.
24
+ * - Focus opens on the pill (Manage your Identity account, or Set up your
25
+ * Identity), else the active organisation, else Sign out. The close button is
26
+ * a stop and never the first one.
27
+ * - The organisations are a `radiogroup`, one tab stop, Up and Down moving
28
+ * between them. Choosing is Space or Enter and never an arrow, because
29
+ * choosing switches the product: it closes the popup and announces
30
+ * "Switched to ...". Five show; "Show all" opens the rest in place, scrolling
31
+ * inside, and focus lands on the row that took its place.
32
+ * - Signing out goes busy in place. A failure keeps the popup open, says in one
33
+ * sentence that the person is still signed in, and turns the row into Try
34
+ * again. It never claims to have signed out.
35
+ *
36
+ * **Hidden for now (D20):** the other accounts and "Add another account" are
37
+ * not drawn until Identity holds several sessions in one browser and products
38
+ * sign in through it, and no product can switch them on.
39
+ *
40
+ * The words are English by default and every one can be replaced (`labels`).
41
+ */
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.ACCOUNT_CSS = exports.ACCOUNT_LABELS = void 0;
44
+ exports.defineAccountElement = defineAccountElement;
45
+ exports.createAccountElementClass = createAccountElementClass;
46
+ const tint_js_1 = require("../tint.js");
47
+ const validate_js_1 = require("../validate.js");
48
+ const contract_js_1 = require("./contract.js");
49
+ const dom_js_1 = require("./dom.js");
50
+ const sentence = (words) => `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
51
+ exports.ACCOUNT_LABELS = {
52
+ trigger: (name) => `Account: ${name}`,
53
+ signedInTo: (product) => `Signed in to ${product}`,
54
+ close: "Close",
55
+ changePicture: "Change your picture",
56
+ manage: "Manage your Identity account",
57
+ setUp: "Set up your Identity",
58
+ organisations: "Organisations",
59
+ roles: { owner: "Owner", admin: "Admin", member: "Member" },
60
+ active: "Active",
61
+ showAll: (count) => `Show all ${String(count)}`,
62
+ switched: (organisation) => `Switched to ${organisation}`,
63
+ switchFailed: (organisation) => `Could not switch to ${organisation}. Try again.`,
64
+ signOut: (product) => `Sign out of ${product}`,
65
+ signOutBoth: (product) => `Sign out of ${product} and Identity`,
66
+ signingOut: (product) => `Signing out of ${product}`,
67
+ signOutFailed: (product) => `You are still signed in. ${sentence(product)} did not answer. Try again, or close the browser to end this session.`,
68
+ retry: "Try again",
69
+ thisProduct: "this product",
70
+ terms: "Terms of Service",
71
+ privacy: "Privacy policy",
72
+ };
73
+ /** A view asked again on opening when it is older than this. */
74
+ const FRESH_MS = 60_000;
75
+ const SHEET_QUERY = "(max-width: 599px)";
76
+ /** The organisations shown before "Show all". */
77
+ const SHOWN = 5;
78
+ const TIP_DELAY_MS = 500;
79
+ /** Long enough to cross from the trigger to its tooltip without losing it. */
80
+ const TIP_LINGER_MS = 150;
81
+ const ROLES = ["owner", "admin", "member"];
82
+ const tintRules = tint_js_1.AVATAR_TINTS.map((tint, index) => `.t${String(index)} { background: ${tint}; }`).join("\n ");
83
+ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
84
+ .trigger {
85
+ width: 40px; height: 40px; padding: 0; margin: 0; border: 0; border-radius: 9999px;
86
+ display: grid; place-items: center; background: transparent; color: inherit; cursor: pointer;
87
+ }
88
+ .trigger:hover { background: var(--_hover); }
89
+ .trigger:active { background: var(--_active); }
90
+ .trigger[aria-expanded="true"] { box-shadow: 0 0 0 2px var(--_accent); }
91
+ .trigger:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
92
+ @media (pointer: coarse) { .trigger { box-sizing: content-box; padding: 2px; } }
93
+
94
+ .avatar {
95
+ position: relative; display: grid; place-items: center; flex-shrink: 0; overflow: hidden;
96
+ border-radius: 9999px; color: ${tint_js_1.AVATAR_INK}; font-weight: 600; line-height: 1;
97
+ }
98
+ .avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
99
+ .s32 { width: 32px; height: 32px; font-size: 14px; }
100
+ .s72 { width: 72px; height: 72px; font-size: 30px; }
101
+ ${tintRules}
102
+
103
+ .tip {
104
+ position: absolute; top: calc(100% + 6px); right: 0; z-index: 2147483001; box-sizing: border-box;
105
+ width: max-content; max-width: min(320px, calc(100vw - 32px)); padding: 5px 9px; border-radius: 8px;
106
+ background: var(--_ink); color: var(--_overlay-solid); box-shadow: 0 0 0 1px var(--_hairline);
107
+ font-size: 12px; line-height: 16px; overflow-wrap: anywhere;
108
+ }
109
+ .tip[hidden], .popup[hidden], .scrim[hidden] { display: none; }
110
+
111
+ .popup {
112
+ position: absolute; top: calc(100% + 8px); right: 0; z-index: 2147483000;
113
+ box-sizing: border-box; width: 384px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 96px);
114
+ overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column;
115
+ padding: 8px 8px 10px; border-radius: 28px;
116
+ background: var(--_tint); -webkit-backdrop-filter: var(--_backdrop); backdrop-filter: var(--_backdrop);
117
+ box-shadow: var(--_shadow); color: var(--_ink); animation: rise 120ms ease-out;
118
+ }
119
+ :host([align="start"]) .popup, :host([align="start"]) .tip { right: auto; left: 0; }
120
+ .popup.sheet {
121
+ position: fixed; top: auto; right: 0; bottom: 0; left: 0; width: 100%; max-width: none;
122
+ max-height: calc(100dvh - 48px); border-radius: 20px 20px 0 0;
123
+ padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px)); animation: slide 200ms ease-out;
124
+ }
125
+ .scrim { position: fixed; inset: 0; z-index: 2147482999; background: oklch(0 0 0 / 0.45); }
126
+ @keyframes rise { from { opacity: 0; transform: translateY(4px); } }
127
+ @keyframes slide { from { transform: translateY(100%); } }
128
+ @keyframes spin { to { transform: rotate(360deg); } }
129
+
130
+ .panel { background: var(--_panel); }
131
+ button.panel, .org { -webkit-tap-highlight-color: transparent; }
132
+ button.panel:hover, .org:hover { background-image: linear-gradient(var(--_hover), var(--_hover)); }
133
+
134
+ .top { display: flex; align-items: center; min-height: 40px; padding-left: 44px; }
135
+ .context { flex: 1; min-width: 0; margin: 0; text-align: center; font-size: 13px; line-height: 18px; color: var(--_ink-2); }
136
+ .close {
137
+ display: grid; place-items: center; flex-shrink: 0; width: 40px; height: 40px; padding: 0; border: 0;
138
+ border-radius: 9999px; background: transparent; color: var(--_ink-2); cursor: pointer;
139
+ }
140
+ .close:hover { background: var(--_hover); }
141
+ .close:focus-visible { outline: 2px solid var(--_ring); outline-offset: -2px; }
142
+
143
+ .card {
144
+ position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px;
145
+ padding: 22px 20px 20px; border-radius: 24px;
146
+ }
147
+ .portrait { position: relative; width: 72px; height: 72px; margin-bottom: 12px; }
148
+ .pencil {
149
+ position: absolute; right: -4px; bottom: -2px; box-sizing: border-box; width: 28px; height: 28px;
150
+ display: grid; place-items: center; border-radius: 9999px; color: var(--_ink);
151
+ background: var(--_overlay-solid); box-shadow: 0 0 0 2px var(--_tint-solid), inset 0 0 0 1px var(--_control);
152
+ }
153
+ .pencil::after { content: ""; position: absolute; inset: -8px; }
154
+ .pencil:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
155
+ .name {
156
+ max-width: 100%; margin: 0; font-size: 20px; line-height: 26px; font-weight: 600; letter-spacing: -0.015em;
157
+ text-align: center; overflow-wrap: anywhere;
158
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
159
+ }
160
+ .email {
161
+ display: flex; justify-content: center; max-width: 100%; margin: 0; overflow: hidden; white-space: nowrap;
162
+ font-size: 14px; line-height: 20px; color: var(--_ink-2);
163
+ }
164
+ .local { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
165
+ .domain { flex-shrink: 0; }
166
+ .pill {
167
+ display: inline-flex; align-items: center; gap: 10px; box-sizing: border-box; max-width: 100%;
168
+ min-height: 44px; margin-top: 16px; padding: 0 20px 0 14px; border: 1px solid var(--_control); border-radius: 9999px;
169
+ color: var(--_ink); font-size: 14px; line-height: 20px; font-weight: 500; text-decoration: none;
170
+ }
171
+ .pill img { display: block; flex-shrink: 0; width: 20px; height: 20px; }
172
+ .pill:hover { background: var(--_hover); }
173
+ .pill.invite { background: var(--_selected); }
174
+ .pill:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
175
+
176
+ .section { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
177
+ .heading { margin: 0; padding: 8px 16px 4px; font-size: 13px; line-height: 18px; font-weight: 500; color: var(--_ink-2); }
178
+ .wrap, .radios { display: flex; flex-direction: column; gap: 2px; }
179
+ .radios.all {
180
+ max-height: 300px; overflow-y: auto; overscroll-behavior: contain;
181
+ -webkit-mask-image: linear-gradient(180deg, #000 82%, transparent); mask-image: linear-gradient(180deg, #000 82%, transparent);
182
+ }
183
+ .radios.all.end { -webkit-mask-image: none; mask-image: none; }
184
+ .org {
185
+ display: flex; align-items: center; gap: 12px; box-sizing: border-box; min-height: 56px; padding: 8px 16px;
186
+ border-radius: 4px; cursor: pointer;
187
+ }
188
+ .org[aria-checked="true"] { background: var(--_selected); }
189
+ .radios[aria-readonly="true"] .org { cursor: default; }
190
+ .radios[aria-busy="true"] .org { cursor: progress; }
191
+ .org:focus-visible { outline: 2px solid var(--_ring); outline-offset: -2px; }
192
+ .wrap > .radios:last-child > .org:last-child { border-radius: 4px 4px 18px 18px; }
193
+ .org-mark {
194
+ display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border-radius: 9px;
195
+ color: ${tint_js_1.AVATAR_INK}; font-size: 14px; font-weight: 600;
196
+ }
197
+ .org-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
198
+ .org-name { font-size: 14px; line-height: 20px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
199
+ .org-role { font-size: 12.5px; line-height: 17px; color: var(--_ink-2); }
200
+ .active { display: flex; align-items: center; gap: 6px; flex-shrink: 0; font-size: 12.5px; font-weight: 500; color: var(--_accent-text); }
201
+ .check { width: 5px; height: 10px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(45deg); }
202
+ .more {
203
+ display: flex; align-items: center; min-height: 48px; padding: 0 16px; border: 0; border-radius: 4px 4px 18px 18px;
204
+ color: var(--_accent-text); font: inherit; font-size: 14px; font-weight: 500; text-align: left; cursor: pointer;
205
+ }
206
+ .more:focus-visible, .signout:focus-visible { outline: 2px solid var(--_ring); outline-offset: -2px; }
207
+ .signout {
208
+ display: flex; align-items: center; gap: 12px; box-sizing: border-box; width: 100%; min-height: 52px; padding: 0 16px;
209
+ border: 0; border-radius: 18px; color: var(--_ink); font: inherit; font-size: 14px; font-weight: 500;
210
+ text-align: left; cursor: pointer;
211
+ }
212
+ .signout[aria-busy="true"] { color: var(--_ink-2); cursor: progress; }
213
+ .spinner {
214
+ box-sizing: border-box; width: 14px; height: 14px; margin: 0 2px; flex-shrink: 0; border-radius: 9999px;
215
+ border: 2px solid var(--_hairline); border-top-color: var(--_ink-2); animation: spin 800ms linear infinite;
216
+ }
217
+ .error { margin: 0; padding: 6px 16px 2px; font-size: 13px; line-height: 19px; color: var(--_crit-ink); }
218
+ .legal {
219
+ display: flex; justify-content: center; flex-wrap: wrap; gap: 6px; padding: 12px 0 4px;
220
+ font-size: 12.5px; line-height: 18px; color: var(--_ink-2);
221
+ }
222
+ .legal a { padding: 2px 6px; border-radius: 6px; color: inherit; text-decoration: none; }
223
+ .legal a:hover { text-decoration: underline; }
224
+ .legal a:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
225
+
226
+ @media (prefers-reduced-motion: reduce) {
227
+ .popup, .popup.sheet { animation: none; }
228
+ .spinner { animation-duration: 2400ms; }
229
+ }
230
+ @media (prefers-reduced-transparency: reduce) {
231
+ .popup { background: var(--_tint-solid); -webkit-backdrop-filter: none; backdrop-filter: none; }
232
+ }
233
+ @media (forced-colors: active) {
234
+ .popup, .panel, .org { border: 1px solid CanvasText; background: Canvas; }
235
+ .org[aria-checked="true"] { outline: 2px solid Highlight; outline-offset: -2px; }
236
+ .avatar, .org-mark { border: 1px solid CanvasText; }
237
+ .pill, .legal a { color: LinkText; }
238
+ }
239
+ `;
240
+ function pencilMark() {
241
+ return (0, dom_js_1.pathIcon)(14, "0 0 24 24", "M4 20l1-5L15.5 4.5a2 2 0 0 1 2.8 0l1.2 1.2a2 2 0 0 1 0 2.8L9 19z");
242
+ }
243
+ function signOutMark() {
244
+ return (0, dom_js_1.pathIcon)(18, "0 0 24 24", "M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5v-2H6V6h4zM16.6 7.4L15.2 8.8 17.4 11H9v2h8.4l-2.2 2.2 1.4 1.4L21.2 12z");
245
+ }
246
+ /** The first letter a person or an organisation is drawn with, whole even outside the basic plane. */
247
+ function initialOf(words) {
248
+ return [...words.trim()][0]?.toLocaleUpperCase() ?? "?";
249
+ }
250
+ /** A round picture, or the coloured initial while it loads and when it fails. */
251
+ function avatar(face, size) {
252
+ const made = (0, dom_js_1.element)("span", {
253
+ class: `avatar ${size} t${String(face.tint)}`,
254
+ "aria-hidden": "true",
255
+ });
256
+ if (face.picture === null) {
257
+ made.textContent = face.initial;
258
+ return made;
259
+ }
260
+ const image = (0, dom_js_1.element)("img", {
261
+ src: face.picture,
262
+ alt: "",
263
+ decoding: "async",
264
+ referrerpolicy: "no-referrer",
265
+ });
266
+ image.addEventListener("error", () => {
267
+ made.textContent = face.initial;
268
+ }, { once: true });
269
+ made.append(image);
270
+ return made;
271
+ }
272
+ /** The address on one line, cut in the middle so the domain survives: the domain is what tells two apart. */
273
+ function emailLine(email) {
274
+ const line = (0, dom_js_1.element)("p", { id: "email", class: "email", title: email });
275
+ const at = email.lastIndexOf("@");
276
+ if (at <= 0) {
277
+ line.append((0, dom_js_1.element)("span", { class: "local" }, email));
278
+ }
279
+ else {
280
+ line.append((0, dom_js_1.element)("span", { class: "local" }, email.slice(0, at)), (0, dom_js_1.element)("span", { class: "domain" }, email.slice(at)));
281
+ }
282
+ return line;
283
+ }
284
+ /** The product's own picture: an absolute or same-site web address, never anything else. */
285
+ function productPicture(value) {
286
+ if (typeof value !== "string" || value === "")
287
+ return null;
288
+ try {
289
+ return (0, validate_js_1.safeUrl)(new URL(value, document.baseURI).href);
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ }
295
+ function personOf(value) {
296
+ if (typeof value !== "object" || value === null)
297
+ return null;
298
+ const { name, email, id, avatarUrl } = value;
299
+ if (typeof email !== "string" || email.trim() === "")
300
+ return null;
301
+ const shown = typeof name === "string" && name.trim() !== "" ? name.trim() : email.trim();
302
+ return {
303
+ name: shown,
304
+ email: email.trim(),
305
+ ...(typeof id === "string" && id !== "" ? { id } : {}),
306
+ avatarUrl: typeof avatarUrl === "string" ? avatarUrl : null,
307
+ };
308
+ }
309
+ function organisationsOf(value) {
310
+ if (!Array.isArray(value))
311
+ return [];
312
+ const out = [];
313
+ for (const one of value) {
314
+ if (typeof one !== "object" || one === null)
315
+ continue;
316
+ const { id, name, role, active } = one;
317
+ if (typeof id !== "string" || id === "" || typeof name !== "string" || name.trim() === "")
318
+ continue;
319
+ if (!ROLES.includes(role))
320
+ continue;
321
+ out.push({ id, name: name.trim(), role: role, active: active === true });
322
+ }
323
+ // The active one first: the row says which one you are acting in.
324
+ const active = out.findIndex((one) => one.active);
325
+ if (active > 0)
326
+ out.unshift(...out.splice(active, 1));
327
+ return out;
328
+ }
329
+ /**
330
+ * Defines `<gp-account>` once, where custom elements exist. Safe to import on a
331
+ * server: nothing touches the DOM until this is called in a browser.
332
+ */
333
+ function defineAccountElement(name = "gp-account") {
334
+ if (typeof customElements === "undefined" || customElements.get(name) !== undefined)
335
+ return;
336
+ customElements.define(name, createAccountElementClass());
337
+ }
338
+ /** The class itself, for a caller that registers it under its own name or in a scoped registry. */
339
+ function createAccountElementClass() {
340
+ return class GpAccountElement extends HTMLElement {
341
+ #core = null;
342
+ #unsubscribe = null;
343
+ #labels = exports.ACCOUNT_LABELS;
344
+ #person = null;
345
+ #organisations = [];
346
+ #product = null;
347
+ #viaIdentity = false;
348
+ #onSignOut = null;
349
+ #onSwitch = null;
350
+ #open = false;
351
+ #sheet = false;
352
+ #allShown = false;
353
+ #signOut = "idle";
354
+ #switching = null;
355
+ #switchFailed = null;
356
+ #roving = null;
357
+ #tipTimer;
358
+ #quietFocus = false;
359
+ #face = "";
360
+ #root;
361
+ #trigger;
362
+ #tip;
363
+ #popup;
364
+ #scrim;
365
+ #status;
366
+ constructor() {
367
+ super();
368
+ this.#root = this.attachShadow({ mode: "open" });
369
+ const style = (0, dom_js_1.element)("style");
370
+ style.textContent = exports.ACCOUNT_CSS;
371
+ this.#trigger = (0, dom_js_1.element)("button", {
372
+ type: "button",
373
+ class: "trigger",
374
+ part: "trigger",
375
+ "aria-haspopup": "dialog",
376
+ "aria-expanded": "false",
377
+ "aria-controls": "popup",
378
+ });
379
+ // The trigger is already named with these words; the tooltip only shows them.
380
+ this.#tip = (0, dom_js_1.element)("div", {
381
+ class: "tip",
382
+ part: "tooltip",
383
+ "aria-hidden": "true",
384
+ hidden: "",
385
+ });
386
+ this.#scrim = (0, dom_js_1.element)("div", { class: "scrim", hidden: "" });
387
+ this.#popup = (0, dom_js_1.element)("div", {
388
+ id: "popup",
389
+ class: "popup",
390
+ part: "popup",
391
+ role: "dialog",
392
+ "aria-labelledby": "who",
393
+ "aria-describedby": "email",
394
+ hidden: "",
395
+ });
396
+ // Outside the popup, so "Switched to ..." is heard after it has closed.
397
+ this.#status = (0, dom_js_1.element)("div", { class: "sr-only", role: "status" });
398
+ this.#root.append(style, this.#trigger, this.#tip, this.#scrim, this.#popup, this.#status);
399
+ this.#trigger.addEventListener("click", () => {
400
+ this.#hideTip();
401
+ if (this.#open)
402
+ this.close(true);
403
+ else
404
+ this.show();
405
+ });
406
+ this.#trigger.addEventListener("focus", () => {
407
+ if (!this.#quietFocus)
408
+ this.#tipSoon();
409
+ });
410
+ this.#trigger.addEventListener("blur", () => this.#hideTip());
411
+ this.addEventListener("pointerenter", () => this.#tipSoon());
412
+ this.addEventListener("pointerleave", () => this.#tipLater());
413
+ this.#scrim.addEventListener("click", () => this.close(true));
414
+ this.#popup.addEventListener("keydown", (event) => this.#keyed(event));
415
+ this.#popup.addEventListener("click", (event) => this.#clicked(event));
416
+ }
417
+ /** The core `<gp-apps>` uses: Identity's half of what is shown. Nothing renders until one is set and enabled. */
418
+ get core() {
419
+ return this.#core;
420
+ }
421
+ set core(next) {
422
+ this.#unsubscribe?.();
423
+ this.#unsubscribe = null;
424
+ this.#core = next;
425
+ if (next !== null) {
426
+ this.#unsubscribe = next.subscribe(() => this.#render());
427
+ if (this.isConnected && next.state().status === "idle")
428
+ void next.load();
429
+ }
430
+ this.#render();
431
+ }
432
+ /** Who is signed in. Nothing renders without one. */
433
+ get person() {
434
+ return this.#person;
435
+ }
436
+ set person(next) {
437
+ const person = personOf(next);
438
+ const same = person !== null &&
439
+ this.#person !== null &&
440
+ person.email === this.#person.email &&
441
+ person.id === this.#person.id;
442
+ if (!same)
443
+ this.#signOut = "idle";
444
+ this.#person = person;
445
+ this.#render();
446
+ }
447
+ get organisations() {
448
+ return this.#organisations;
449
+ }
450
+ /** None, or an empty list, and the section is absent rather than empty. */
451
+ set organisations(next) {
452
+ this.#organisations = organisationsOf(next);
453
+ if (this.#switchFailed !== null &&
454
+ !this.#organisations.some((one) => one.id === this.#switchFailed))
455
+ this.#switchFailed = null;
456
+ this.#render();
457
+ }
458
+ /** The product's own name, read from its catalogue entry. Without it, the current tile's. */
459
+ get product() {
460
+ return this.#product;
461
+ }
462
+ set product(next) {
463
+ this.#product = typeof next === "string" && next.trim() !== "" ? next.trim() : null;
464
+ this.#render();
465
+ }
466
+ /** Whether the product signs in through Identity, so signing out ends both and the row says so (D21). */
467
+ get viaIdentity() {
468
+ return this.#viaIdentity;
469
+ }
470
+ set viaIdentity(next) {
471
+ this.#viaIdentity = next === true;
472
+ this.#render();
473
+ }
474
+ get onSignOut() {
475
+ return this.#onSignOut;
476
+ }
477
+ /** No handler, no Sign out row. */
478
+ set onSignOut(next) {
479
+ this.#onSignOut = typeof next === "function" ? next : null;
480
+ this.#render();
481
+ }
482
+ get onSwitch() {
483
+ return this.#onSwitch;
484
+ }
485
+ /** No handler, and the organisations are listed read only. */
486
+ set onSwitch(next) {
487
+ this.#onSwitch = typeof next === "function" ? next : null;
488
+ this.#render();
489
+ }
490
+ get labels() {
491
+ return this.#labels;
492
+ }
493
+ /** Any of the words, replaced; the rest stay English. */
494
+ set labels(next) {
495
+ this.#labels = {
496
+ ...exports.ACCOUNT_LABELS,
497
+ ...next,
498
+ roles: { ...exports.ACCOUNT_LABELS.roles, ...next.roles },
499
+ };
500
+ this.#render();
501
+ }
502
+ get open() {
503
+ return this.#open;
504
+ }
505
+ connectedCallback() {
506
+ // A property set before the element was defined sits on the instance; move it through the setters.
507
+ for (const key of [
508
+ "core",
509
+ "person",
510
+ "organisations",
511
+ "product",
512
+ "viaIdentity",
513
+ "onSignOut",
514
+ "onSwitch",
515
+ "labels",
516
+ ]) {
517
+ if (Object.prototype.hasOwnProperty.call(this, key)) {
518
+ const value = this[key];
519
+ delete this[key];
520
+ this[key] = value;
521
+ }
522
+ }
523
+ if (this.#core?.state().status === "idle")
524
+ void this.#core.load();
525
+ this.#render();
526
+ }
527
+ disconnectedCallback() {
528
+ this.close(false);
529
+ this.#hideTip();
530
+ }
531
+ #outside = (event) => {
532
+ if (event.composedPath().includes(this))
533
+ return;
534
+ this.close(false);
535
+ // Focus returns to the trigger unless the click landed on something that takes it.
536
+ setTimeout(() => {
537
+ if (document.activeElement === document.body || document.activeElement === null) {
538
+ this.#returnFocus();
539
+ }
540
+ }, 0);
541
+ };
542
+ #documentKey = (event) => {
543
+ if (event.key === "Escape" && this.#open) {
544
+ event.stopPropagation();
545
+ this.close(true);
546
+ }
547
+ };
548
+ #mediaChanged = () => {
549
+ if (!this.#open)
550
+ return;
551
+ this.#layout();
552
+ this.#render();
553
+ };
554
+ /** Escape takes the tooltip away and reaches nothing else, wherever focus is (1.4.13). */
555
+ #tipKey = (event) => {
556
+ if (event.key !== "Escape")
557
+ return;
558
+ event.stopPropagation();
559
+ this.#hideTip();
560
+ };
561
+ #tipSoon() {
562
+ if (this.#open || this.hidden)
563
+ return;
564
+ clearTimeout(this.#tipTimer);
565
+ this.#tipTimer = setTimeout(() => this.#showTip(), TIP_DELAY_MS);
566
+ }
567
+ /** The pointer left: the tooltip goes shortly unless it comes back, or focus holds it. */
568
+ #tipLater() {
569
+ if (this.#root.activeElement === this.#trigger)
570
+ return;
571
+ clearTimeout(this.#tipTimer);
572
+ this.#tipTimer = setTimeout(() => this.#hideTip(), TIP_LINGER_MS);
573
+ }
574
+ #showTip() {
575
+ if (this.#open || this.hidden)
576
+ return;
577
+ this.#tip.hidden = false;
578
+ window.addEventListener("keydown", this.#tipKey, true);
579
+ }
580
+ #hideTip() {
581
+ clearTimeout(this.#tipTimer);
582
+ this.#tip.hidden = true;
583
+ window.removeEventListener("keydown", this.#tipKey, true);
584
+ }
585
+ #returnFocus() {
586
+ this.#quietFocus = true;
587
+ this.#trigger.focus();
588
+ this.#quietFocus = false;
589
+ }
590
+ /** Opens the popup and puts focus on the pill, else the active organisation, else Sign out. */
591
+ show() {
592
+ const core = this.#core;
593
+ if (core === null || this.hidden || this.#open)
594
+ return;
595
+ this.#open = true;
596
+ this.#hideTip();
597
+ const state = core.state();
598
+ if (state.status !== "loading" &&
599
+ (state.updatedAt === null || Date.now() - state.updatedAt > FRESH_MS)) {
600
+ void core.load();
601
+ }
602
+ this.#layout();
603
+ this.#trigger.setAttribute("aria-expanded", "true");
604
+ this.#popup.hidden = false;
605
+ document.addEventListener("pointerdown", this.#outside, true);
606
+ document.addEventListener("keydown", this.#documentKey);
607
+ window.matchMedia?.(SHEET_QUERY).addEventListener?.("change", this.#mediaChanged);
608
+ this.#render();
609
+ this.#focusStart();
610
+ }
611
+ /** Closes it, and gives focus back to the trigger when asked to. */
612
+ close(returnFocus) {
613
+ if (!this.#open)
614
+ return;
615
+ this.#open = false;
616
+ this.#allShown = false;
617
+ this.#switchFailed = null;
618
+ if (this.#signOut === "failed")
619
+ this.#signOut = "idle";
620
+ this.#trigger.setAttribute("aria-expanded", "false");
621
+ this.#popup.hidden = true;
622
+ this.#scrim.hidden = true;
623
+ document.removeEventListener("pointerdown", this.#outside, true);
624
+ document.removeEventListener("keydown", this.#documentKey);
625
+ window.matchMedia?.(SHEET_QUERY).removeEventListener?.("change", this.#mediaChanged);
626
+ if (returnFocus)
627
+ this.#returnFocus();
628
+ }
629
+ #layout() {
630
+ this.#sheet = window.matchMedia?.(SHEET_QUERY).matches ?? false;
631
+ this.#popup.classList.toggle("sheet", this.#sheet);
632
+ this.#scrim.hidden = !this.#sheet;
633
+ if (this.#sheet)
634
+ this.#popup.setAttribute("aria-modal", "true");
635
+ else
636
+ this.#popup.removeAttribute("aria-modal");
637
+ }
638
+ #keyed(event) {
639
+ if (event.key === "Escape") {
640
+ event.preventDefault();
641
+ event.stopPropagation();
642
+ this.close(true);
643
+ return;
644
+ }
645
+ if (event.key === "Tab") {
646
+ this.#tabbed(event);
647
+ return;
648
+ }
649
+ const radio = event.composedPath()[0]?.closest?.('[role="radio"]');
650
+ if (!(radio instanceof HTMLElement))
651
+ return;
652
+ if (event.key === " " || event.key === "Enter") {
653
+ event.preventDefault();
654
+ const id = radio.dataset["id"];
655
+ if (id !== undefined)
656
+ void this.#choose(id);
657
+ return;
658
+ }
659
+ const radios = this.#radios();
660
+ const index = radios.indexOf(radio);
661
+ let next;
662
+ switch (event.key) {
663
+ case "ArrowDown":
664
+ case "ArrowRight":
665
+ next = Math.min(index + 1, radios.length - 1);
666
+ break;
667
+ case "ArrowUp":
668
+ case "ArrowLeft":
669
+ next = Math.max(index - 1, 0);
670
+ break;
671
+ case "Home":
672
+ next = 0;
673
+ break;
674
+ case "End":
675
+ next = radios.length - 1;
676
+ break;
677
+ default:
678
+ return;
679
+ }
680
+ event.preventDefault();
681
+ const target = radios[next];
682
+ if (target === undefined)
683
+ return;
684
+ this.#roveTo(target);
685
+ target.focus();
686
+ }
687
+ /** Tab between the stops; from the last one it closes, or in a sheet it loops. */
688
+ #tabbed(event) {
689
+ const stops = [
690
+ ...this.#popup.querySelectorAll('button, a[href], [role="radio"][tabindex="0"]'),
691
+ ];
692
+ if (stops.length === 0)
693
+ return;
694
+ const here = stops.indexOf(this.#root.activeElement);
695
+ const last = stops.length - 1;
696
+ if (!event.shiftKey && here === last) {
697
+ event.preventDefault();
698
+ if (this.#sheet)
699
+ stops[0]?.focus();
700
+ else
701
+ this.close(true);
702
+ }
703
+ else if (event.shiftKey && here === 0 && this.#sheet) {
704
+ event.preventDefault();
705
+ stops[last]?.focus();
706
+ }
707
+ }
708
+ #clicked(event) {
709
+ const target = event.composedPath()[0]?.closest?.("[data-action]");
710
+ if (!(target instanceof HTMLElement))
711
+ return;
712
+ switch (target.dataset["action"]) {
713
+ case "close":
714
+ this.close(true);
715
+ break;
716
+ case "more":
717
+ this.#showAll();
718
+ break;
719
+ case "signout":
720
+ void this.#signOutNow();
721
+ break;
722
+ case "org": {
723
+ const id = target.dataset["id"];
724
+ if (id !== undefined)
725
+ void this.#choose(id);
726
+ break;
727
+ }
728
+ default:
729
+ break;
730
+ }
731
+ }
732
+ #radios() {
733
+ return [...this.#popup.querySelectorAll('[role="radio"]')];
734
+ }
735
+ #roveTo(radio) {
736
+ for (const other of this.#radios())
737
+ other.tabIndex = other === radio ? 0 : -1;
738
+ this.#roving = radio.dataset["id"] ?? null;
739
+ }
740
+ #byKey(key) {
741
+ return [...this.#popup.querySelectorAll("[data-key]")].find((one) => one.dataset["key"] === key);
742
+ }
743
+ #focusStart() {
744
+ const start = this.#byKey("pill") ??
745
+ this.#popup.querySelector('[role="radio"][tabindex="0"]') ??
746
+ this.#byKey("signout") ??
747
+ this.#byKey("close");
748
+ start?.focus();
749
+ }
750
+ /** The rest open in place, and focus lands on the row that took "Show all"'s spot. */
751
+ #showAll() {
752
+ this.#allShown = true;
753
+ const moved = this.#organisations[SHOWN];
754
+ if (moved !== undefined)
755
+ this.#roving = moved.id;
756
+ this.#render();
757
+ if (moved !== undefined)
758
+ this.#byKey(`org:${moved.id}`)?.focus();
759
+ }
760
+ async #choose(id) {
761
+ const handler = this.#onSwitch;
762
+ const organisation = this.#organisations.find((one) => one.id === id);
763
+ if (handler === null ||
764
+ organisation === undefined ||
765
+ organisation.active ||
766
+ this.#switching !== null)
767
+ return;
768
+ this.#switching = id;
769
+ this.#switchFailed = null;
770
+ this.#roving = id;
771
+ this.#render();
772
+ try {
773
+ await handler(id);
774
+ }
775
+ catch {
776
+ this.#switching = null;
777
+ this.#switchFailed = id;
778
+ this.#render();
779
+ return;
780
+ }
781
+ this.#switching = null;
782
+ this.close(true);
783
+ this.#status.textContent = this.#labels.switched(organisation.name);
784
+ }
785
+ async #signOutNow() {
786
+ const handler = this.#onSignOut;
787
+ if (handler === null || this.#signOut === "busy")
788
+ return;
789
+ this.#signOut = "busy";
790
+ this.#render();
791
+ try {
792
+ await handler();
793
+ }
794
+ catch {
795
+ this.#signOut = "failed";
796
+ this.#render();
797
+ return;
798
+ }
799
+ // Signed out. The product navigates; the kept list goes, so a shared device never shows the next person this one's.
800
+ this.#core?.clear();
801
+ }
802
+ #productName(view) {
803
+ return this.#product ?? view?.yours.find((tile) => tile.current)?.displayName ?? null;
804
+ }
805
+ /** Identity's picture if there is an Identity, else the product's, else the coloured initial (D22). */
806
+ #faceOf(view, person) {
807
+ const own = view?.person.hasIdentity === true ? view.person : null;
808
+ return {
809
+ picture: own?.avatarUrl ?? productPicture(person.avatarUrl),
810
+ tint: own?.tint ?? (0, tint_js_1.avatarTint)(person.id ?? person.email.toLowerCase()),
811
+ initial: initialOf(person.name),
812
+ };
813
+ }
814
+ #render() {
815
+ const state = this.#core?.state() ?? null;
816
+ const person = this.#person;
817
+ this.hidden = state === null || state.status === "off" || person === null;
818
+ if (state === null || person === null || this.hidden) {
819
+ this.close(false);
820
+ this.#hideTip();
821
+ return;
822
+ }
823
+ const labels = this.#labels;
824
+ const view = state.view;
825
+ const name = labels.trigger(person.name);
826
+ this.#trigger.setAttribute("aria-label", name);
827
+ this.#tip.textContent = name;
828
+ const face = this.#faceOf(view, person);
829
+ const signature = `${face.picture ?? ""} ${String(face.tint)} ${face.initial}`;
830
+ if (signature !== this.#face) {
831
+ this.#face = signature;
832
+ this.#trigger.replaceChildren(avatar(face, "s32"));
833
+ }
834
+ if (!this.#open)
835
+ return;
836
+ // What had focus and how far things were scrolled, so a view that swaps in underneath keeps both.
837
+ const active = this.#root.activeElement;
838
+ const focused = active?.dataset["key"];
839
+ const hadFocus = active !== null && this.#popup.contains(active);
840
+ const scrolled = this.#popup.scrollTop;
841
+ const listScrolled = this.#popup.querySelector(".radios")?.scrollTop ?? 0;
842
+ this.#popup.replaceChildren(this.#content(view, person, face));
843
+ this.#popup.scrollTop = scrolled;
844
+ const list = this.#popup.querySelector(".radios.all");
845
+ if (list !== null) {
846
+ list.scrollTop = listScrolled;
847
+ const fade = () => {
848
+ list.classList.toggle("end", list.scrollTop + list.clientHeight >= list.scrollHeight - 2);
849
+ };
850
+ list.addEventListener("scroll", fade, { passive: true });
851
+ fade();
852
+ }
853
+ const again = focused === undefined ? undefined : this.#byKey(focused);
854
+ if (again !== undefined)
855
+ again.focus();
856
+ else if (hadFocus)
857
+ this.#focusStart();
858
+ }
859
+ #content(view, person, face) {
860
+ const labels = this.#labels;
861
+ const product = this.#productName(view);
862
+ const out = document.createDocumentFragment();
863
+ const top = (0, dom_js_1.element)("div", { class: "top" });
864
+ top.append((0, dom_js_1.element)("p", { class: "context" }, product === null ? "" : labels.signedInTo(product)));
865
+ const close = (0, dom_js_1.element)("button", {
866
+ type: "button",
867
+ class: "close",
868
+ "data-action": "close",
869
+ "data-key": "close",
870
+ "aria-label": labels.close,
871
+ });
872
+ close.append((0, dom_js_1.crossMark)());
873
+ top.append(close);
874
+ out.append(top);
875
+ const own = view?.person.hasIdentity === true;
876
+ const links = view?.identity ?? null;
877
+ const card = (0, dom_js_1.element)("div", { class: "card panel" });
878
+ const portrait = (0, dom_js_1.element)("span", { class: "portrait" });
879
+ portrait.append(avatar(face, "s72"));
880
+ if (own && links !== null) {
881
+ const pencil = (0, dom_js_1.element)("a", {
882
+ class: "pencil",
883
+ href: links.pictureUrl,
884
+ "data-key": "pencil",
885
+ "aria-label": labels.changePicture,
886
+ });
887
+ pencil.append(pencilMark());
888
+ portrait.append(pencil);
889
+ }
890
+ card.append(portrait, (0, dom_js_1.element)("h2", { id: "who", class: "name", title: person.name }, person.name), emailLine(person.email));
891
+ if (links !== null) {
892
+ const pill = (0, dom_js_1.element)("a", {
893
+ class: own ? "pill" : "pill invite",
894
+ href: own ? links.accountUrl : links.setupUrl,
895
+ "data-key": "pill",
896
+ });
897
+ if (links.iconUrl !== null) {
898
+ pill.append((0, dom_js_1.element)("img", {
899
+ src: links.iconUrl,
900
+ alt: "",
901
+ width: "20",
902
+ height: "20",
903
+ decoding: "async",
904
+ referrerpolicy: "no-referrer",
905
+ }));
906
+ }
907
+ pill.append((0, dom_js_1.element)("span", {}, own ? labels.manage : labels.setUp));
908
+ card.append(pill);
909
+ }
910
+ out.append(card);
911
+ if (this.#organisations.length > 0)
912
+ out.append(this.#organisationsSection());
913
+ if (this.#onSignOut !== null)
914
+ out.append(this.#signOutSection(product));
915
+ if (view !== null && view.legal !== null) {
916
+ const footer = (0, dom_js_1.element)("div", { class: "legal" });
917
+ footer.append((0, dom_js_1.element)("a", { href: view.legal.termsUrl, "data-key": "terms" }, labels.terms), (0, dom_js_1.element)("span", { "aria-hidden": "true" }, "·"), (0, dom_js_1.element)("a", { href: view.legal.privacyUrl, "data-key": "privacy" }, labels.privacy));
918
+ out.append(footer);
919
+ }
920
+ return out;
921
+ }
922
+ #organisationsSection() {
923
+ const labels = this.#labels;
924
+ const all = this.#organisations;
925
+ const cut = !this.#allShown && all.length > SHOWN;
926
+ const shown = cut ? all.slice(0, SHOWN) : all;
927
+ const stop = shown.find((one) => one.id === this.#roving) ?? shown.find((one) => one.active) ?? shown[0];
928
+ const section = (0, dom_js_1.element)("div", { class: "section" });
929
+ section.append((0, dom_js_1.element)("p", { id: "orgs", class: "heading" }, labels.organisations));
930
+ const wrap = (0, dom_js_1.element)("div", { class: "wrap" });
931
+ const radios = (0, dom_js_1.element)("div", {
932
+ class: this.#allShown && all.length > SHOWN ? "radios all" : "radios",
933
+ role: "radiogroup",
934
+ "aria-labelledby": "orgs",
935
+ });
936
+ if (this.#onSwitch === null)
937
+ radios.setAttribute("aria-readonly", "true");
938
+ if (this.#switching !== null)
939
+ radios.setAttribute("aria-busy", "true");
940
+ for (const one of shown) {
941
+ const role = labels.roles[one.role];
942
+ const row = (0, dom_js_1.element)("div", {
943
+ class: "org panel",
944
+ role: "radio",
945
+ "aria-checked": String(one.active),
946
+ "aria-label": `${one.name}, ${role}`,
947
+ tabindex: one === stop ? "0" : "-1",
948
+ "data-action": "org",
949
+ "data-id": one.id,
950
+ "data-key": `org:${one.id}`,
951
+ });
952
+ const text = (0, dom_js_1.element)("span", { class: "org-text", "aria-hidden": "true" });
953
+ text.append((0, dom_js_1.element)("span", { class: "org-name", title: one.name }, one.name), (0, dom_js_1.element)("span", { class: "org-role" }, role));
954
+ row.append((0, dom_js_1.element)("span", { class: `org-mark t${String((0, tint_js_1.avatarTint)(one.id))}`, "aria-hidden": "true" }, initialOf(one.name)), text);
955
+ if (one.active) {
956
+ const tag = (0, dom_js_1.element)("span", { class: "active", "aria-hidden": "true" });
957
+ tag.append((0, dom_js_1.element)("span", { class: "check" }), labels.active);
958
+ row.append(tag);
959
+ }
960
+ radios.append(row);
961
+ }
962
+ wrap.append(radios);
963
+ if (cut) {
964
+ wrap.append((0, dom_js_1.element)("button", { type: "button", class: "more panel", "data-action": "more", "data-key": "more" }, labels.showAll(all.length)));
965
+ }
966
+ section.append(wrap);
967
+ const failed = all.find((one) => one.id === this.#switchFailed);
968
+ if (failed !== undefined) {
969
+ section.append((0, dom_js_1.element)("p", { class: "error", role: "alert" }, labels.switchFailed(failed.name)));
970
+ }
971
+ return section;
972
+ }
973
+ #signOutSection(product) {
974
+ const labels = this.#labels;
975
+ const name = product ?? labels.thisProduct;
976
+ const busy = this.#signOut === "busy";
977
+ const failed = this.#signOut === "failed";
978
+ const section = (0, dom_js_1.element)("div", { class: "section" });
979
+ const button = (0, dom_js_1.element)("button", {
980
+ type: "button",
981
+ class: "signout panel",
982
+ "data-action": "signout",
983
+ "data-key": "signout",
984
+ });
985
+ if (busy) {
986
+ button.setAttribute("aria-busy", "true");
987
+ button.append((0, dom_js_1.element)("span", { class: "spinner", "aria-hidden": "true" }));
988
+ }
989
+ else {
990
+ button.append(signOutMark());
991
+ }
992
+ button.append((0, dom_js_1.element)("span", {}, busy
993
+ ? labels.signingOut(name)
994
+ : failed
995
+ ? labels.retry
996
+ : this.#viaIdentity
997
+ ? labels.signOutBoth(name)
998
+ : labels.signOut(name)));
999
+ section.append(button);
1000
+ if (failed) {
1001
+ button.setAttribute("aria-describedby", "signout-failed");
1002
+ section.append((0, dom_js_1.element)("p", { id: "signout-failed", class: "error", role: "alert" }, labels.signOutFailed(name)));
1003
+ }
1004
+ return section;
1005
+ }
1006
+ };
1007
+ }
1008
+ //# sourceMappingURL=account.js.map