@mparticle/web-sdk 3.27.0 → 3.29.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.
@@ -19,8 +19,8 @@ export interface IButtonClick {
19
19
  linkPath?: string;
20
20
  fileExtension?: string;
21
21
  checked?: boolean;
22
- pick?: true;
23
22
  }
23
+ export declare const TEST_ID_ATTRIBUTES: string[];
24
24
  export declare function isLinkClick(click: IButtonClick): boolean;
25
25
  export declare function describeButtonClick(control: Element, path?: Array<EventTarget | null>): IButtonClick | undefined;
26
26
  export interface IPaymentChoice {
@@ -28,4 +28,5 @@ export interface IPaymentChoice {
28
28
  names: string[];
29
29
  }
30
30
  export declare function describePaymentChoice(control: Element, path?: Array<EventTarget | null>): IPaymentChoice | undefined;
31
+ export declare function describeCheckedPaymentChoice(): IPaymentChoice | undefined;
31
32
  export declare function isPicked(radio: Element): boolean;
@@ -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;
@@ -1,3 +1,4 @@
1
+ import { IButtonClick } from './buttonDescription';
1
2
  export type CheckoutIntentKind = 'commit' | 'wallet' | 'checkout_step' | 'frame' | 'cart';
2
3
  export type PaymentFrameFamily = 'apple_pay' | 'google_pay' | 'amazon_pay' | 'shop_pay' | 'card';
3
4
  export type CheckoutIntentSource = 'vendor' | 'label' | 'brand' | 'frame' | 'submit' | 'name' | 'api';
@@ -7,6 +8,7 @@ export interface ICheckoutIntent {
7
8
  adds?: true;
8
9
  }
9
10
  export declare function classifyIntent(control: Element, pathname: string): ICheckoutIntent | undefined;
11
+ export declare function namesWallet(control: Element, click?: IButtonClick): boolean;
10
12
  export declare function classifyIntentControl(control: Element, pathname: string): Exclude<CheckoutIntentKind, 'frame'> | undefined;
11
13
  export interface IPaymentFrame {
12
14
  kind: 'wallet' | 'frame';
@@ -1,7 +1,9 @@
1
1
  export declare const LEFTOVER_ESCAPE: RegExp;
2
+ export declare function hidesSegmentAfter(route: string, segment: string): boolean;
2
3
  export declare function hidesNextSegment(route: string): boolean;
3
4
  export declare function decodeFully(text: string): string | undefined;
4
5
  export declare function routeSegment(segments: string[], i: number): string;
5
6
  export declare function isTokenSegment(segment: string): boolean;
7
+ export declare function maskTokenSegment(segment: string, text: string, route: string | undefined): string | undefined;
6
8
  export declare function pagePath(pathname: string): string;
7
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.27.0",
3
+ "version": "3.29.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
  }
@@ -10,9 +10,9 @@ import {
10
10
  import { maskLabel, maskNumbers, MAX_RAW_LABEL_LENGTH } from './labelMasking';
11
11
  import {
12
12
  decodeFully,
13
- hidesNextSegment,
14
- isTokenSegment,
13
+ hidesSegmentAfter,
15
14
  LEFTOVER_ESCAPE,
15
+ maskTokenSegment,
16
16
  routeSegment,
17
17
  } from '../pathMasking';
18
18
 
@@ -54,8 +54,6 @@ export interface IButtonClick {
54
54
  linkPath?: string;
55
55
  fileExtension?: string;
56
56
  checked?: boolean;
57
- // A pick among options or tabs, which waits for the batch interval.
58
- pick?: true;
59
57
  }
60
58
 
61
59
  function elementKind(control: Element): ButtonElementKind {
@@ -205,12 +203,19 @@ function textOf(element: Element | null): string | undefined {
205
203
  : '';
206
204
  }
207
205
 
208
- // The control itself is already known to be shown.
206
+ // The control itself is already known to be shown. An inline icon names
207
+ // itself with aria-label rather than alt.
209
208
  function shownImageAlt(control: Element): string | undefined {
210
- const images = control.querySelectorAll('img[alt]');
209
+ const images = control.querySelectorAll(
210
+ 'img[alt], svg[aria-label], [role="img"][aria-label]'
211
+ );
211
212
  for (let i = 0; i < images.length && i < MAX_READS; i++) {
212
- if (isShown(images[i], control) && isVisible(images[i])) {
213
- return nonBlank(images[i].getAttribute('alt'));
213
+ const alt = nonBlank(
214
+ images[i].getAttribute('alt') ||
215
+ images[i].getAttribute('aria-label')
216
+ );
217
+ if (alt && isShown(images[i], control) && isVisible(images[i])) {
218
+ return alt;
214
219
  }
215
220
  }
216
221
  return undefined;
@@ -390,7 +395,7 @@ function questionName(value: string | null): string | undefined {
390
395
  return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
391
396
  }
392
397
 
393
- const TEST_ID_ATTRIBUTES = [
398
+ export const TEST_ID_ATTRIBUTES = [
394
399
  'data-testid',
395
400
  'data-test-id',
396
401
  'data-test',
@@ -497,12 +502,21 @@ function linkPath(link: HTMLAnchorElement): string | undefined {
497
502
  return undefined;
498
503
  }
499
504
  const segments = path.split('/');
500
- const masked = segments.map((segment, i) =>
501
- isTokenSegment(segment) ||
502
- (i > 0 && segment && hidesNextSegment(routeSegment(segments, i)))
503
- ? '*'
504
- : segment
505
- );
505
+ const masked = segments.map((segment, i) => {
506
+ if (
507
+ i > 0 &&
508
+ segment &&
509
+ hidesSegmentAfter(routeSegment(segments, i), segment)
510
+ ) {
511
+ return '*';
512
+ }
513
+ const tokens = maskTokenSegment(
514
+ segment,
515
+ segment,
516
+ i > 0 ? routeSegment(segments, i) : undefined
517
+ );
518
+ return tokens === undefined ? segment : tokens;
519
+ });
506
520
  return maskNumbers(masked.join('/'));
507
521
  }
508
522
 
@@ -677,7 +691,6 @@ export function describeButtonClick(
677
691
  // The box as the click left it: the browser toggles it before the click
678
692
  // reaches listeners, and puts it back if the click is cancelled.
679
693
  checked: box ? box.checked : undefined,
680
- pick: choice || control.matches('[role~="tab"]') ? true : undefined,
681
694
  };
682
695
  }
683
696
 
@@ -685,7 +698,9 @@ export function describeButtonClick(
685
698
  // aria-label, aria-labelledby or legend, masked like a label and cut at its
686
699
  // first colon, since a legend can repeat the pick ("Color: Navy"). Never the
687
700
  // option's own text, which is the shopper's answer.
688
- const NAMED_GROUP = '[role="radiogroup"], [role="listbox"], [role="group"], fieldset';
701
+ const NAMED_GROUP =
702
+ '[role="radiogroup"], [role="listbox"], [role="group"], fieldset, ' +
703
+ '[role="region"][aria-label], [role="region"][aria-labelledby]';
689
704
  // A control such as the button that opens a list box shows the option picked.
690
705
  const NOT_A_QUESTION =
691
706
  CHOICE +
@@ -734,6 +749,144 @@ function sameQuestion(option: Element, choice: Element): boolean {
734
749
  );
735
750
  }
736
751
 
752
+ function isTransparent(element: Element, until: Element): boolean {
753
+ return someComposedAncestor(
754
+ element,
755
+ node => window.getComputedStyle(node).opacity === '0',
756
+ until
757
+ );
758
+ }
759
+
760
+ // Content that is never painted where it sits: SVG that is only referenced or
761
+ // read, and a canvas's fallback, which some browsers still give boxes.
762
+ const UNDRAWN =
763
+ 'svg title, svg desc, svg metadata, svg defs, svg symbol, svg clipPath, ' +
764
+ 'svg mask, svg pattern, svg marker, svg linearGradient, ' +
765
+ 'svg radialGradient, svg filter, canvas';
766
+
767
+ // Whether text sits in any closed details element outside its summary.
768
+ function isInClosedDetails(text: Node, parent: Element): boolean {
769
+ let details = parent.closest('details:not([open])');
770
+ while (details) {
771
+ const summary = Array.from(details.children).filter(
772
+ child => child.tagName === 'SUMMARY'
773
+ )[0];
774
+ if (!summary || !summary.contains(text)) {
775
+ return true;
776
+ }
777
+ details = details.parentElement
778
+ ? details.parentElement.closest('details:not([open])')
779
+ : null;
780
+ }
781
+ return false;
782
+ }
783
+
784
+ // Whether the browser shows an element through every ancestor it renders
785
+ // under, shadow trees included, which a walk of light ancestors can't see
786
+ // into when a shadow root is closed. Where it can't say (checkVisibility
787
+ // arrived in Chrome 105, Firefox 125 and Safari 17.4), the element counts as
788
+ // not shown, so option rows name no group in older browsers.
789
+ function isComposedVisible(element: Element): boolean {
790
+ const check = (element as Element & {
791
+ checkVisibility?: (options: { [key: string]: boolean }) => boolean;
792
+ }).checkVisibility;
793
+ return (
794
+ isFunction(check) &&
795
+ // Older Chrome versions read the first two names, newer browsers the rest.
796
+ check.call(element, {
797
+ checkOpacity: true,
798
+ checkVisibilityCSS: true,
799
+ opacityProperty: true,
800
+ visibilityProperty: true,
801
+ })
802
+ );
803
+ }
804
+
805
+ // Whether the browser lays a text node out in a box of its own. Text it never
806
+ // draws, such as an SVG definition, canvas fallback, closed details or an
807
+ // unslotted child of a shadow host, has none. Where boxes can't be read, the
808
+ // text counts as not drawn.
809
+ function isDrawn(text: Node): boolean {
810
+ const range = isFunction(document.createRange)
811
+ ? document.createRange()
812
+ : null;
813
+ if (!range || !isFunction(range.getClientRects)) {
814
+ return false;
815
+ }
816
+ range.selectNodeContents(text);
817
+ return Array.from(range.getClientRects()).some(
818
+ box => box.width > 0 && box.height > 0
819
+ );
820
+ }
821
+
822
+ // Whether a row shows text of its own: a drawn, shown, non-transparent text
823
+ // node, outside any form field, editable area or opted-out part, beside
824
+ // nothing but the row's own input. Not a row with a shadow root, slot or
825
+ // custom element, whose rendered text a walk of its light children can't see.
826
+ function showsText(row: Element, own: Element): boolean {
827
+ if (
828
+ isUnsafeTextSource(row, own) ||
829
+ hasComposedAncestor(own, '[data-mp-no-track]') ||
830
+ row.shadowRoot ||
831
+ row.tagName.indexOf('-') !== -1 ||
832
+ row.querySelector('slot')
833
+ ) {
834
+ return false;
835
+ }
836
+ // A row too large to check in full counts as showing nothing.
837
+ const descendants = row.querySelectorAll('*');
838
+ if (descendants.length > MAX_READS) {
839
+ return false;
840
+ }
841
+ for (let i = 0; i < descendants.length; i++) {
842
+ if (
843
+ descendants[i].shadowRoot ||
844
+ descendants[i].tagName.indexOf('-') !== -1
845
+ ) {
846
+ return false;
847
+ }
848
+ }
849
+ const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
850
+ for (let i = 0; i < MAX_READS && walker.nextNode(); i++) {
851
+ const parent = walker.currentNode.parentElement;
852
+ if (
853
+ parent &&
854
+ nonBlank(walker.currentNode.nodeValue) &&
855
+ // Undrawn content, and the body of a closed details element,
856
+ // which browsers still give layout boxes.
857
+ !parent.closest(UNDRAWN) &&
858
+ !isInClosedDetails(walker.currentNode, parent) &&
859
+ isShown(parent, row) &&
860
+ isVisible(parent) &&
861
+ !isTransparent(parent, row) &&
862
+ isComposedVisible(parent) &&
863
+ isDrawn(walker.currentNode)
864
+ ) {
865
+ return true;
866
+ }
867
+ }
868
+ return false;
869
+ }
870
+
871
+ // A native input inside a shown control of its own, such as a menu option row
872
+ // with a styled radio, which shows text of its own and doesn't also hold the
873
+ // option picked.
874
+ function showsRow(option: Element, choice: Element, group: Element): boolean {
875
+ const row =
876
+ option instanceof HTMLInputElement && option.parentElement
877
+ ? option.parentElement.closest(CONTROL_SELECTOR)
878
+ : null;
879
+ return (
880
+ !!row &&
881
+ group.contains(row) &&
882
+ !row.contains(choice) &&
883
+ isShown(row, group) &&
884
+ isVisible(row) &&
885
+ !isTransparent(row, group) &&
886
+ showsText(row, option)
887
+ );
888
+ }
889
+
737
890
  // Whether a group offers another answer to the question picked: a shown option
738
891
  // to the same question that isn't part of the one picked. A card around one
739
892
  // address radio and a "make default" checkbox, or around a role radio and the
@@ -749,12 +902,9 @@ function holdsOptions(group: Element, choice: Element): boolean {
749
902
  isShown(option, group) &&
750
903
  isVisible(option) &&
751
904
  // A fully transparent option, as a native input behind a styled
752
- // one often is, shows the shopper no second answer.
753
- !someComposedAncestor(
754
- option,
755
- node => window.getComputedStyle(node).opacity === '0',
756
- group
757
- )
905
+ // one often is, shows the shopper no second answer, unless a
906
+ // shown row around it does.
907
+ (!isTransparent(option, group) || showsRow(option, choice, group))
758
908
  ) {
759
909
  return true;
760
910
  }
@@ -782,34 +932,39 @@ function optionLabels(option: Element, root: Document | ShadowRoot): Element[] {
782
932
  return labels;
783
933
  }
784
934
 
785
- // The group asking the question a pick answers. A role radio's is its
786
- // radiogroup and an option's its list box; a role toggle, such as a switch,
787
- // stands alone. A native box's is the nearest group offering other answers,
788
- // since a group around one option, such as a saved address card, can be
789
- // named by that option.
790
- function questionGroup(choice: Element): Element | null {
935
+ // The name of the group asking the question a pick answers. A role radio's
936
+ // group is its radiogroup and an option's its list box; a role toggle, such as
937
+ // a switch, stands alone. A native box's is the nearest group offering other
938
+ // answers, since a group around one option, such as a saved address card, can
939
+ // be named by that option. A region with no usable name is passed over, so a
940
+ // group around it can still name the pick. Each group's name is read once.
941
+ function groupName(choice: Element): string | undefined {
791
942
  if (!(choice instanceof HTMLInputElement)) {
792
943
  const container = choice.matches('[role~="option"]')
793
944
  ? choice.closest('[role="listbox"]')
794
945
  : choice.matches('[role~="radio"]')
795
946
  ? choice.closest('[role="radiogroup"]')
796
947
  : null;
797
- return container && holdsOptions(container, choice) ? container : null;
948
+ return container && holdsOptions(container, choice)
949
+ ? nameOf(container)
950
+ : undefined;
798
951
  }
799
952
  let group = choice.closest(NAMED_GROUP);
800
- while (group && !holdsOptions(group, choice)) {
953
+ while (group) {
954
+ if (holdsOptions(group, choice)) {
955
+ const name = nameOf(group);
956
+ if (name || !group.matches('[role="region"]')) {
957
+ return name;
958
+ }
959
+ }
801
960
  group = group.parentElement
802
961
  ? group.parentElement.closest(NAMED_GROUP)
803
962
  : null;
804
963
  }
805
- return group;
964
+ return undefined;
806
965
  }
807
966
 
808
- function groupName(choice: Element): string | undefined {
809
- const group = questionGroup(choice);
810
- if (!group) {
811
- return undefined;
812
- }
967
+ function nameOf(group: Element): string | undefined {
813
968
  let raw = nonBlank(group.getAttribute('aria-label'));
814
969
  if (!raw) {
815
970
  const ids = group.getAttribute('aria-labelledby');
@@ -822,11 +977,24 @@ function groupName(choice: Element): string | undefined {
822
977
  const root = (isFunction(group.getRootNode)
823
978
  ? group.getRootNode()
824
979
  : document) as Document | ShadowRoot;
825
- const source = ids
980
+ const target = ids
826
981
  ? root.getElementById
827
982
  ? root.getElementById(ids.trim().split(/\s+/)[0])
828
983
  : null
829
984
  : legend || null;
985
+ // A region opened by an accordion header outside a tab list is named
986
+ // by the header's first heading, never the rest of what it shows.
987
+ const header =
988
+ target &&
989
+ group.matches('[role="region"]') &&
990
+ (target.getAttribute('role') || '').trim() === 'tab' &&
991
+ !hasComposedAncestor(target, '[role~="tablist"]') &&
992
+ !target.querySelector(NOT_A_QUESTION)
993
+ ? target
994
+ : null;
995
+ const source = header
996
+ ? header.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]')
997
+ : target;
830
998
  // A label that is, holds or sits in an option or a control, or in an
831
999
  // option's label, would name the answer.
832
1000
  const holder = source ? source.closest('label') : null;
@@ -842,7 +1010,10 @@ function groupName(choice: Element): string | undefined {
842
1010
  raw =
843
1011
  source &&
844
1012
  !labelsOption &&
845
- !hasComposedAncestor(source, NOT_A_QUESTION) &&
1013
+ !someComposedAncestor(
1014
+ source,
1015
+ node => node !== header && node.matches(NOT_A_QUESTION)
1016
+ ) &&
846
1017
  !source.querySelector(NOT_A_QUESTION) &&
847
1018
  !(holder && (holder.control || holder.querySelector(CHOICE)))
848
1019
  ? textOf(source)
@@ -871,9 +1042,9 @@ function isPaymentQuestion(radio: Element): boolean {
871
1042
  return question.some(words => PAYMENT_QUESTION.test(nameWords(words)));
872
1043
  }
873
1044
 
874
- // What names a payment option: the radio's accessible name, the text and
875
- // image of each label, and its hand-written ids, text masked like a label.
876
- function optionNames(radio: Element): string[] {
1045
+ // What names a payment option to shoppers: the radio's accessible name and the
1046
+ // text and image of each label, masked like a label.
1047
+ function shownOptionNames(radio: Element): Array<string | undefined> {
877
1048
  const names: Array<string | undefined> = [
878
1049
  maskLabel(accessibleName(radio).raw),
879
1050
  ];
@@ -887,15 +1058,26 @@ function optionNames(radio: Element): string[] {
887
1058
  names.push(maskLabel(shownImageAlt(label)));
888
1059
  }
889
1060
  });
890
- names.push(
891
- stableIdentifier(radio.getAttribute('id')),
892
- firstIdentifier(radio, TEST_ID_ATTRIBUTES)
893
- );
1061
+ return names;
1062
+ }
1063
+
1064
+ function distinctNames(names: Array<string | undefined>): string[] {
894
1065
  return names.filter(
895
1066
  (name, index) => !!name && names.indexOf(name) === index
896
1067
  );
897
1068
  }
898
1069
 
1070
+ // What names a payment option: the names shoppers read, then its hand-written
1071
+ // ids.
1072
+ function optionNames(radio: Element): string[] {
1073
+ return distinctNames(
1074
+ shownOptionNames(radio).concat(
1075
+ stableIdentifier(radio.getAttribute('id')),
1076
+ firstIdentifier(radio, TEST_ID_ATTRIBUTES)
1077
+ )
1078
+ );
1079
+ }
1080
+
899
1081
  // The payment radio a click picks and the names of its option, for the Rokt
900
1082
  // kit alone to read a payment method from. A radio's option is the shopper's
901
1083
  // answer, so these names never go into the logged click.
@@ -920,6 +1102,44 @@ export function describePaymentChoice(
920
1102
  return names.length ? { radio: choice, names } : undefined;
921
1103
  }
922
1104
 
1105
+ const CHECKED_RADIO =
1106
+ 'input[type="radio"]:checked, [role~="radio"][aria-checked="true"], [role~="menuitemradio"][aria-checked="true"]';
1107
+
1108
+ // The payment radio the page holds checked, such as a card option checked by
1109
+ // default that the shopper never clicked. A page holding more than one option
1110
+ // names no single method, so it describes nothing. One option drawn twice
1111
+ // counts once: a role radio wrapping its native input, a hidden input a
1112
+ // component library keeps beside its role radio, or one group rendered for two
1113
+ // layouts, whose copies share the names shoppers read but not their ids. An
1114
+ // option shoppers see no name for is told apart by its ids.
1115
+ export function describeCheckedPaymentChoice(): IPaymentChoice | undefined {
1116
+ const radios = Array.from(document.querySelectorAll(CHECKED_RADIO)).filter(
1117
+ radio =>
1118
+ isPicked(radio) &&
1119
+ radio.getAttribute('aria-hidden') !== 'true' &&
1120
+ !hasComposedAncestor(radio, '[data-mp-no-track]') &&
1121
+ isPaymentQuestion(radio)
1122
+ );
1123
+ const choices = radios
1124
+ .filter(
1125
+ radio =>
1126
+ !radios.some(other => other !== radio && radio.contains(other))
1127
+ )
1128
+ .map(radio => {
1129
+ const shown = distinctNames(shownOptionNames(radio));
1130
+ const names = optionNames(radio);
1131
+ return {
1132
+ radio,
1133
+ names,
1134
+ option: (shown.length ? shown : names).join('\n'),
1135
+ };
1136
+ });
1137
+ const options = new Set(choices.map(choice => choice.option));
1138
+ return options.size === 1 && choices[0].names.length
1139
+ ? { radio: choices[0].radio, names: choices[0].names }
1140
+ : undefined;
1141
+ }
1142
+
923
1143
  // Read once the page has handled the click, which may have cancelled it.
924
1144
  export function isPicked(radio: Element): boolean {
925
1145
  return radio instanceof HTMLInputElement