@ghub/identity-chrome 0.2.0 → 0.4.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.
package/README.md CHANGED
@@ -76,7 +76,7 @@ const account = document.querySelector("gp-account")!;
76
76
  account.core = chrome;
77
77
  account.product = "GAdvisory"; // your own name, from your catalogue entry
78
78
  account.person = { name: user.name, email: user.email, id: user.id, avatarUrl: user.picture };
79
- account.organisations = [{ id: "org_1", name: "Hafen Labs", role: "admin", active: true }];
79
+ account.organisations = [{ id: "org_1", name: "Hafen Labs", role: "Admin", active: true }];
80
80
  account.onSwitch = async (id) => switchOrganisation(id); // reject when it did not switch
81
81
  account.onSignOut = async () => {
82
82
  await signOut(); // reject when it failed: the popup says they are still signed in
@@ -89,7 +89,8 @@ set before the element is defined. Neither control renders anything while the co
89
89
  and the account popup renders nothing without a person.
90
90
 
91
91
  **The account popup shows mostly what the product passes**: the name and address from your
92
- session, your organisations with their roles (Owner, Admin or Member) and your two actions.
92
+ session, your organisations with the role your product gives them there (its own label, already in the
93
+ person's language and shown as given; none, and the row has no second line) and your two actions.
93
94
  Identity adds the picture, the colour of the initial, the links to its own account and set up
94
95
  screens and the footer, so the popup still signs somebody out while Identity is down. The
95
96
  picture is Identity's if the person has one, else yours, else a coloured initial whose tint
@@ -99,6 +100,38 @@ Identity, and Sign out says it ends both. Sign out leaves this product only.
99
100
 
100
101
  Every word can be replaced through `labels`; the rest stay English.
101
102
 
103
+ ### At the bottom of a sidebar, with your own items
104
+
105
+ ```html
106
+ <div style="display: flex; align-items: center">
107
+ <gp-account
108
+ layout="row"
109
+ density="compact"
110
+ placement="top"
111
+ align="start"
112
+ style="flex: 1"
113
+ ></gp-account>
114
+ <gp-apps placement="top" align="start"></gp-apps>
115
+ </div>
116
+ ```
117
+
118
+ ```ts
119
+ account.labels = { organisations: "Groups" };
120
+ account.items = [
121
+ { id: "profile", label: "Profile", href: "/settings/profile", icon: userSvg },
122
+ { id: "account", label: "Account", href: "/settings/account", icon: settingsSvg },
123
+ { id: "shortcuts", label: "Keyboard shortcuts", onSelect: () => openShortcuts(), icon: keysSvg },
124
+ ];
125
+ ```
126
+
127
+ `layout="row"` draws the trigger as a full width row, the picture with the name over the
128
+ address; `density="compact"` draws the account popup a step smaller (320 wide, tighter rows),
129
+ so it does not cover the rail and half the page; `placement="top"` opens both popups upward.
130
+ Your items follow the organisations under your product's name, as links or as buttons; on a link, `onSelect` may call
131
+ `preventDefault()` and navigate with your own router. An icon is SVG markup on a 24 grid and
132
+ is kept to its shapes. Until you sign in through Identity, Sign out is the last row of your
133
+ items' group; with `viaIdentity` it ends both sessions and stands on its own.
134
+
102
135
  ### In React
103
136
 
104
137
  ```tsx
@@ -32,13 +32,24 @@
32
32
  * sentence that the person is still signed in, and turns the row into Try
33
33
  * again. It never claims to have signed out.
34
34
  *
35
+ * **In a sidebar** (`layout="row"`, `placement="top"`): the trigger is a full
36
+ * width row, the picture with the name over the address, and the popup opens
37
+ * upward from it. A product with a sidebar puts `<gp-apps>` at the row's end.
38
+ * `density="compact"` draws the popup a step smaller (320 wide, tighter rows),
39
+ * so it does not cover the rail and half the page.
40
+ *
41
+ * **The product's own items** (`items`) sit after the organisations, as a group
42
+ * headed with the product's name. Until the product signs in through Identity,
43
+ * Sign out is the last row of that same group, because it is one of the
44
+ * product's own actions; once it does (`viaIdentity`), signing out ends both
45
+ * sessions and stands on its own again.
46
+ *
35
47
  * **Hidden for now (D20):** the other accounts and "Add another account" are
36
48
  * not drawn until Identity holds several sessions in one browser and products
37
49
  * sign in through it, and no product can switch them on.
38
50
  *
39
51
  * The words are English by default and every one can be replaced (`labels`).
40
52
  */
41
- export type OrganisationRole = "owner" | "admin" | "member";
42
53
  /** Who is signed in, from the product's own session. */
43
54
  export interface AccountPerson {
44
55
  name: string;
@@ -52,9 +63,29 @@ export interface AccountPerson {
52
63
  export interface AccountOrganisation {
53
64
  id: string;
54
65
  name: string;
55
- role: OrganisationRole;
66
+ /**
67
+ * The product's own role for this person here, already in the person's
68
+ * language and shown as given. Roles differ per product and can be custom,
69
+ * so the element has no list of its own. None, and the row has no second line.
70
+ */
71
+ role?: string | null;
56
72
  active: boolean;
57
73
  }
74
+ /** One of the product's own items in the popup, such as its profile or its settings. */
75
+ export interface AccountItem {
76
+ /** Stable across renders, so focus stays on the item when the list is set again. */
77
+ id: string;
78
+ label: string;
79
+ /** A link, so middle click and opening in a new tab work as on any link. Relative to the page. */
80
+ href?: string;
81
+ /**
82
+ * Called when it is chosen. On a link, `event.preventDefault()` hands the
83
+ * navigation to the product, such as a client side router.
84
+ */
85
+ onSelect?: (event: MouseEvent) => void;
86
+ /** SVG markup on a 24 grid, drawn at 18 in the row's ink. Only shapes are kept. */
87
+ icon?: string;
88
+ }
58
89
  /** Signs the person out of this product. Rejecting means they are still signed in. */
59
90
  export type SignOutHandler = () => unknown;
60
91
  /** Switches the product to this organisation. Rejecting means it did not. */
@@ -68,7 +99,6 @@ export interface AccountLabels {
68
99
  manage: string;
69
100
  setUp: string;
70
101
  organisations: string;
71
- roles: Record<OrganisationRole, string>;
72
102
  active: string;
73
103
  showAll: (count: number) => string;
74
104
  switched: (organisation: string) => string;
@@ -86,6 +116,11 @@ export interface AccountLabels {
86
116
  }
87
117
  export declare const ACCOUNT_LABELS: AccountLabels;
88
118
  export declare const ACCOUNT_CSS: string;
119
+ /**
120
+ * A product's icon, kept to shapes: no script, no link, no style and no reference
121
+ * to anything outside it, because it is drawn inside every page the element is on.
122
+ */
123
+ export declare function iconOf(markup: string): SVGSVGElement | null;
89
124
  /**
90
125
  * Defines `<gp-account>` once, where custom elements exist. Safe to import on a
91
126
  * server: nothing touches the DOM until this is called in a browser.
@@ -33,6 +33,18 @@
33
33
  * sentence that the person is still signed in, and turns the row into Try
34
34
  * again. It never claims to have signed out.
35
35
  *
36
+ * **In a sidebar** (`layout="row"`, `placement="top"`): the trigger is a full
37
+ * width row, the picture with the name over the address, and the popup opens
38
+ * upward from it. A product with a sidebar puts `<gp-apps>` at the row's end.
39
+ * `density="compact"` draws the popup a step smaller (320 wide, tighter rows),
40
+ * so it does not cover the rail and half the page.
41
+ *
42
+ * **The product's own items** (`items`) sit after the organisations, as a group
43
+ * headed with the product's name. Until the product signs in through Identity,
44
+ * Sign out is the last row of that same group, because it is one of the
45
+ * product's own actions; once it does (`viaIdentity`), signing out ends both
46
+ * sessions and stands on its own again.
47
+ *
36
48
  * **Hidden for now (D20):** the other accounts and "Add another account" are
37
49
  * not drawn until Identity holds several sessions in one browser and products
38
50
  * sign in through it, and no product can switch them on.
@@ -41,6 +53,7 @@
41
53
  */
42
54
  Object.defineProperty(exports, "__esModule", { value: true });
43
55
  exports.ACCOUNT_CSS = exports.ACCOUNT_LABELS = void 0;
56
+ exports.iconOf = iconOf;
44
57
  exports.defineAccountElement = defineAccountElement;
45
58
  exports.createAccountElementClass = createAccountElementClass;
46
59
  const tint_js_1 = require("../tint.js");
@@ -56,7 +69,6 @@ exports.ACCOUNT_LABELS = {
56
69
  manage: "Manage your Identity account",
57
70
  setUp: "Set up your Identity",
58
71
  organisations: "Organisations",
59
- roles: { owner: "Owner", admin: "Admin", member: "Member" },
60
72
  active: "Active",
61
73
  showAll: (count) => `Show all ${String(count)}`,
62
74
  switched: (organisation) => `Switched to ${organisation}`,
@@ -78,7 +90,6 @@ const SHOWN = 5;
78
90
  const TIP_DELAY_MS = 500;
79
91
  /** Long enough to cross from the trigger to its tooltip without losing it. */
80
92
  const TIP_LINGER_MS = 150;
81
- const ROLES = ["owner", "admin", "member"];
82
93
  const tintRules = tint_js_1.AVATAR_TINTS.map((tint, index) => `.t${String(index)} { background: ${tint}; }`).join("\n ");
83
94
  exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
84
95
  .trigger {
@@ -89,7 +100,17 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
89
100
  .trigger:active { background: var(--_active); }
90
101
  .trigger[aria-expanded="true"] { box-shadow: 0 0 0 2px var(--_accent); }
91
102
  .trigger:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
92
- @media (pointer: coarse) { .trigger { box-sizing: content-box; padding: 2px; } }
103
+ @media (pointer: coarse) { :host(:not([layout="row"])) .trigger { box-sizing: content-box; padding: 2px; } }
104
+ :host([layout="row"]) { display: block; }
105
+ :host([layout="row"]) .trigger {
106
+ box-sizing: border-box; width: 100%; height: auto; min-height: 44px; padding: 6px 8px; border-radius: 10px;
107
+ display: flex; align-items: center; gap: 10px; text-align: left; font: inherit;
108
+ }
109
+ :host([layout="row"]) .trigger[aria-expanded="true"] { background: var(--_hover); box-shadow: inset 0 0 0 1px var(--_accent); }
110
+ :host([layout="row"]) .trigger:focus-visible { outline-offset: 0; }
111
+ .who { flex: 1; min-width: 0; display: flex; flex-direction: column; }
112
+ .who-name { font-size: 14px; line-height: 19px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
113
+ .who-mail { font-size: 12px; line-height: 16px; color: var(--_ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
93
114
 
94
115
  .avatar {
95
116
  position: relative; display: grid; place-items: center; flex-shrink: 0; overflow: hidden;
@@ -117,6 +138,8 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
117
138
  box-shadow: var(--_shadow); color: var(--_ink); animation: rise 120ms ease-out;
118
139
  }
119
140
  :host([align="start"]) .popup, :host([align="start"]) .tip { right: auto; left: 0; }
141
+ :host([placement="top"]) .popup:not(.sheet) { top: auto; bottom: calc(100% + 8px); }
142
+ :host([placement="top"]) .tip { top: auto; bottom: calc(100% + 6px); }
120
143
  .popup.sheet {
121
144
  position: fixed; top: auto; right: 0; bottom: 0; left: 0; width: 100%; max-width: none;
122
145
  max-height: calc(100dvh - 48px); border-radius: 20px 20px 0 0;
@@ -175,6 +198,20 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
175
198
 
176
199
  .section { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
177
200
  .heading { margin: 0; padding: 8px 16px 4px; font-size: 13px; line-height: 18px; font-weight: 500; color: var(--_ink-2); }
201
+ .heading.with-mark { display: flex; align-items: center; gap: 8px; }
202
+ .heading img { display: block; width: 16px; height: 16px; flex-shrink: 0; }
203
+ .stack { display: flex; flex-direction: column; gap: 2px; }
204
+ .stack > * { border-radius: 4px; }
205
+ .stack > :first-child { border-top-left-radius: 18px; border-top-right-radius: 18px; }
206
+ .stack > :last-child { border-bottom-left-radius: 18px; border-bottom-right-radius: 18px; }
207
+ .item {
208
+ display: flex; align-items: center; gap: 12px; box-sizing: border-box; width: 100%; min-height: 48px; padding: 0 16px;
209
+ border: 0; color: var(--_ink); font: inherit; font-size: 14px; line-height: 20px; font-weight: 500;
210
+ text-align: left; text-decoration: none; cursor: pointer;
211
+ }
212
+ .item svg { flex-shrink: 0; color: var(--_ink-2); }
213
+ .item:focus-visible { outline: 2px solid var(--_ring); outline-offset: -2px; }
214
+ a.item:hover, button.item:hover { background-image: linear-gradient(var(--_hover), var(--_hover)); }
178
215
  .wrap, .radios { display: flex; flex-direction: column; gap: 2px; }
179
216
  .radios.all {
180
217
  max-height: 300px; overflow-y: auto; overscroll-behavior: contain;
@@ -206,7 +243,7 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
206
243
  .more:focus-visible, .signout:focus-visible { outline: 2px solid var(--_ring); outline-offset: -2px; }
207
244
  .signout {
208
245
  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;
246
+ border: 0; color: var(--_ink); font: inherit; font-size: 14px; font-weight: 500;
210
247
  text-align: left; cursor: pointer;
211
248
  }
212
249
  .signout[aria-busy="true"] { color: var(--_ink-2); cursor: progress; }
@@ -223,6 +260,40 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
223
260
  .legal a:hover { text-decoration: underline; }
224
261
  .legal a:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
225
262
 
263
+ /* density="compact": the same popup a step smaller, for a sidebar, where the
264
+ full one covers the rail and half the page. Below 600 it is the same sheet. */
265
+ :host([density="compact"]) .popup:not(.sheet) { width: 320px; padding: 6px 6px 8px; border-radius: 22px; }
266
+ :host([density="compact"]) .top { min-height: 32px; padding-left: 36px; }
267
+ :host([density="compact"]) .context { font-size: 12px; line-height: 16px; }
268
+ :host([density="compact"]) .close { width: 32px; height: 32px; }
269
+ :host([density="compact"]) .card { padding: 14px 14px 14px; border-radius: 18px; gap: 1px; }
270
+ :host([density="compact"]) .portrait { width: 52px; height: 52px; margin-bottom: 8px; }
271
+ :host([density="compact"]) .s72 { width: 52px; height: 52px; font-size: 22px; }
272
+ :host([density="compact"]) .pencil { width: 24px; height: 24px; right: -5px; bottom: -3px; }
273
+ :host([density="compact"]) .name { font-size: 16px; line-height: 21px; }
274
+ :host([density="compact"]) .email { font-size: 13px; line-height: 18px; }
275
+ :host([density="compact"]) .pill {
276
+ min-height: 34px; margin-top: 10px; padding: 0 14px 0 10px; gap: 8px; font-size: 13px; line-height: 18px;
277
+ }
278
+ :host([density="compact"]) .pill img { width: 18px; height: 18px; }
279
+ :host([density="compact"]) .section { margin-top: 4px; }
280
+ :host([density="compact"]) .heading { padding: 6px 12px 2px; font-size: 12px; line-height: 16px; }
281
+ :host([density="compact"]) .heading img { width: 14px; height: 14px; }
282
+ :host([density="compact"]) .stack > :first-child { border-top-left-radius: 14px; border-top-right-radius: 14px; }
283
+ :host([density="compact"]) .stack > :last-child { border-bottom-left-radius: 14px; border-bottom-right-radius: 14px; }
284
+ :host([density="compact"]) .item { min-height: 38px; padding: 0 12px; gap: 10px; font-size: 13px; line-height: 18px; }
285
+ :host([density="compact"]) .item svg, :host([density="compact"]) .signout svg { width: 16px; height: 16px; }
286
+ :host([density="compact"]) .org { min-height: 44px; padding: 5px 12px; gap: 10px; }
287
+ :host([density="compact"]) .wrap > .radios:last-child > .org:last-child { border-radius: 4px 4px 14px 14px; }
288
+ :host([density="compact"]) .org-mark { width: 28px; height: 28px; border-radius: 8px; font-size: 12.5px; }
289
+ :host([density="compact"]) .org-name { font-size: 13px; line-height: 18px; }
290
+ :host([density="compact"]) .org-role { font-size: 11.5px; line-height: 15px; }
291
+ :host([density="compact"]) .active { font-size: 12px; gap: 5px; }
292
+ :host([density="compact"]) .more { min-height: 38px; padding: 0 12px; font-size: 13px; border-radius: 4px 4px 14px 14px; }
293
+ :host([density="compact"]) .signout { min-height: 40px; padding: 0 12px; gap: 10px; font-size: 13px; }
294
+ :host([density="compact"]) .error { padding: 4px 12px 2px; font-size: 12px; line-height: 17px; }
295
+ :host([density="compact"]) .legal { padding: 8px 0 2px; font-size: 11.5px; line-height: 16px; }
296
+
226
297
  @media (prefers-reduced-motion: reduce) {
227
298
  .popup, .popup.sheet { animation: none; }
228
299
  .spinner { animation-duration: 2400ms; }
@@ -231,7 +302,7 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
231
302
  .popup { background: var(--_tint-solid); -webkit-backdrop-filter: none; backdrop-filter: none; }
232
303
  }
233
304
  @media (forced-colors: active) {
234
- .popup, .panel, .org { border: 1px solid CanvasText; background: Canvas; }
305
+ .popup, .panel, .org, .item { border: 1px solid CanvasText; background: Canvas; }
235
306
  .org[aria-checked="true"] { outline: 2px solid Highlight; outline-offset: -2px; }
236
307
  .avatar, .org-mark { border: 1px solid CanvasText; }
237
308
  .pill, .legal a { color: LinkText; }
@@ -281,8 +352,8 @@ function emailLine(email) {
281
352
  }
282
353
  return line;
283
354
  }
284
- /** The product's own picture: an absolute or same-site web address, never anything else. */
285
- function productPicture(value) {
355
+ /** An address the product gave, absolute or relative to the page, and only ever http or https. */
356
+ function productAddress(value) {
286
357
  if (typeof value !== "string" || value === "")
287
358
  return null;
288
359
  try {
@@ -292,6 +363,108 @@ function productPicture(value) {
292
363
  return null;
293
364
  }
294
365
  }
366
+ const ICON_TAGS = new Set([
367
+ "svg",
368
+ "g",
369
+ "path",
370
+ "circle",
371
+ "ellipse",
372
+ "line",
373
+ "polyline",
374
+ "polygon",
375
+ "rect",
376
+ ]);
377
+ const ICON_ATTRIBUTES = new Set([
378
+ "viewBox",
379
+ "d",
380
+ "cx",
381
+ "cy",
382
+ "r",
383
+ "rx",
384
+ "ry",
385
+ "x",
386
+ "y",
387
+ "x1",
388
+ "y1",
389
+ "x2",
390
+ "y2",
391
+ "width",
392
+ "height",
393
+ "points",
394
+ "fill",
395
+ "stroke",
396
+ "stroke-width",
397
+ "stroke-linecap",
398
+ "stroke-linejoin",
399
+ "fill-rule",
400
+ "clip-rule",
401
+ "transform",
402
+ "opacity",
403
+ ]);
404
+ /**
405
+ * A product's icon, kept to shapes: no script, no link, no style and no reference
406
+ * to anything outside it, because it is drawn inside every page the element is on.
407
+ */
408
+ function iconOf(markup) {
409
+ let parsed;
410
+ try {
411
+ parsed = new DOMParser().parseFromString(markup, "image/svg+xml");
412
+ }
413
+ catch {
414
+ return null;
415
+ }
416
+ const root = parsed.documentElement;
417
+ if (root.localName !== "svg" || parsed.getElementsByTagName("parsererror").length > 0)
418
+ return null;
419
+ const copy = (source) => {
420
+ if (!ICON_TAGS.has(source.localName))
421
+ return null;
422
+ const made = document.createElementNS(dom_js_1.SVG_NS, source.localName);
423
+ for (const attribute of [...source.attributes]) {
424
+ if (ICON_ATTRIBUTES.has(attribute.name) && !/url\(|javascript:|&/iu.test(attribute.value)) {
425
+ made.setAttribute(attribute.name, attribute.value);
426
+ }
427
+ }
428
+ for (const child of [...source.children]) {
429
+ const kept = copy(child);
430
+ if (kept !== null)
431
+ made.append(kept);
432
+ }
433
+ return made;
434
+ };
435
+ const svg = copy(root);
436
+ if (svg === null)
437
+ return null;
438
+ svg.setAttribute("width", "18");
439
+ svg.setAttribute("height", "18");
440
+ svg.setAttribute("aria-hidden", "true");
441
+ svg.setAttribute("focusable", "false");
442
+ return svg;
443
+ }
444
+ function itemsOf(value) {
445
+ if (!Array.isArray(value))
446
+ return [];
447
+ const out = [];
448
+ for (const one of value) {
449
+ if (typeof one !== "object" || one === null)
450
+ continue;
451
+ const { id, label, href, onSelect, icon } = one;
452
+ if (typeof id !== "string" || id === "" || typeof label !== "string" || label.trim() === "")
453
+ continue;
454
+ const link = typeof href === "string" ? productAddress(href) : null;
455
+ const select = typeof onSelect === "function" ? onSelect : null;
456
+ if (link === null && select === null)
457
+ continue;
458
+ out.push({
459
+ id,
460
+ label: label.trim(),
461
+ href: link,
462
+ onSelect: select,
463
+ icon: typeof icon === "string" ? iconOf(icon) : null,
464
+ });
465
+ }
466
+ return out;
467
+ }
295
468
  function personOf(value) {
296
469
  if (typeof value !== "object" || value === null)
297
470
  return null;
@@ -316,9 +489,8 @@ function organisationsOf(value) {
316
489
  const { id, name, role, active } = one;
317
490
  if (typeof id !== "string" || id === "" || typeof name !== "string" || name.trim() === "")
318
491
  continue;
319
- if (!ROLES.includes(role))
320
- continue;
321
- out.push({ id, name: name.trim(), role: role, active: active === true });
492
+ const label = typeof role === "string" && role.trim() !== "" ? role.trim() : null;
493
+ out.push({ id, name: name.trim(), role: label, active: active === true });
322
494
  }
323
495
  // The active one first: the row says which one you are acting in.
324
496
  const active = out.findIndex((one) => one.active);
@@ -347,6 +519,8 @@ function createAccountElementClass() {
347
519
  #viaIdentity = false;
348
520
  #onSignOut = null;
349
521
  #onSwitch = null;
522
+ #items = [];
523
+ #given = [];
350
524
  #open = false;
351
525
  #sheet = false;
352
526
  #allShown = false;
@@ -487,16 +661,28 @@ function createAccountElementClass() {
487
661
  this.#onSwitch = typeof next === "function" ? next : null;
488
662
  this.#render();
489
663
  }
664
+ static get observedAttributes() {
665
+ return ["layout"];
666
+ }
667
+ attributeChangedCallback() {
668
+ this.#face = "";
669
+ this.#render();
670
+ }
671
+ get items() {
672
+ return this.#given;
673
+ }
674
+ /** The product's own items. None, and the group is absent. */
675
+ set items(next) {
676
+ this.#given = Array.isArray(next) ? next : [];
677
+ this.#items = itemsOf(next);
678
+ this.#render();
679
+ }
490
680
  get labels() {
491
681
  return this.#labels;
492
682
  }
493
683
  /** Any of the words, replaced; the rest stay English. */
494
684
  set labels(next) {
495
- this.#labels = {
496
- ...exports.ACCOUNT_LABELS,
497
- ...next,
498
- roles: { ...exports.ACCOUNT_LABELS.roles, ...next.roles },
499
- };
685
+ this.#labels = { ...exports.ACCOUNT_LABELS, ...next };
500
686
  this.#render();
501
687
  }
502
688
  get open() {
@@ -512,6 +698,7 @@ function createAccountElementClass() {
512
698
  "viaIdentity",
513
699
  "onSignOut",
514
700
  "onSwitch",
701
+ "items",
515
702
  "labels",
516
703
  ]) {
517
704
  if (Object.prototype.hasOwnProperty.call(this, key)) {
@@ -725,6 +912,9 @@ function createAccountElementClass() {
725
912
  void this.#choose(id);
726
913
  break;
727
914
  }
915
+ case "item":
916
+ this.#chosen(target, event);
917
+ break;
728
918
  default:
729
919
  break;
730
920
  }
@@ -757,6 +947,25 @@ function createAccountElementClass() {
757
947
  if (moved !== undefined)
758
948
  this.#byKey(`org:${moved.id}`)?.focus();
759
949
  }
950
+ /** One of the product's items: its handler runs, a link goes where it goes, and the popup closes. */
951
+ #chosen(target, event) {
952
+ const item = this.#items.find((one) => one.id === target.dataset["id"]);
953
+ if (item === undefined)
954
+ return;
955
+ try {
956
+ item.onSelect?.(event);
957
+ }
958
+ catch {
959
+ // The product's fault, and not a reason to leave the popup open over the page.
960
+ }
961
+ this.close(false);
962
+ // Back to the trigger unless the item took focus somewhere, as a dialog it opened would.
963
+ setTimeout(() => {
964
+ if (document.activeElement === document.body || document.activeElement === null) {
965
+ this.#returnFocus();
966
+ }
967
+ }, 0);
968
+ }
760
969
  async #choose(id) {
761
970
  const handler = this.#onSwitch;
762
971
  const organisation = this.#organisations.find((one) => one.id === id);
@@ -806,7 +1015,7 @@ function createAccountElementClass() {
806
1015
  #faceOf(view, person) {
807
1016
  const own = view?.person.hasIdentity === true ? view.person : null;
808
1017
  return {
809
- picture: own?.avatarUrl ?? productPicture(person.avatarUrl),
1018
+ picture: own?.avatarUrl ?? productAddress(person.avatarUrl),
810
1019
  tint: own?.tint ?? (0, tint_js_1.avatarTint)(person.id ?? person.email.toLowerCase()),
811
1020
  initial: initialOf(person.name),
812
1021
  };
@@ -823,13 +1032,20 @@ function createAccountElementClass() {
823
1032
  const labels = this.#labels;
824
1033
  const view = state.view;
825
1034
  const name = labels.trigger(person.name);
826
- this.#trigger.setAttribute("aria-label", name);
1035
+ const row = this.getAttribute("layout") === "row";
1036
+ // In a row the address is on screen too, so the name says it as well (2.5.3, label in name).
1037
+ this.#trigger.setAttribute("aria-label", row ? `${name}, ${person.email}` : name);
827
1038
  this.#tip.textContent = name;
828
1039
  const face = this.#faceOf(view, person);
829
- const signature = `${face.picture ?? ""} ${String(face.tint)} ${face.initial}`;
1040
+ const signature = `${face.picture ?? ""} ${String(face.tint)} ${face.initial} ${row ? `${person.name} ${person.email}` : ""}`;
830
1041
  if (signature !== this.#face) {
831
1042
  this.#face = signature;
832
1043
  this.#trigger.replaceChildren(avatar(face, "s32"));
1044
+ if (row) {
1045
+ const who = (0, dom_js_1.element)("span", { class: "who", "aria-hidden": "true" });
1046
+ who.append((0, dom_js_1.element)("span", { class: "who-name" }, person.name), (0, dom_js_1.element)("span", { class: "who-mail" }, person.email));
1047
+ this.#trigger.append(who);
1048
+ }
833
1049
  }
834
1050
  if (!this.#open)
835
1051
  return;
@@ -910,8 +1126,7 @@ function createAccountElementClass() {
910
1126
  out.append(card);
911
1127
  if (this.#organisations.length > 0)
912
1128
  out.append(this.#organisationsSection());
913
- if (this.#onSignOut !== null)
914
- out.append(this.#signOutSection(product));
1129
+ out.append(...this.#actionSections(view, product));
915
1130
  if (view !== null && view.legal !== null) {
916
1131
  const footer = (0, dom_js_1.element)("div", { class: "legal" });
917
1132
  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));
@@ -938,19 +1153,20 @@ function createAccountElementClass() {
938
1153
  if (this.#switching !== null)
939
1154
  radios.setAttribute("aria-busy", "true");
940
1155
  for (const one of shown) {
941
- const role = labels.roles[one.role];
942
1156
  const row = (0, dom_js_1.element)("div", {
943
1157
  class: "org panel",
944
1158
  role: "radio",
945
1159
  "aria-checked": String(one.active),
946
- "aria-label": `${one.name}, ${role}`,
1160
+ "aria-label": one.role ? `${one.name}, ${one.role}` : one.name,
947
1161
  tabindex: one === stop ? "0" : "-1",
948
1162
  "data-action": "org",
949
1163
  "data-id": one.id,
950
1164
  "data-key": `org:${one.id}`,
951
1165
  });
952
1166
  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));
1167
+ text.append((0, dom_js_1.element)("span", { class: "org-name", title: one.name }, one.name));
1168
+ if (one.role)
1169
+ text.append((0, dom_js_1.element)("span", { class: "org-role" }, one.role));
954
1170
  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
1171
  if (one.active) {
956
1172
  const tag = (0, dom_js_1.element)("span", { class: "active", "aria-hidden": "true" });
@@ -970,12 +1186,77 @@ function createAccountElementClass() {
970
1186
  }
971
1187
  return section;
972
1188
  }
973
- #signOutSection(product) {
1189
+ /**
1190
+ * The product's items and Sign out. Until the product signs in through
1191
+ * Identity, Sign out is the last row of the items' group, one of the
1192
+ * product's own actions; once it does, it ends both sessions and stands apart.
1193
+ */
1194
+ #actionSections(view, product) {
974
1195
  const labels = this.#labels;
975
1196
  const name = product ?? labels.thisProduct;
1197
+ const sections = [];
1198
+ const joined = !this.#viaIdentity;
1199
+ let stack = null;
1200
+ let section = null;
1201
+ if (this.#items.length > 0) {
1202
+ section = (0, dom_js_1.element)("div", { class: "section" });
1203
+ if (product !== null) {
1204
+ const heading = (0, dom_js_1.element)("p", { class: "heading with-mark" });
1205
+ const mark = view?.yours.find((tile) => tile.current)?.iconUrl ?? null;
1206
+ if (mark !== null) {
1207
+ heading.append((0, dom_js_1.element)("img", {
1208
+ src: mark,
1209
+ alt: "",
1210
+ width: "16",
1211
+ height: "16",
1212
+ referrerpolicy: "no-referrer",
1213
+ }));
1214
+ }
1215
+ heading.append(product);
1216
+ section.append(heading);
1217
+ }
1218
+ stack = (0, dom_js_1.element)("div", { class: "stack" });
1219
+ for (const item of this.#items)
1220
+ stack.append(this.#itemRow(item));
1221
+ section.append(stack);
1222
+ sections.push(section);
1223
+ }
1224
+ if (this.#onSignOut === null)
1225
+ return sections;
1226
+ const button = this.#signOutButton(name);
1227
+ if (joined && stack !== null && section !== null) {
1228
+ stack.append(button);
1229
+ }
1230
+ else {
1231
+ section = (0, dom_js_1.element)("div", { class: "section" });
1232
+ stack = (0, dom_js_1.element)("div", { class: "stack" });
1233
+ stack.append(button);
1234
+ section.append(stack);
1235
+ sections.push(section);
1236
+ }
1237
+ if (this.#signOut === "failed") {
1238
+ button.setAttribute("aria-describedby", "signout-failed");
1239
+ section.append((0, dom_js_1.element)("p", { id: "signout-failed", class: "error", role: "alert" }, labels.signOutFailed(name)));
1240
+ }
1241
+ return sections;
1242
+ }
1243
+ #itemRow(item) {
1244
+ const row = item.href === null
1245
+ ? (0, dom_js_1.element)("button", { type: "button" })
1246
+ : (0, dom_js_1.element)("a", { href: item.href });
1247
+ row.className = "item panel";
1248
+ row.dataset["action"] = "item";
1249
+ row.dataset["id"] = item.id;
1250
+ row.dataset["key"] = `item:${item.id}`;
1251
+ if (item.icon !== null)
1252
+ row.append(item.icon.cloneNode(true));
1253
+ row.append((0, dom_js_1.element)("span", {}, item.label));
1254
+ return row;
1255
+ }
1256
+ #signOutButton(name) {
1257
+ const labels = this.#labels;
976
1258
  const busy = this.#signOut === "busy";
977
1259
  const failed = this.#signOut === "failed";
978
- const section = (0, dom_js_1.element)("div", { class: "section" });
979
1260
  const button = (0, dom_js_1.element)("button", {
980
1261
  type: "button",
981
1262
  class: "signout panel",
@@ -996,12 +1277,7 @@ function createAccountElementClass() {
996
1277
  : this.#viaIdentity
997
1278
  ? labels.signOutBoth(name)
998
1279
  : 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;
1280
+ return button;
1005
1281
  }
1006
1282
  };
1007
1283
  }
@@ -79,6 +79,7 @@ exports.APPS_CSS = `${contract_js_1.CONTRACT_CSS}
79
79
  animation: rise 120ms ease-out;
80
80
  }
81
81
  :host([align="start"]) .popup { right: auto; left: 0; }
82
+ :host([placement="top"]) .popup:not(.sheet) { top: auto; bottom: calc(100% + 8px); }
82
83
  .popup[hidden], .scrim[hidden] { display: none; }
83
84
  .scroller { overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; gap: 4px; }
84
85
  .popup.sheet {
@@ -5,7 +5,7 @@
5
5
  * in a browser.
6
6
  */
7
7
  import type { ChromeCore } from "../core.js";
8
- import { type AccountLabels, type AccountOrganisation, type AccountPerson, type SignOutHandler, type SwitchHandler } from "./account.js";
8
+ import { type AccountItem, type AccountLabels, type AccountOrganisation, type AccountPerson, type SignOutHandler, type SwitchHandler } from "./account.js";
9
9
  import { type AppsLabels } from "./apps.js";
10
10
  /** What `<gp-apps>` offers its page. */
11
11
  export interface AppsElement extends HTMLElement {
@@ -29,6 +29,8 @@ export interface AccountElement extends HTMLElement {
29
29
  viaIdentity: boolean;
30
30
  onSignOut: SignOutHandler | null;
31
31
  onSwitch: SwitchHandler | null;
32
+ get items(): AccountItem[];
33
+ set items(next: AccountItem[] | null);
32
34
  get labels(): AccountLabels;
33
35
  set labels(next: Partial<AccountLabels>);
34
36
  readonly open: boolean;
@@ -43,6 +45,6 @@ declare global {
43
45
  }
44
46
  /** Defines `<gp-apps>` and `<gp-account>`, each once. */
45
47
  export declare function defineChromeElements(): void;
46
- export { ACCOUNT_LABELS, createAccountElementClass, defineAccountElement, type AccountLabels, type AccountOrganisation, type AccountPerson, type OrganisationRole, type SignOutHandler, type SwitchHandler, } from "./account.js";
48
+ export { ACCOUNT_LABELS, createAccountElementClass, defineAccountElement, iconOf, type AccountItem, type AccountLabels, type AccountOrganisation, type AccountPerson, type SignOutHandler, type SwitchHandler, } from "./account.js";
47
49
  export { APPS_LABELS, createAppsElementClass, defineAppsElement, type AppsLabels } from "./apps.js";
48
50
  //# sourceMappingURL=index.d.ts.map
@@ -6,7 +6,7 @@
6
6
  * in a browser.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.defineAppsElement = exports.createAppsElementClass = exports.APPS_LABELS = exports.defineAccountElement = exports.createAccountElementClass = exports.ACCOUNT_LABELS = void 0;
9
+ exports.defineAppsElement = exports.createAppsElementClass = exports.APPS_LABELS = exports.iconOf = exports.defineAccountElement = exports.createAccountElementClass = exports.ACCOUNT_LABELS = void 0;
10
10
  exports.defineChromeElements = defineChromeElements;
11
11
  const account_js_1 = require("./account.js");
12
12
  const apps_js_1 = require("./apps.js");
@@ -19,6 +19,7 @@ var account_js_2 = require("./account.js");
19
19
  Object.defineProperty(exports, "ACCOUNT_LABELS", { enumerable: true, get: function () { return account_js_2.ACCOUNT_LABELS; } });
20
20
  Object.defineProperty(exports, "createAccountElementClass", { enumerable: true, get: function () { return account_js_2.createAccountElementClass; } });
21
21
  Object.defineProperty(exports, "defineAccountElement", { enumerable: true, get: function () { return account_js_2.defineAccountElement; } });
22
+ Object.defineProperty(exports, "iconOf", { enumerable: true, get: function () { return account_js_2.iconOf; } });
22
23
  var apps_js_2 = require("./apps.js");
23
24
  Object.defineProperty(exports, "APPS_LABELS", { enumerable: true, get: function () { return apps_js_2.APPS_LABELS; } });
24
25
  Object.defineProperty(exports, "createAppsElementClass", { enumerable: true, get: function () { return apps_js_2.createAppsElementClass; } });
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  export { CACHE_KEY, CACHE_MAX_AGE_MS, DEFAULT_TIMEOUT_MS, createChromeCore, type ChromeCore, type ChromeCoreOptions, type ChromeState, type ChromeStatus, type StorageLike, } from "./core.js";
11
11
  export { catalogueMarkUrl, createCatalogue, type Catalogue, type CatalogueOptions, } from "./catalogue.js";
12
- export { ACCOUNT_LABELS, APPS_LABELS, createAccountElementClass, createAppsElementClass, defineAccountElement, defineAppsElement, defineChromeElements, type AccountElement, type AccountLabels, type AccountOrganisation, type AccountPerson, type AppsElement, type AppsLabels, type OrganisationRole, type SignOutHandler, type SwitchHandler, } from "./elements/index.js";
12
+ export { ACCOUNT_LABELS, APPS_LABELS, createAccountElementClass, createAppsElementClass, defineAccountElement, defineAppsElement, defineChromeElements, type AccountElement, type AccountItem, type AccountLabels, type AccountOrganisation, type AccountPerson, type AppsElement, type AppsLabels, type SignOutHandler, type SwitchHandler, } from "./elements/index.js";
13
13
  export { AVATAR_INK, AVATAR_TINTS, avatarTint } from "./tint.js";
14
14
  export type { CatalogueEntry, ChromeIdentityLinks, ChromeLegal, ChromePerson, ChromePublicView, ChromeTile, ChromeView, } from "./types.js";
15
15
  export { safeUrl } from "./validate.js";
package/dist/react.d.ts CHANGED
@@ -9,17 +9,20 @@
9
9
  */
10
10
  import { type ReactElement } from "react";
11
11
  import type { ChromeCore } from "./core.js";
12
- import type { AccountLabels, AccountOrganisation, AccountPerson, AppsLabels, SignOutHandler, SwitchHandler } from "./elements/index.js";
12
+ import type { AccountItem, AccountLabels, AccountOrganisation, AccountPerson, AppsLabels, SignOutHandler, SwitchHandler } from "./elements/index.js";
13
13
  /** Which edge of the trigger the popup lines up with. The end, by default. */
14
14
  type Align = "start" | "end";
15
+ /** Whether the popup opens below the trigger (a top bar) or above it (the bottom of a sidebar). Below, by default. */
16
+ type Placement = "bottom" | "top";
15
17
  export interface GpAppsProps {
16
18
  core: ChromeCore | null;
17
19
  labels?: Partial<AppsLabels>;
18
20
  align?: Align;
21
+ placement?: Placement;
19
22
  className?: string;
20
23
  }
21
24
  /** `<gp-apps>`, the apps picker. */
22
- export declare function GpApps({ core, labels, align, className }: GpAppsProps): ReactElement;
25
+ export declare function GpApps({ core, labels, align, placement, className }: GpAppsProps): ReactElement;
23
26
  export interface GpAccountProps {
24
27
  core: ChromeCore | null;
25
28
  /** Who is signed in, from the product's own session. Nothing renders without one. */
@@ -31,11 +34,18 @@ export interface GpAccountProps {
31
34
  viaIdentity?: boolean;
32
35
  onSignOut?: SignOutHandler | null;
33
36
  onSwitch?: SwitchHandler | null;
37
+ /** The product's own items, after the organisations. */
38
+ items?: AccountItem[] | null;
34
39
  labels?: Partial<AccountLabels>;
40
+ /** `row` for the bottom of a sidebar: the picture with the name over the address, full width. */
41
+ layout?: "button" | "row";
42
+ /** `compact` for a sidebar: the popup a step smaller. */
43
+ density?: "regular" | "compact";
35
44
  align?: Align;
45
+ placement?: Placement;
36
46
  className?: string;
37
47
  }
38
48
  /** `<gp-account>`, the account popup. */
39
- export declare function GpAccount({ core, person, organisations, product, viaIdentity, onSignOut, onSwitch, labels, align, className, }: GpAccountProps): ReactElement;
49
+ export declare function GpAccount({ core, person, organisations, product, viaIdentity, onSignOut, onSwitch, items, labels, layout, density, align, placement, className, }: GpAccountProps): ReactElement;
40
50
  export {};
41
51
  //# sourceMappingURL=react.d.ts.map
package/dist/react.js CHANGED
@@ -15,7 +15,7 @@ exports.GpAccount = GpAccount;
15
15
  const react_1 = require("react");
16
16
  const index_js_1 = require("./elements/index.js");
17
17
  /** `<gp-apps>`, the apps picker. */
18
- function GpApps({ core, labels, align, className }) {
18
+ function GpApps({ core, labels, align, placement, className }) {
19
19
  const ref = (0, react_1.useRef)(null);
20
20
  (0, react_1.useEffect)(() => {
21
21
  (0, index_js_1.defineChromeElements)();
@@ -28,10 +28,10 @@ function GpApps({ core, labels, align, className }) {
28
28
  if (ref.current !== null && labels !== undefined)
29
29
  ref.current.labels = labels;
30
30
  }, [labels]);
31
- return (0, react_1.createElement)("gp-apps", { ref, className, align });
31
+ return (0, react_1.createElement)("gp-apps", { ref, className, align, placement });
32
32
  }
33
33
  /** `<gp-account>`, the account popup. */
34
- function GpAccount({ core, person, organisations = null, product = null, viaIdentity = false, onSignOut = null, onSwitch = null, labels, align, className, }) {
34
+ function GpAccount({ core, person, organisations = null, product = null, viaIdentity = false, onSignOut = null, onSwitch = null, items = null, labels, layout, density, align, placement, className, }) {
35
35
  const ref = (0, react_1.useRef)(null);
36
36
  (0, react_1.useEffect)(() => {
37
37
  (0, index_js_1.defineChromeElements)();
@@ -64,10 +64,14 @@ function GpAccount({ core, person, organisations = null, product = null, viaIden
64
64
  if (ref.current !== null)
65
65
  ref.current.onSwitch = onSwitch;
66
66
  }, [onSwitch]);
67
+ (0, react_1.useEffect)(() => {
68
+ if (ref.current !== null)
69
+ ref.current.items = items;
70
+ }, [items]);
67
71
  (0, react_1.useEffect)(() => {
68
72
  if (ref.current !== null && labels !== undefined)
69
73
  ref.current.labels = labels;
70
74
  }, [labels]);
71
- return (0, react_1.createElement)("gp-account", { ref, className, align });
75
+ return (0, react_1.createElement)("gp-account", { ref, className, layout, density, align, placement });
72
76
  }
73
77
  //# sourceMappingURL=react.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghub/identity-chrome",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "The shared header controls of the Gelhaus Solutions products: the apps picker and the account popup, as custom elements with shadow DOM and as React wrappers. The browser half (state, cache, the last list held for an outage) and the server half (the token calls and a route handler). No dependencies.",
5
5
  "author": "Gelhaus Solutions (Enno Gelhaus) <egelhaus@ennogelhaus.de> (https://ennogelhaus.de)",
6
6
  "homepage": "https://ennogelhaus.de",