@ghub/identity-chrome 0.2.0 → 0.3.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,31 @@ 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 layout="row" placement="top" align="start" style="flex: 1"></gp-account>
108
+ <gp-apps placement="top" align="start"></gp-apps>
109
+ </div>
110
+ ```
111
+
112
+ ```ts
113
+ account.labels = { organisations: "Groups" };
114
+ account.items = [
115
+ { id: "profile", label: "Profile", href: "/settings/profile", icon: userSvg },
116
+ { id: "account", label: "Account", href: "/settings/account", icon: settingsSvg },
117
+ { id: "replay", label: "Session recording", onSelect: () => openReplayChoice(), icon: videoSvg },
118
+ ];
119
+ ```
120
+
121
+ `layout="row"` draws the trigger as a full width row, the picture with the name over the
122
+ address; `placement="top"` opens both popups upward. Your items follow the organisations
123
+ under your product's name, as links or as buttons; on a link, `onSelect` may call
124
+ `preventDefault()` and navigate with your own router. An icon is SVG markup on a 24 grid and
125
+ is kept to its shapes. Until you sign in through Identity, Sign out is the last row of your
126
+ items' group; with `viaIdentity` it ends both sessions and stands on its own.
127
+
102
128
  ### In React
103
129
 
104
130
  ```tsx
@@ -32,13 +32,22 @@
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
+ *
39
+ * **The product's own items** (`items`) sit after the organisations, as a group
40
+ * headed with the product's name. Until the product signs in through Identity,
41
+ * Sign out is the last row of that same group, because it is one of the
42
+ * product's own actions; once it does (`viaIdentity`), signing out ends both
43
+ * sessions and stands on its own again.
44
+ *
35
45
  * **Hidden for now (D20):** the other accounts and "Add another account" are
36
46
  * not drawn until Identity holds several sessions in one browser and products
37
47
  * sign in through it, and no product can switch them on.
38
48
  *
39
49
  * The words are English by default and every one can be replaced (`labels`).
40
50
  */
41
- export type OrganisationRole = "owner" | "admin" | "member";
42
51
  /** Who is signed in, from the product's own session. */
43
52
  export interface AccountPerson {
44
53
  name: string;
@@ -52,9 +61,29 @@ export interface AccountPerson {
52
61
  export interface AccountOrganisation {
53
62
  id: string;
54
63
  name: string;
55
- role: OrganisationRole;
64
+ /**
65
+ * The product's own role for this person here, already in the person's
66
+ * language and shown as given. Roles differ per product and can be custom,
67
+ * so the element has no list of its own. None, and the row has no second line.
68
+ */
69
+ role?: string | null;
56
70
  active: boolean;
57
71
  }
72
+ /** One of the product's own items in the popup, such as its profile or its settings. */
73
+ export interface AccountItem {
74
+ /** Stable across renders, so focus stays on the item when the list is set again. */
75
+ id: string;
76
+ label: string;
77
+ /** A link, so middle click and opening in a new tab work as on any link. Relative to the page. */
78
+ href?: string;
79
+ /**
80
+ * Called when it is chosen. On a link, `event.preventDefault()` hands the
81
+ * navigation to the product, such as a client side router.
82
+ */
83
+ onSelect?: (event: MouseEvent) => void;
84
+ /** SVG markup on a 24 grid, drawn at 18 in the row's ink. Only shapes are kept. */
85
+ icon?: string;
86
+ }
58
87
  /** Signs the person out of this product. Rejecting means they are still signed in. */
59
88
  export type SignOutHandler = () => unknown;
60
89
  /** Switches the product to this organisation. Rejecting means it did not. */
@@ -68,7 +97,6 @@ export interface AccountLabels {
68
97
  manage: string;
69
98
  setUp: string;
70
99
  organisations: string;
71
- roles: Record<OrganisationRole, string>;
72
100
  active: string;
73
101
  showAll: (count: number) => string;
74
102
  switched: (organisation: string) => string;
@@ -86,6 +114,11 @@ export interface AccountLabels {
86
114
  }
87
115
  export declare const ACCOUNT_LABELS: AccountLabels;
88
116
  export declare const ACCOUNT_CSS: string;
117
+ /**
118
+ * A product's icon, kept to shapes: no script, no link, no style and no reference
119
+ * to anything outside it, because it is drawn inside every page the element is on.
120
+ */
121
+ export declare function iconOf(markup: string): SVGSVGElement | null;
89
122
  /**
90
123
  * Defines `<gp-account>` once, where custom elements exist. Safe to import on a
91
124
  * server: nothing touches the DOM until this is called in a browser.
@@ -33,6 +33,16 @@
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
+ *
40
+ * **The product's own items** (`items`) sit after the organisations, as a group
41
+ * headed with the product's name. Until the product signs in through Identity,
42
+ * Sign out is the last row of that same group, because it is one of the
43
+ * product's own actions; once it does (`viaIdentity`), signing out ends both
44
+ * sessions and stands on its own again.
45
+ *
36
46
  * **Hidden for now (D20):** the other accounts and "Add another account" are
37
47
  * not drawn until Identity holds several sessions in one browser and products
38
48
  * sign in through it, and no product can switch them on.
@@ -41,6 +51,7 @@
41
51
  */
42
52
  Object.defineProperty(exports, "__esModule", { value: true });
43
53
  exports.ACCOUNT_CSS = exports.ACCOUNT_LABELS = void 0;
54
+ exports.iconOf = iconOf;
44
55
  exports.defineAccountElement = defineAccountElement;
45
56
  exports.createAccountElementClass = createAccountElementClass;
46
57
  const tint_js_1 = require("../tint.js");
@@ -56,7 +67,6 @@ exports.ACCOUNT_LABELS = {
56
67
  manage: "Manage your Identity account",
57
68
  setUp: "Set up your Identity",
58
69
  organisations: "Organisations",
59
- roles: { owner: "Owner", admin: "Admin", member: "Member" },
60
70
  active: "Active",
61
71
  showAll: (count) => `Show all ${String(count)}`,
62
72
  switched: (organisation) => `Switched to ${organisation}`,
@@ -78,7 +88,6 @@ const SHOWN = 5;
78
88
  const TIP_DELAY_MS = 500;
79
89
  /** Long enough to cross from the trigger to its tooltip without losing it. */
80
90
  const TIP_LINGER_MS = 150;
81
- const ROLES = ["owner", "admin", "member"];
82
91
  const tintRules = tint_js_1.AVATAR_TINTS.map((tint, index) => `.t${String(index)} { background: ${tint}; }`).join("\n ");
83
92
  exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
84
93
  .trigger {
@@ -89,7 +98,17 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
89
98
  .trigger:active { background: var(--_active); }
90
99
  .trigger[aria-expanded="true"] { box-shadow: 0 0 0 2px var(--_accent); }
91
100
  .trigger:focus-visible { outline: 2px solid var(--_ring); outline-offset: 2px; }
92
- @media (pointer: coarse) { .trigger { box-sizing: content-box; padding: 2px; } }
101
+ @media (pointer: coarse) { :host(:not([layout="row"])) .trigger { box-sizing: content-box; padding: 2px; } }
102
+ :host([layout="row"]) { display: block; }
103
+ :host([layout="row"]) .trigger {
104
+ box-sizing: border-box; width: 100%; height: auto; min-height: 44px; padding: 6px 8px; border-radius: 10px;
105
+ display: flex; align-items: center; gap: 10px; text-align: left; font: inherit;
106
+ }
107
+ :host([layout="row"]) .trigger[aria-expanded="true"] { background: var(--_hover); box-shadow: inset 0 0 0 1px var(--_accent); }
108
+ :host([layout="row"]) .trigger:focus-visible { outline-offset: 0; }
109
+ .who { flex: 1; min-width: 0; display: flex; flex-direction: column; }
110
+ .who-name { font-size: 14px; line-height: 19px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
111
+ .who-mail { font-size: 12px; line-height: 16px; color: var(--_ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
93
112
 
94
113
  .avatar {
95
114
  position: relative; display: grid; place-items: center; flex-shrink: 0; overflow: hidden;
@@ -117,6 +136,8 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
117
136
  box-shadow: var(--_shadow); color: var(--_ink); animation: rise 120ms ease-out;
118
137
  }
119
138
  :host([align="start"]) .popup, :host([align="start"]) .tip { right: auto; left: 0; }
139
+ :host([placement="top"]) .popup:not(.sheet) { top: auto; bottom: calc(100% + 8px); }
140
+ :host([placement="top"]) .tip { top: auto; bottom: calc(100% + 6px); }
120
141
  .popup.sheet {
121
142
  position: fixed; top: auto; right: 0; bottom: 0; left: 0; width: 100%; max-width: none;
122
143
  max-height: calc(100dvh - 48px); border-radius: 20px 20px 0 0;
@@ -175,6 +196,20 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
175
196
 
176
197
  .section { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
177
198
  .heading { margin: 0; padding: 8px 16px 4px; font-size: 13px; line-height: 18px; font-weight: 500; color: var(--_ink-2); }
199
+ .heading.with-mark { display: flex; align-items: center; gap: 8px; }
200
+ .heading img { display: block; width: 16px; height: 16px; flex-shrink: 0; }
201
+ .stack { display: flex; flex-direction: column; gap: 2px; }
202
+ .stack > * { border-radius: 4px; }
203
+ .stack > :first-child { border-top-left-radius: 18px; border-top-right-radius: 18px; }
204
+ .stack > :last-child { border-bottom-left-radius: 18px; border-bottom-right-radius: 18px; }
205
+ .item {
206
+ display: flex; align-items: center; gap: 12px; box-sizing: border-box; width: 100%; min-height: 48px; padding: 0 16px;
207
+ border: 0; color: var(--_ink); font: inherit; font-size: 14px; line-height: 20px; font-weight: 500;
208
+ text-align: left; text-decoration: none; cursor: pointer;
209
+ }
210
+ .item svg { flex-shrink: 0; color: var(--_ink-2); }
211
+ .item:focus-visible { outline: 2px solid var(--_ring); outline-offset: -2px; }
212
+ a.item:hover, button.item:hover { background-image: linear-gradient(var(--_hover), var(--_hover)); }
178
213
  .wrap, .radios { display: flex; flex-direction: column; gap: 2px; }
179
214
  .radios.all {
180
215
  max-height: 300px; overflow-y: auto; overscroll-behavior: contain;
@@ -206,7 +241,7 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
206
241
  .more:focus-visible, .signout:focus-visible { outline: 2px solid var(--_ring); outline-offset: -2px; }
207
242
  .signout {
208
243
  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;
244
+ border: 0; color: var(--_ink); font: inherit; font-size: 14px; font-weight: 500;
210
245
  text-align: left; cursor: pointer;
211
246
  }
212
247
  .signout[aria-busy="true"] { color: var(--_ink-2); cursor: progress; }
@@ -231,7 +266,7 @@ exports.ACCOUNT_CSS = `${contract_js_1.CONTRACT_CSS}
231
266
  .popup { background: var(--_tint-solid); -webkit-backdrop-filter: none; backdrop-filter: none; }
232
267
  }
233
268
  @media (forced-colors: active) {
234
- .popup, .panel, .org { border: 1px solid CanvasText; background: Canvas; }
269
+ .popup, .panel, .org, .item { border: 1px solid CanvasText; background: Canvas; }
235
270
  .org[aria-checked="true"] { outline: 2px solid Highlight; outline-offset: -2px; }
236
271
  .avatar, .org-mark { border: 1px solid CanvasText; }
237
272
  .pill, .legal a { color: LinkText; }
@@ -281,8 +316,8 @@ function emailLine(email) {
281
316
  }
282
317
  return line;
283
318
  }
284
- /** The product's own picture: an absolute or same-site web address, never anything else. */
285
- function productPicture(value) {
319
+ /** An address the product gave, absolute or relative to the page, and only ever http or https. */
320
+ function productAddress(value) {
286
321
  if (typeof value !== "string" || value === "")
287
322
  return null;
288
323
  try {
@@ -292,6 +327,108 @@ function productPicture(value) {
292
327
  return null;
293
328
  }
294
329
  }
330
+ const ICON_TAGS = new Set([
331
+ "svg",
332
+ "g",
333
+ "path",
334
+ "circle",
335
+ "ellipse",
336
+ "line",
337
+ "polyline",
338
+ "polygon",
339
+ "rect",
340
+ ]);
341
+ const ICON_ATTRIBUTES = new Set([
342
+ "viewBox",
343
+ "d",
344
+ "cx",
345
+ "cy",
346
+ "r",
347
+ "rx",
348
+ "ry",
349
+ "x",
350
+ "y",
351
+ "x1",
352
+ "y1",
353
+ "x2",
354
+ "y2",
355
+ "width",
356
+ "height",
357
+ "points",
358
+ "fill",
359
+ "stroke",
360
+ "stroke-width",
361
+ "stroke-linecap",
362
+ "stroke-linejoin",
363
+ "fill-rule",
364
+ "clip-rule",
365
+ "transform",
366
+ "opacity",
367
+ ]);
368
+ /**
369
+ * A product's icon, kept to shapes: no script, no link, no style and no reference
370
+ * to anything outside it, because it is drawn inside every page the element is on.
371
+ */
372
+ function iconOf(markup) {
373
+ let parsed;
374
+ try {
375
+ parsed = new DOMParser().parseFromString(markup, "image/svg+xml");
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ const root = parsed.documentElement;
381
+ if (root.localName !== "svg" || parsed.getElementsByTagName("parsererror").length > 0)
382
+ return null;
383
+ const copy = (source) => {
384
+ if (!ICON_TAGS.has(source.localName))
385
+ return null;
386
+ const made = document.createElementNS(dom_js_1.SVG_NS, source.localName);
387
+ for (const attribute of [...source.attributes]) {
388
+ if (ICON_ATTRIBUTES.has(attribute.name) && !/url\(|javascript:|&/iu.test(attribute.value)) {
389
+ made.setAttribute(attribute.name, attribute.value);
390
+ }
391
+ }
392
+ for (const child of [...source.children]) {
393
+ const kept = copy(child);
394
+ if (kept !== null)
395
+ made.append(kept);
396
+ }
397
+ return made;
398
+ };
399
+ const svg = copy(root);
400
+ if (svg === null)
401
+ return null;
402
+ svg.setAttribute("width", "18");
403
+ svg.setAttribute("height", "18");
404
+ svg.setAttribute("aria-hidden", "true");
405
+ svg.setAttribute("focusable", "false");
406
+ return svg;
407
+ }
408
+ function itemsOf(value) {
409
+ if (!Array.isArray(value))
410
+ return [];
411
+ const out = [];
412
+ for (const one of value) {
413
+ if (typeof one !== "object" || one === null)
414
+ continue;
415
+ const { id, label, href, onSelect, icon } = one;
416
+ if (typeof id !== "string" || id === "" || typeof label !== "string" || label.trim() === "")
417
+ continue;
418
+ const link = typeof href === "string" ? productAddress(href) : null;
419
+ const select = typeof onSelect === "function" ? onSelect : null;
420
+ if (link === null && select === null)
421
+ continue;
422
+ out.push({
423
+ id,
424
+ label: label.trim(),
425
+ href: link,
426
+ onSelect: select,
427
+ icon: typeof icon === "string" ? iconOf(icon) : null,
428
+ });
429
+ }
430
+ return out;
431
+ }
295
432
  function personOf(value) {
296
433
  if (typeof value !== "object" || value === null)
297
434
  return null;
@@ -316,9 +453,8 @@ function organisationsOf(value) {
316
453
  const { id, name, role, active } = one;
317
454
  if (typeof id !== "string" || id === "" || typeof name !== "string" || name.trim() === "")
318
455
  continue;
319
- if (!ROLES.includes(role))
320
- continue;
321
- out.push({ id, name: name.trim(), role: role, active: active === true });
456
+ const label = typeof role === "string" && role.trim() !== "" ? role.trim() : null;
457
+ out.push({ id, name: name.trim(), role: label, active: active === true });
322
458
  }
323
459
  // The active one first: the row says which one you are acting in.
324
460
  const active = out.findIndex((one) => one.active);
@@ -347,6 +483,8 @@ function createAccountElementClass() {
347
483
  #viaIdentity = false;
348
484
  #onSignOut = null;
349
485
  #onSwitch = null;
486
+ #items = [];
487
+ #given = [];
350
488
  #open = false;
351
489
  #sheet = false;
352
490
  #allShown = false;
@@ -487,16 +625,28 @@ function createAccountElementClass() {
487
625
  this.#onSwitch = typeof next === "function" ? next : null;
488
626
  this.#render();
489
627
  }
628
+ static get observedAttributes() {
629
+ return ["layout"];
630
+ }
631
+ attributeChangedCallback() {
632
+ this.#face = "";
633
+ this.#render();
634
+ }
635
+ get items() {
636
+ return this.#given;
637
+ }
638
+ /** The product's own items. None, and the group is absent. */
639
+ set items(next) {
640
+ this.#given = Array.isArray(next) ? next : [];
641
+ this.#items = itemsOf(next);
642
+ this.#render();
643
+ }
490
644
  get labels() {
491
645
  return this.#labels;
492
646
  }
493
647
  /** Any of the words, replaced; the rest stay English. */
494
648
  set labels(next) {
495
- this.#labels = {
496
- ...exports.ACCOUNT_LABELS,
497
- ...next,
498
- roles: { ...exports.ACCOUNT_LABELS.roles, ...next.roles },
499
- };
649
+ this.#labels = { ...exports.ACCOUNT_LABELS, ...next };
500
650
  this.#render();
501
651
  }
502
652
  get open() {
@@ -512,6 +662,7 @@ function createAccountElementClass() {
512
662
  "viaIdentity",
513
663
  "onSignOut",
514
664
  "onSwitch",
665
+ "items",
515
666
  "labels",
516
667
  ]) {
517
668
  if (Object.prototype.hasOwnProperty.call(this, key)) {
@@ -725,6 +876,9 @@ function createAccountElementClass() {
725
876
  void this.#choose(id);
726
877
  break;
727
878
  }
879
+ case "item":
880
+ this.#chosen(target, event);
881
+ break;
728
882
  default:
729
883
  break;
730
884
  }
@@ -757,6 +911,25 @@ function createAccountElementClass() {
757
911
  if (moved !== undefined)
758
912
  this.#byKey(`org:${moved.id}`)?.focus();
759
913
  }
914
+ /** One of the product's items: its handler runs, a link goes where it goes, and the popup closes. */
915
+ #chosen(target, event) {
916
+ const item = this.#items.find((one) => one.id === target.dataset["id"]);
917
+ if (item === undefined)
918
+ return;
919
+ try {
920
+ item.onSelect?.(event);
921
+ }
922
+ catch {
923
+ // The product's fault, and not a reason to leave the popup open over the page.
924
+ }
925
+ this.close(false);
926
+ // Back to the trigger unless the item took focus somewhere, as a dialog it opened would.
927
+ setTimeout(() => {
928
+ if (document.activeElement === document.body || document.activeElement === null) {
929
+ this.#returnFocus();
930
+ }
931
+ }, 0);
932
+ }
760
933
  async #choose(id) {
761
934
  const handler = this.#onSwitch;
762
935
  const organisation = this.#organisations.find((one) => one.id === id);
@@ -806,7 +979,7 @@ function createAccountElementClass() {
806
979
  #faceOf(view, person) {
807
980
  const own = view?.person.hasIdentity === true ? view.person : null;
808
981
  return {
809
- picture: own?.avatarUrl ?? productPicture(person.avatarUrl),
982
+ picture: own?.avatarUrl ?? productAddress(person.avatarUrl),
810
983
  tint: own?.tint ?? (0, tint_js_1.avatarTint)(person.id ?? person.email.toLowerCase()),
811
984
  initial: initialOf(person.name),
812
985
  };
@@ -823,13 +996,20 @@ function createAccountElementClass() {
823
996
  const labels = this.#labels;
824
997
  const view = state.view;
825
998
  const name = labels.trigger(person.name);
826
- this.#trigger.setAttribute("aria-label", name);
999
+ const row = this.getAttribute("layout") === "row";
1000
+ // In a row the address is on screen too, so the name says it as well (2.5.3, label in name).
1001
+ this.#trigger.setAttribute("aria-label", row ? `${name}, ${person.email}` : name);
827
1002
  this.#tip.textContent = name;
828
1003
  const face = this.#faceOf(view, person);
829
- const signature = `${face.picture ?? ""} ${String(face.tint)} ${face.initial}`;
1004
+ const signature = `${face.picture ?? ""} ${String(face.tint)} ${face.initial} ${row ? `${person.name} ${person.email}` : ""}`;
830
1005
  if (signature !== this.#face) {
831
1006
  this.#face = signature;
832
1007
  this.#trigger.replaceChildren(avatar(face, "s32"));
1008
+ if (row) {
1009
+ const who = (0, dom_js_1.element)("span", { class: "who", "aria-hidden": "true" });
1010
+ who.append((0, dom_js_1.element)("span", { class: "who-name" }, person.name), (0, dom_js_1.element)("span", { class: "who-mail" }, person.email));
1011
+ this.#trigger.append(who);
1012
+ }
833
1013
  }
834
1014
  if (!this.#open)
835
1015
  return;
@@ -910,8 +1090,7 @@ function createAccountElementClass() {
910
1090
  out.append(card);
911
1091
  if (this.#organisations.length > 0)
912
1092
  out.append(this.#organisationsSection());
913
- if (this.#onSignOut !== null)
914
- out.append(this.#signOutSection(product));
1093
+ out.append(...this.#actionSections(view, product));
915
1094
  if (view !== null && view.legal !== null) {
916
1095
  const footer = (0, dom_js_1.element)("div", { class: "legal" });
917
1096
  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 +1117,20 @@ function createAccountElementClass() {
938
1117
  if (this.#switching !== null)
939
1118
  radios.setAttribute("aria-busy", "true");
940
1119
  for (const one of shown) {
941
- const role = labels.roles[one.role];
942
1120
  const row = (0, dom_js_1.element)("div", {
943
1121
  class: "org panel",
944
1122
  role: "radio",
945
1123
  "aria-checked": String(one.active),
946
- "aria-label": `${one.name}, ${role}`,
1124
+ "aria-label": one.role ? `${one.name}, ${one.role}` : one.name,
947
1125
  tabindex: one === stop ? "0" : "-1",
948
1126
  "data-action": "org",
949
1127
  "data-id": one.id,
950
1128
  "data-key": `org:${one.id}`,
951
1129
  });
952
1130
  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));
1131
+ text.append((0, dom_js_1.element)("span", { class: "org-name", title: one.name }, one.name));
1132
+ if (one.role)
1133
+ text.append((0, dom_js_1.element)("span", { class: "org-role" }, one.role));
954
1134
  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
1135
  if (one.active) {
956
1136
  const tag = (0, dom_js_1.element)("span", { class: "active", "aria-hidden": "true" });
@@ -970,12 +1150,77 @@ function createAccountElementClass() {
970
1150
  }
971
1151
  return section;
972
1152
  }
973
- #signOutSection(product) {
1153
+ /**
1154
+ * The product's items and Sign out. Until the product signs in through
1155
+ * Identity, Sign out is the last row of the items' group, one of the
1156
+ * product's own actions; once it does, it ends both sessions and stands apart.
1157
+ */
1158
+ #actionSections(view, product) {
974
1159
  const labels = this.#labels;
975
1160
  const name = product ?? labels.thisProduct;
1161
+ const sections = [];
1162
+ const joined = !this.#viaIdentity;
1163
+ let stack = null;
1164
+ let section = null;
1165
+ if (this.#items.length > 0) {
1166
+ section = (0, dom_js_1.element)("div", { class: "section" });
1167
+ if (product !== null) {
1168
+ const heading = (0, dom_js_1.element)("p", { class: "heading with-mark" });
1169
+ const mark = view?.yours.find((tile) => tile.current)?.iconUrl ?? null;
1170
+ if (mark !== null) {
1171
+ heading.append((0, dom_js_1.element)("img", {
1172
+ src: mark,
1173
+ alt: "",
1174
+ width: "16",
1175
+ height: "16",
1176
+ referrerpolicy: "no-referrer",
1177
+ }));
1178
+ }
1179
+ heading.append(product);
1180
+ section.append(heading);
1181
+ }
1182
+ stack = (0, dom_js_1.element)("div", { class: "stack" });
1183
+ for (const item of this.#items)
1184
+ stack.append(this.#itemRow(item));
1185
+ section.append(stack);
1186
+ sections.push(section);
1187
+ }
1188
+ if (this.#onSignOut === null)
1189
+ return sections;
1190
+ const button = this.#signOutButton(name);
1191
+ if (joined && stack !== null && section !== null) {
1192
+ stack.append(button);
1193
+ }
1194
+ else {
1195
+ section = (0, dom_js_1.element)("div", { class: "section" });
1196
+ stack = (0, dom_js_1.element)("div", { class: "stack" });
1197
+ stack.append(button);
1198
+ section.append(stack);
1199
+ sections.push(section);
1200
+ }
1201
+ if (this.#signOut === "failed") {
1202
+ button.setAttribute("aria-describedby", "signout-failed");
1203
+ section.append((0, dom_js_1.element)("p", { id: "signout-failed", class: "error", role: "alert" }, labels.signOutFailed(name)));
1204
+ }
1205
+ return sections;
1206
+ }
1207
+ #itemRow(item) {
1208
+ const row = item.href === null
1209
+ ? (0, dom_js_1.element)("button", { type: "button" })
1210
+ : (0, dom_js_1.element)("a", { href: item.href });
1211
+ row.className = "item panel";
1212
+ row.dataset["action"] = "item";
1213
+ row.dataset["id"] = item.id;
1214
+ row.dataset["key"] = `item:${item.id}`;
1215
+ if (item.icon !== null)
1216
+ row.append(item.icon.cloneNode(true));
1217
+ row.append((0, dom_js_1.element)("span", {}, item.label));
1218
+ return row;
1219
+ }
1220
+ #signOutButton(name) {
1221
+ const labels = this.#labels;
976
1222
  const busy = this.#signOut === "busy";
977
1223
  const failed = this.#signOut === "failed";
978
- const section = (0, dom_js_1.element)("div", { class: "section" });
979
1224
  const button = (0, dom_js_1.element)("button", {
980
1225
  type: "button",
981
1226
  class: "signout panel",
@@ -996,12 +1241,7 @@ function createAccountElementClass() {
996
1241
  : this.#viaIdentity
997
1242
  ? labels.signOutBoth(name)
998
1243
  : 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;
1244
+ return button;
1005
1245
  }
1006
1246
  };
1007
1247
  }
@@ -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,16 @@ 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";
35
42
  align?: Align;
43
+ placement?: Placement;
36
44
  className?: string;
37
45
  }
38
46
  /** `<gp-account>`, the account popup. */
39
- export declare function GpAccount({ core, person, organisations, product, viaIdentity, onSignOut, onSwitch, labels, align, className, }: GpAccountProps): ReactElement;
47
+ export declare function GpAccount({ core, person, organisations, product, viaIdentity, onSignOut, onSwitch, items, labels, layout, align, placement, className, }: GpAccountProps): ReactElement;
40
48
  export {};
41
49
  //# 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, 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, 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.3.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",