@mparticle/web-sdk 3.26.1 → 3.28.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.
@@ -1,6 +1,6 @@
1
1
  export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet' | 'link';
2
2
  export type LinkType = 'same_page' | 'internal' | 'outbound' | 'download';
3
- export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'alt' | 'title' | 'value' | 'none';
3
+ export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'alt' | 'title' | 'value' | 'heading' | 'none';
4
4
  export interface IButtonClick {
5
5
  label?: string;
6
6
  labelSource: ButtonLabelSource;
@@ -11,10 +11,12 @@ export interface IButtonClick {
11
11
  testId?: string;
12
12
  controls?: string;
13
13
  form?: string;
14
+ group?: string;
14
15
  element: ButtonElementKind;
15
16
  type: string;
16
17
  linkType?: LinkType;
17
18
  linkDomain?: string;
19
+ linkPath?: string;
18
20
  fileExtension?: string;
19
21
  checked?: boolean;
20
22
  }
@@ -20,9 +20,6 @@ export declare class ButtonTracker {
20
20
  private pagePath;
21
21
  private pageEvents;
22
22
  private pending;
23
- private uploadTimerId;
24
- private uploadHeld;
25
- private leaving;
26
23
  constructor(mpInstance: IMParticleWebSDKInstance);
27
24
  get isActive(): boolean;
28
25
  init(options: IButtonTrackerOptions): void;
@@ -37,9 +34,6 @@ export declare class ButtonTracker {
37
34
  private readonly onEvent;
38
35
  private observe;
39
36
  private logClick;
40
- private queuedForUpload;
41
- private uploadNow;
42
- private cancelUpload;
43
37
  private emit;
44
38
  private pickPayment;
45
39
  private eventsEnabled;
@@ -9,5 +9,6 @@ export declare function isWalletElement(element: Element): boolean;
9
9
  export declare function isQuietIntentControl(control: Element, isVendorWallet: boolean, isCart?: boolean): boolean;
10
10
  export declare function isWalletNamedControl(element: Element): boolean;
11
11
  export declare function isExcludedPath(path: Array<EventTarget | null>): boolean;
12
- export declare function findControl(path: Array<EventTarget | null>, included?: boolean): Element | undefined;
12
+ export declare function findControl(path: Array<EventTarget | null>, included?: boolean, limit?: number): Element | undefined;
13
+ export declare function findRoleControl(path: Array<EventTarget | null>, limit: number): Element | undefined;
13
14
  export declare function isUnsafeTextSource(element: Element, own?: Element): boolean;
@@ -1,2 +1,3 @@
1
1
  export declare const MAX_RAW_LABEL_LENGTH = 128;
2
2
  export declare function maskLabel(raw: string | null | undefined): string | undefined;
3
+ export declare function maskNumbers(text: string): string | undefined;
@@ -2,6 +2,7 @@ import { valueof } from './utils';
2
2
  export declare const UrlMode: {
3
3
  readonly Path: "path";
4
4
  readonly Full: "full";
5
+ readonly Report: "report";
5
6
  };
6
7
  export type UrlMode = valueof<typeof UrlMode>;
7
8
  export declare const EXCLUDED_URL_QUERY_PARAMS: string[];
@@ -0,0 +1,9 @@
1
+ export declare const LEFTOVER_ESCAPE: RegExp;
2
+ export declare function hidesSegmentAfter(route: string, segment: string): boolean;
3
+ export declare function hidesNextSegment(route: string): boolean;
4
+ export declare function decodeFully(text: string): string | undefined;
5
+ export declare function routeSegment(segments: string[], i: number): string;
6
+ export declare function isTokenSegment(segment: string): boolean;
7
+ export declare function maskTokenSegment(segment: string, text: string, route: string | undefined): string | undefined;
8
+ export declare function pagePath(pathname: string): string;
9
+ export declare function pageUrlPath(pathname: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mparticle/web-sdk",
3
- "version": "3.26.1",
3
+ "version": "3.28.0",
4
4
  "description": "mParticle core SDK for web applications",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -479,14 +479,16 @@ export class BatchUploader {
479
479
  this.batchesQueuedForProcessing.unshift(...batchesThatDidNotUpload);
480
480
  }
481
481
 
482
- // Update Offline Storage with current state of batch queue
483
- if (!useBeacon && this.offlineStorageEnabled && this.batchVault) {
484
- // Note: since beacon is "Fire and forget" it will empty `batchesThatDidNotUplod`
485
- // regardless of whether the batches were successfully uploaded or not. We should
486
- // therefore NOT overwrite Offline Storage when beacon returns, so that we can retry
487
- // uploading saved batches at a later time. Batches should only be removed from
488
- // Local Storage once we can confirm they are successfully uploaded.
489
-
482
+ // Update Offline Storage with current state of batch queue. Storage was
483
+ // purged before sending. A beacon the browser accepted counts as sent, so
484
+ // the beacon path writes nothing unless the browser refused a batch: then
485
+ // `batchesThatDidNotUpload` holds it, and it is stored since the page may
486
+ // be going away.
487
+ if (
488
+ this.offlineStorageEnabled &&
489
+ this.batchVault &&
490
+ (!useBeacon || !isEmpty(batchesThatDidNotUpload))
491
+ ) {
490
492
  this.storeBatchesQueuedForProcessing();
491
493
  }
492
494
 
@@ -570,6 +572,7 @@ export class BatchUploader {
570
572
  this.mpInstance.Logger.verbose(`Batch count: ${uploads.length}`);
571
573
  }
572
574
 
575
+ const refused: Batch[] = [];
573
576
  for (let i = 0; i < uploads.length; i++) {
574
577
  const fetchPayload: IFetchPayload = {
575
578
  method: 'POST',
@@ -586,7 +589,13 @@ export class BatchUploader {
586
589
  type: 'text/plain;charset=UTF-8',
587
590
  });
588
591
 
589
- navigator.sendBeacon(this.uploadUrl, blob);
592
+ // The browser refuses a beacon it won't send, such as one over its
593
+ // size limit, which says nothing about the next batch. Keep it and
594
+ // try the rest. Only false is a refusal: a wrapper that returns
595
+ // nothing has still sent it.
596
+ if (navigator.sendBeacon(this.uploadUrl, blob) === false) {
597
+ refused.push(uploads[i]);
598
+ }
590
599
  } else {
591
600
  try {
592
601
  const response = await this.uploader.upload(fetchPayload);
@@ -629,6 +638,13 @@ export class BatchUploader {
629
638
  }
630
639
  }
631
640
  }
641
+
642
+ if (!isEmpty(refused)) {
643
+ this.mpInstance.Logger.warning(
644
+ `The browser refused the exit upload of ${refused.length} batch(es). Keeping them for the next upload.`
645
+ );
646
+ return refused;
647
+ }
632
648
  return null;
633
649
  }
634
650
  }
@@ -5,6 +5,7 @@ import KitFilterHelper from '../kitFilterHelper';
5
5
  import { BaseEvent, SDKEvent } from '../sdkRuntimeModels';
6
6
  import { EventType, MessageType } from '../types';
7
7
  import { IButtonClick } from './buttonDescription';
8
+ import { pagePath } from '../pathMasking';
8
9
 
9
10
  // Marks an automatically logged button click, like `is_auto_page_view`.
10
11
  export const AUTO_BUTTON_ATTRIBUTE = 'is_auto_button_click';
@@ -27,7 +28,7 @@ const clickAttributes = (
27
28
  label_source: click.labelSource,
28
29
  element: click.element,
29
30
  type: click.type,
30
- path: pathname,
31
+ path: pagePath(pathname),
31
32
  hostname: window.location.hostname,
32
33
  };
33
34
  // Optional fields are left out when the control has no safe value for them.
@@ -40,8 +41,10 @@ const clickAttributes = (
40
41
  test_id: click.testId,
41
42
  controls: click.controls,
42
43
  form: click.form,
44
+ group: click.group,
43
45
  link_type: click.linkType,
44
46
  link_domain: click.linkDomain,
47
+ link_path: click.linkPath,
45
48
  file_extension: click.fileExtension,
46
49
  };
47
50
  for (const key of Object.keys(optional)) {
@@ -72,8 +75,9 @@ export const buildButtonClickEvent = (
72
75
 
73
76
  // One custom event per click on a link that leaves the page. Typed Other, not
74
77
  // Navigation, so no kit reads it as a page change. A plain link to another page
75
- // of the site goes without its text, which can name the shopper ("Hi, Jane");
76
- // a button or wallet acting as a link keeps its name.
78
+ // of the site goes without its text, which can name the shopper ("Hi, Jane"), and
79
+ // with its destination path instead; a button or wallet acting as a link keeps
80
+ // its name.
77
81
  export const buildLinkClickEvent = (
78
82
  click: IButtonClick,
79
83
  pathname: string
@@ -114,8 +118,10 @@ const CLICK_ATTRIBUTES = [
114
118
  'test_id',
115
119
  'controls',
116
120
  'form',
121
+ 'group',
117
122
  'link_type',
118
123
  'link_domain',
124
+ 'link_path',
119
125
  'file_extension',
120
126
  'checked',
121
127
  ];
@@ -1,12 +1,20 @@
1
1
  import { isFunction } from '../utils';
2
2
  import {
3
+ CONTROL_SELECTOR,
3
4
  hasComposedAncestor,
4
5
  isUnsafeTextSource,
5
6
  isWalletElement,
6
7
  nameWords,
7
8
  someComposedAncestor,
8
9
  } from './controls';
9
- import { maskLabel, MAX_RAW_LABEL_LENGTH } from './labelMasking';
10
+ import { maskLabel, maskNumbers, MAX_RAW_LABEL_LENGTH } from './labelMasking';
11
+ import {
12
+ decodeFully,
13
+ hidesSegmentAfter,
14
+ LEFTOVER_ESCAPE,
15
+ maskTokenSegment,
16
+ routeSegment,
17
+ } from '../pathMasking';
10
18
 
11
19
  // Describes a clicked button for auto button tracking: its masked name, icon,
12
20
  // hand-written identifiers and kind. Never reads form field values.
@@ -25,6 +33,7 @@ export type ButtonLabelSource =
25
33
  | 'alt'
26
34
  | 'title'
27
35
  | 'value'
36
+ | 'heading'
28
37
  | 'none';
29
38
 
30
39
  export interface IButtonClick {
@@ -37,10 +46,12 @@ export interface IButtonClick {
37
46
  testId?: string;
38
47
  controls?: string;
39
48
  form?: string;
49
+ group?: string;
40
50
  element: ButtonElementKind;
41
51
  type: string;
42
52
  linkType?: LinkType;
43
53
  linkDomain?: string;
54
+ linkPath?: string;
44
55
  fileExtension?: string;
45
56
  checked?: boolean;
46
57
  }
@@ -53,7 +64,13 @@ function elementKind(control: Element): ButtonElementKind {
53
64
  if (tag === 'button' || tag === 'input') {
54
65
  return tag;
55
66
  }
56
- return control.matches('a[href]:not([role="button"])') ? 'link' : 'role';
67
+ // A link run by script reads as a link, as an anchor does, so whoever reads
68
+ // the click treats it the same.
69
+ return control.matches(
70
+ 'a[href]:not([role="button"]), [role~="link"]:not([role~="button"])'
71
+ )
72
+ ? 'link'
73
+ : 'role';
57
74
  }
58
75
 
59
76
  const NOT_RENDERED =
@@ -253,7 +270,7 @@ function accessibleName(
253
270
  } else if (readable && isShown(control)) {
254
271
  const text = renderedText(control);
255
272
  if (text === undefined) {
256
- return { source: 'none' };
273
+ return headingName(control) || { source: 'none' };
257
274
  }
258
275
  if (text) {
259
276
  return { raw: text, source: 'text' };
@@ -263,6 +280,12 @@ function accessibleName(
263
280
  return { raw: alt, source: 'alt' };
264
281
  }
265
282
  }
283
+ if (!(control instanceof HTMLInputElement) && isShown(control)) {
284
+ const heading = headingName(control);
285
+ if (heading) {
286
+ return heading;
287
+ }
288
+ }
266
289
  const title = nonBlank(control.getAttribute('title'));
267
290
  if (title) {
268
291
  return { raw: title, source: 'title' };
@@ -270,6 +293,33 @@ function accessibleName(
270
293
  return { source: 'none' };
271
294
  }
272
295
 
296
+ // A card whose text runs too long to keep, or has too many parts to read, is
297
+ // named by its first heading, as a product or menu card usually is. Not a card
298
+ // holding a form field, nor one that takes in slotted content or holds a
299
+ // shadow root, whose fields the form field check can't see.
300
+ function headingName(
301
+ control: Element
302
+ ): { raw: string; source: ButtonLabelSource } | undefined {
303
+ if (
304
+ isUnsafeTextSource(control) ||
305
+ control.shadowRoot ||
306
+ control.querySelector('slot') ||
307
+ // A custom element can hide fields in a closed shadow root, which
308
+ // nothing outside it can see.
309
+ control.tagName.indexOf('-') !== -1 ||
310
+ Array.from(control.querySelectorAll('*')).some(
311
+ element =>
312
+ !!element.shadowRoot || element.tagName.indexOf('-') !== -1
313
+ )
314
+ ) {
315
+ return undefined;
316
+ }
317
+ const text = textOf(
318
+ control.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]')
319
+ );
320
+ return text ? { raw: text, source: 'heading' } : undefined;
321
+ }
322
+
273
323
  const ICON_CLASS = /^(?:fa|fas|far|fal|icon|bi|glyphicon|ti|mdi|ri|la|lucide|ion|feather|ph)-([a-z][a-z-]{1,31})$/;
274
324
  // Size, style and animation classes that share the icon prefixes.
275
325
  const ICON_MODIFIER = /^(?:solid|regular|light|thin|duotone|brands|sharp|fw|lg|xs|sm|xl|spin|pulse|border|inverse|stack|flip(?:-[a-z]+)?|rotate(?:-[a-z]+)?|pull-[a-z]+)$/;
@@ -331,6 +381,13 @@ function stableIdentifier(value: string | null): string | undefined {
331
381
  : undefined;
332
382
  }
333
383
 
384
+ // A picker's input name, which names its question. Sites often end it with the
385
+ // id of the product it belongs to (`color-swatches-48213907`); without that
386
+ // number it still names the question.
387
+ function questionName(value: string | null): string | undefined {
388
+ return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
389
+ }
390
+
334
391
  const TEST_ID_ATTRIBUTES = [
335
392
  'data-testid',
336
393
  'data-test-id',
@@ -378,24 +435,88 @@ function formIdentifier(control: Element): string | undefined {
378
435
 
379
436
  // The browser's normalised type: a button defaults to 'submit' and an unknown
380
437
  // input type reads as 'text'. A native button or input reports its own type, a
381
- // wallet-named one included; any other control, such as a role button or a
382
- // vendor's wallet element, reads as 'button'.
438
+ // wallet-named one included. A role control that isn't a button reads as its
439
+ // role: 'radio', 'option', 'tab', 'link' and so on. Any other control, such as
440
+ // a role button or a vendor's wallet element, reads as 'button'.
441
+ const CONTROL_ROLES = [
442
+ 'radio',
443
+ 'menuitemradio',
444
+ 'checkbox',
445
+ 'menuitemcheckbox',
446
+ 'switch',
447
+ 'option',
448
+ 'tab',
449
+ 'link',
450
+ ];
451
+
383
452
  function controlType(control: Element): string {
384
453
  const tag = control.tagName.toLowerCase();
385
- return tag === 'button' || tag === 'input'
386
- ? (control as HTMLButtonElement | HTMLInputElement).type
387
- : 'button';
454
+ if (tag === 'button' || tag === 'input') {
455
+ return (control as HTMLButtonElement | HTMLInputElement).type;
456
+ }
457
+ if (control.matches('[role~="button"]')) {
458
+ return 'button';
459
+ }
460
+ const roles = (control.getAttribute('role') || '').trim().split(/\s+/);
461
+ for (const role of roles) {
462
+ if (CONTROL_ROLES.indexOf(role) !== -1) {
463
+ return role;
464
+ }
465
+ }
466
+ return 'button';
388
467
  }
389
468
 
390
469
  interface ILinkDetails {
391
470
  type: LinkType;
392
471
  domain?: string;
472
+ path?: string;
393
473
  fileExtension?: string;
394
474
  }
395
475
 
476
+ // The page a link to another page of the site opens: its decoded path alone,
477
+ // since an id in a path can point at one shopper. A segment holding a token,
478
+ // or following a route word that names a person or carries a code, goes as
479
+ // `*`, and numbers in any script are masked as in labels. Dropped when it
480
+ // holds an @, `;` or `=` (session ids and matrix parameters), is too long to
481
+ // be a route, or the browser can't mask every script's digits.
482
+ function linkPath(link: HTMLAnchorElement): string | undefined {
483
+ // Each segment decodes on its own, so a literal `%` in one never stops the
484
+ // next from decoding.
485
+ const decoded = link.pathname.split('/').map(decodeFully);
486
+ if (decoded.some(segment => segment === undefined)) {
487
+ return undefined;
488
+ }
489
+ const path = decoded.join('/');
490
+ if (
491
+ path.length > MAX_RAW_LABEL_LENGTH ||
492
+ /[@;=]/.test(path) ||
493
+ LEFTOVER_ESCAPE.test(path)
494
+ ) {
495
+ return undefined;
496
+ }
497
+ const segments = path.split('/');
498
+ const masked = segments.map((segment, i) => {
499
+ if (
500
+ i > 0 &&
501
+ segment &&
502
+ hidesSegmentAfter(routeSegment(segments, i), segment)
503
+ ) {
504
+ return '*';
505
+ }
506
+ const tokens = maskTokenSegment(
507
+ segment,
508
+ segment,
509
+ i > 0 ? routeSegment(segments, i) : undefined
510
+ );
511
+ return tokens === undefined ? segment : tokens;
512
+ });
513
+ return maskNumbers(masked.join('/'));
514
+ }
515
+
396
516
  // Reads the link's own resolved URL parts, never its full href: only the hostname of
397
- // a link that leaves the site and the extension of a file it downloads travel.
398
- // mailto:, tel: and other schemes give undefined.
517
+ // a link that leaves the site, the path of one to another page of the site and the
518
+ // extension of a file it downloads travel. mailto:, tel: and other schemes give
519
+ // undefined.
399
520
  function describeLink(link: Element): ILinkDetails | undefined {
400
521
  const href = (link.getAttribute('href') || '').trim();
401
522
  if (!href || href.charAt(0) === '#') {
@@ -429,7 +550,7 @@ function describeLink(link: Element): ILinkDetails | undefined {
429
550
  return link.pathname === window.location.pathname &&
430
551
  link.search === window.location.search
431
552
  ? { type: 'same_page' }
432
- : { type: 'internal' };
553
+ : { type: 'internal', path: linkPath(link) };
433
554
  }
434
555
 
435
556
  // Whether a described click is on a link that leaves the page, logged as a link
@@ -442,7 +563,7 @@ export function isLinkClick(click: IButtonClick): boolean {
442
563
  // fallbacks, so any of its tokens counts.
443
564
  const CHOICE =
444
565
  'input[type="checkbox"], input[type="radio"], [role~="checkbox"], [role~="radio"], ' +
445
- '[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"]';
566
+ '[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"], [role~="option"]';
446
567
  const RADIO_ROLE = '[role~="radio"], [role~="menuitemradio"]';
447
568
 
448
569
  function boxOf(element: Element): Element | null {
@@ -512,36 +633,53 @@ export function describeButtonClick(
512
633
  // radio's id, test id and the panel it opens name the option picked, so
513
634
  // only the name of its input, the question, goes with it. A wrapper's own
514
635
  // name, or a role radio's, can name the option instead.
515
- const radio = isRadio(choice);
636
+ // An option of a list box, like a radio, names the answer picked.
637
+ const radio =
638
+ isRadio(choice) || (!!choice && choice.matches('[role~="option"]'));
516
639
  const named = !radio
517
640
  ? control
518
- : choice instanceof HTMLInputElement
641
+ : choice instanceof HTMLInputElement &&
642
+ !choice.matches('[role~="option"]')
519
643
  ? choice
520
644
  : null;
521
- const name = choice ? undefined : accessibleName(control);
645
+ // A link run by script has no address to say where it goes, so it goes
646
+ // without its text, as a plain link to another page of the site does: the
647
+ // text can name the shopper ("Hi, Jane").
648
+ // A native button keeps its name whatever its role, as it always has.
649
+ const textless =
650
+ !!choice ||
651
+ (!link &&
652
+ element !== 'wallet' &&
653
+ control.matches('[role~="link"]') &&
654
+ !control.matches(CONTROL_SELECTOR));
655
+ const name = textless ? undefined : accessibleName(control);
522
656
  const label = name ? maskLabel(name.raw) : undefined;
523
657
  return {
524
658
  label,
525
659
  labelSource: label ? name.source : 'none',
526
660
  // Kept on its own as well: it often says more than the visible text,
527
661
  // such as which item a remove button removes.
528
- ariaLabel: choice
662
+ ariaLabel: textless
529
663
  ? undefined
530
664
  : maskLabel(control.getAttribute('aria-label')),
531
665
  icon: choice ? undefined : iconHint(control),
532
666
  elementId: radio
533
667
  ? undefined
534
668
  : stableIdentifier(control.getAttribute('id')),
535
- elementName: stableIdentifier(named && named.getAttribute('name')),
669
+ elementName: (choice ? questionName : stableIdentifier)(
670
+ named && named.getAttribute('name')
671
+ ),
536
672
  testId: radio
537
673
  ? undefined
538
674
  : firstIdentifier(control, TEST_ID_ATTRIBUTES),
539
675
  controls: radio ? undefined : controlsIdentifier(control),
540
676
  form: formIdentifier(control),
677
+ group: choice ? groupName(choice) : undefined,
541
678
  element,
542
679
  type: controlType(control),
543
680
  linkType: link ? link.type : undefined,
544
681
  linkDomain: link ? link.domain : undefined,
682
+ linkPath: link ? link.path : undefined,
545
683
  fileExtension: link ? link.fileExtension : undefined,
546
684
  // The box as the click left it: the browser toggles it before the click
547
685
  // reaches listeners, and puts it back if the click is cancelled.
@@ -549,6 +687,176 @@ export function describeButtonClick(
549
687
  };
550
688
  }
551
689
 
690
+ // The question a picker answers, from the group around it: the group's
691
+ // aria-label, aria-labelledby or legend, masked like a label and cut at its
692
+ // first colon, since a legend can repeat the pick ("Color: Navy"). Never the
693
+ // option's own text, which is the shopper's answer.
694
+ const NAMED_GROUP = '[role="radiogroup"], [role="listbox"], [role="group"], fieldset';
695
+ // A control such as the button that opens a list box shows the option picked.
696
+ const NOT_A_QUESTION =
697
+ CHOICE +
698
+ ', ' +
699
+ CONTROL_SELECTOR +
700
+ ', a[href], input, select, textarea, slot, [aria-haspopup], [role~="button"], ' +
701
+ '[role~="link"], [role~="tab"], [role~="combobox"]';
702
+
703
+ const OPTION_KINDS = [
704
+ 'radio',
705
+ 'menuitemradio',
706
+ 'checkbox',
707
+ 'menuitemcheckbox',
708
+ 'switch',
709
+ 'option',
710
+ ];
711
+
712
+ function optionKind(option: Element): string | undefined {
713
+ if (option instanceof HTMLInputElement) {
714
+ return option.type;
715
+ }
716
+ const roles = (option.getAttribute('role') || '').trim().split(/\s+/);
717
+ return roles.filter(role => OPTION_KINDS.indexOf(role) !== -1)[0];
718
+ }
719
+
720
+ // Whether two options answer the same question. A native box shares its
721
+ // question only with a box of the same name in the same form, as the browser
722
+ // groups radios, so separate shipping and billing radios ask two.
723
+ function sameQuestion(option: Element, choice: Element): boolean {
724
+ if (!(choice instanceof HTMLInputElement)) {
725
+ // A role option answers only within its own radio group or list box.
726
+ const container = '[role="radiogroup"], [role="listbox"]';
727
+ return (
728
+ !(option instanceof HTMLInputElement) &&
729
+ optionKind(option) === optionKind(choice) &&
730
+ option.closest(container) === choice.closest(container)
731
+ );
732
+ }
733
+ const name = choice.getAttribute('name');
734
+ return (
735
+ option instanceof HTMLInputElement &&
736
+ option.type === choice.type &&
737
+ !!name &&
738
+ option.getAttribute('name') === name &&
739
+ option.form === choice.form
740
+ );
741
+ }
742
+
743
+ // Whether a group offers another answer to the question picked: a shown option
744
+ // to the same question that isn't part of the one picked. A card around one
745
+ // address radio and a "make default" checkbox, or around a role radio and the
746
+ // hidden input behind it, offers none.
747
+ function holdsOptions(group: Element, choice: Element): boolean {
748
+ const options = group.querySelectorAll(CHOICE);
749
+ for (let i = 0; i < options.length; i++) {
750
+ const option = options[i];
751
+ if (
752
+ !option.contains(choice) &&
753
+ !choice.contains(option) &&
754
+ sameQuestion(option, choice) &&
755
+ isShown(option, group) &&
756
+ isVisible(option) &&
757
+ // A fully transparent option, as a native input behind a styled
758
+ // one often is, shows the shopper no second answer.
759
+ !someComposedAncestor(
760
+ option,
761
+ node => window.getComputedStyle(node).opacity === '0',
762
+ group
763
+ )
764
+ ) {
765
+ return true;
766
+ }
767
+ }
768
+ return false;
769
+ }
770
+
771
+ // The elements an option is named by: those its aria-labelledby points at and
772
+ // a native box's labels.
773
+ function optionLabels(option: Element, root: Document | ShadowRoot): Element[] {
774
+ const labels: Element[] =
775
+ option instanceof HTMLInputElement && option.labels
776
+ ? Array.from(option.labels)
777
+ : [];
778
+ (option.getAttribute('aria-labelledby') || '')
779
+ .trim()
780
+ .split(/\s+/)
781
+ .forEach(ref => {
782
+ const target =
783
+ ref && root.getElementById ? root.getElementById(ref) : null;
784
+ if (target) {
785
+ labels.push(target);
786
+ }
787
+ });
788
+ return labels;
789
+ }
790
+
791
+ // The group asking the question a pick answers. A role radio's is its
792
+ // radiogroup and an option's its list box; a role toggle, such as a switch,
793
+ // stands alone. A native box's is the nearest group offering other answers,
794
+ // since a group around one option, such as a saved address card, can be
795
+ // named by that option.
796
+ function questionGroup(choice: Element): Element | null {
797
+ if (!(choice instanceof HTMLInputElement)) {
798
+ const container = choice.matches('[role~="option"]')
799
+ ? choice.closest('[role="listbox"]')
800
+ : choice.matches('[role~="radio"]')
801
+ ? choice.closest('[role="radiogroup"]')
802
+ : null;
803
+ return container && holdsOptions(container, choice) ? container : null;
804
+ }
805
+ let group = choice.closest(NAMED_GROUP);
806
+ while (group && !holdsOptions(group, choice)) {
807
+ group = group.parentElement
808
+ ? group.parentElement.closest(NAMED_GROUP)
809
+ : null;
810
+ }
811
+ return group;
812
+ }
813
+
814
+ function groupName(choice: Element): string | undefined {
815
+ const group = questionGroup(choice);
816
+ if (!group) {
817
+ return undefined;
818
+ }
819
+ let raw = nonBlank(group.getAttribute('aria-label'));
820
+ if (!raw) {
821
+ const ids = group.getAttribute('aria-labelledby');
822
+ const legend =
823
+ group.tagName === 'FIELDSET'
824
+ ? Array.from(group.children).filter(
825
+ child => child.tagName === 'LEGEND'
826
+ )[0]
827
+ : undefined;
828
+ const root = (isFunction(group.getRootNode)
829
+ ? group.getRootNode()
830
+ : document) as Document | ShadowRoot;
831
+ const source = ids
832
+ ? root.getElementById
833
+ ? root.getElementById(ids.trim().split(/\s+/)[0])
834
+ : null
835
+ : legend || null;
836
+ // A label that is, holds or sits in an option or a control, or in an
837
+ // option's label, would name the answer.
838
+ const holder = source ? source.closest('label') : null;
839
+ // Nor one that holds, or sits in, what an option of the group names
840
+ // itself by.
841
+ const labelsOption =
842
+ !!source &&
843
+ Array.from(group.querySelectorAll(CHOICE)).some(option =>
844
+ optionLabels(option, root).some(
845
+ label => source.contains(label) || label.contains(source)
846
+ )
847
+ );
848
+ raw =
849
+ source &&
850
+ !labelsOption &&
851
+ !hasComposedAncestor(source, NOT_A_QUESTION) &&
852
+ !source.querySelector(NOT_A_QUESTION) &&
853
+ !(holder && (holder.control || holder.querySelector(CHOICE)))
854
+ ? textOf(source)
855
+ : undefined;
856
+ }
857
+ return raw ? maskLabel(raw.split(':')[0]) : undefined;
858
+ }
859
+
552
860
  // A question about payment, read from the names its author gave it, so
553
861
  // "checkout[payment_gateway]", "paymentMethod" and "tender_type" all count and
554
862
  // "payee" does not. A radio's input name is its question; its group or