@mparticle/web-sdk 3.28.0 → 3.29.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mparticle/web-sdk",
3
- "version": "3.28.0",
3
+ "version": "3.29.1",
4
4
  "description": "mParticle core SDK for web applications",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -56,6 +56,11 @@ export interface IButtonClick {
56
56
  checked?: boolean;
57
57
  }
58
58
 
59
+ // A link run by script reads as a link, as an anchor does, so whoever reads
60
+ // the click treats it the same.
61
+ const LINK_CONTROL =
62
+ 'a[href]:not([role="button"]), [role~="link"]:not([role~="button"])';
63
+
59
64
  function elementKind(control: Element): ButtonElementKind {
60
65
  if (isWalletElement(control)) {
61
66
  return 'wallet';
@@ -64,13 +69,7 @@ function elementKind(control: Element): ButtonElementKind {
64
69
  if (tag === 'button' || tag === 'input') {
65
70
  return tag;
66
71
  }
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';
72
+ return control.matches(LINK_CONTROL) ? 'link' : 'role';
74
73
  }
75
74
 
76
75
  const NOT_RENDERED =
@@ -203,12 +202,19 @@ function textOf(element: Element | null): string | undefined {
203
202
  : '';
204
203
  }
205
204
 
206
- // The control itself is already known to be shown.
205
+ // The control itself is already known to be shown. An inline icon names
206
+ // itself with aria-label rather than alt.
207
207
  function shownImageAlt(control: Element): string | undefined {
208
- const images = control.querySelectorAll('img[alt]');
208
+ const images = control.querySelectorAll(
209
+ 'img[alt], svg[aria-label], [role="img"][aria-label]'
210
+ );
209
211
  for (let i = 0; i < images.length && i < MAX_READS; i++) {
210
- if (isShown(images[i], control) && isVisible(images[i])) {
211
- return nonBlank(images[i].getAttribute('alt'));
212
+ const alt = nonBlank(
213
+ images[i].getAttribute('alt') ||
214
+ images[i].getAttribute('aria-label')
215
+ );
216
+ if (alt && isShown(images[i], control) && isVisible(images[i])) {
217
+ return alt;
212
218
  }
213
219
  }
214
220
  return undefined;
@@ -381,14 +387,40 @@ function stableIdentifier(value: string | null): string | undefined {
381
387
  : undefined;
382
388
  }
383
389
 
390
+ // An id React's useId generates, in any of the forms React has used: `:r1:`,
391
+ // `«r1»` and `_r_1_`. On the client it is r and a short base-32 counter; on
392
+ // the server, R and a base-32 tree id, where a component's later ids add an H
393
+ // and a counter (`:R1H1:`). A word that only starts with r, such as
394
+ // `:region:`, is not one.
395
+ const REACT_ID = /:(?:r[0-9a-v]{1,4}|R[0-9a-v]+(?:H[0-9a-v]+)?):|\u00ab(?:r[0-9a-v]{1,4}|R[0-9a-v]+(?:H[0-9a-v]+)?)\u00bb|_(?:r_[0-9a-v]{1,4}|R_[0-9a-v]+(?:H[0-9a-v]+)?)_/g;
396
+
397
+ // What a UI library puts before the React id it names an unnamed group with
398
+ // (`radix-:r1:`, `react-aria-:R1:`). Left on its own once that id is removed,
399
+ // it names the library, not the question.
400
+ const LIBRARY_ID_PREFIX = /^(?:radix|react-aria|headlessui(?:-[a-z]+)*|mui|chakra|reach|ark|mantine|base-ui)$/i;
401
+
384
402
  // 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.
403
+ // id of the product it belongs to (`color-swatches-48213907`), or build it
404
+ // with a React id (`variant_group__R_1mn59b_`); without either it still names
405
+ // the question.
387
406
  function questionName(value: string | null): string | undefined {
388
- return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
407
+ if (!value) {
408
+ return undefined;
409
+ }
410
+ const trimmed = value.trim();
411
+ const withoutReactId = trimmed.replace(REACT_ID, '-');
412
+ const name = stableIdentifier(
413
+ withoutReactId
414
+ .replace(/([-_])[-_]+/g, '$1')
415
+ .replace(/^[-_]+|[-_]+$/g, '')
416
+ .replace(/[-_][0-9]{4,}$/, '')
417
+ );
418
+ return name && withoutReactId !== trimmed && LIBRARY_ID_PREFIX.test(name)
419
+ ? undefined
420
+ : name;
389
421
  }
390
422
 
391
- const TEST_ID_ATTRIBUTES = [
423
+ export const TEST_ID_ATTRIBUTES = [
392
424
  'data-testid',
393
425
  'data-test-id',
394
426
  'data-test',
@@ -422,14 +454,19 @@ function controlsIdentifier(control: Element): string | undefined {
422
454
  }
423
455
 
424
456
  // The id of the form a button or input actually submits, which the browser
425
- // resolves from its form attribute or its owning form. getAttribute, because a
426
- // field named "id" shadows form.id.
457
+ // resolves from its form attribute or its owning form. A role control, such as
458
+ // a styled radio box, belongs to the form around it, read without following a
459
+ // form attribute or leaving its shadow root, since neither makes it part of
460
+ // the form; a link, anchor or role, to none. getAttribute, because a field
461
+ // named "id" shadows form.id.
427
462
  function formIdentifier(control: Element): string | undefined {
428
463
  const form =
429
464
  control instanceof HTMLButtonElement ||
430
465
  control instanceof HTMLInputElement
431
466
  ? control.form
432
- : null;
467
+ : control.matches(LINK_CONTROL)
468
+ ? null
469
+ : control.closest('form');
433
470
  return form ? stableIdentifier(form.getAttribute('id')) : undefined;
434
471
  }
435
472
 
@@ -691,7 +728,9 @@ export function describeButtonClick(
691
728
  // aria-label, aria-labelledby or legend, masked like a label and cut at its
692
729
  // first colon, since a legend can repeat the pick ("Color: Navy"). Never the
693
730
  // option's own text, which is the shopper's answer.
694
- const NAMED_GROUP = '[role="radiogroup"], [role="listbox"], [role="group"], fieldset';
731
+ const NAMED_GROUP =
732
+ '[role="radiogroup"], [role="listbox"], [role="group"], fieldset, ' +
733
+ '[role="region"][aria-label], [role="region"][aria-labelledby]';
695
734
  // A control such as the button that opens a list box shows the option picked.
696
735
  const NOT_A_QUESTION =
697
736
  CHOICE +
@@ -740,6 +779,193 @@ function sameQuestion(option: Element, choice: Element): boolean {
740
779
  );
741
780
  }
742
781
 
782
+ function isTransparent(element: Element, until: Element): boolean {
783
+ return someComposedAncestor(
784
+ element,
785
+ node => window.getComputedStyle(node).opacity === '0',
786
+ until
787
+ );
788
+ }
789
+
790
+ // Content that is never painted where it sits: SVG that is only referenced or
791
+ // read, and a canvas's fallback, which some browsers still give boxes.
792
+ const UNDRAWN =
793
+ 'svg title, svg desc, svg metadata, svg defs, svg symbol, svg clipPath, ' +
794
+ 'svg mask, svg pattern, svg marker, svg linearGradient, ' +
795
+ 'svg radialGradient, svg filter, canvas';
796
+
797
+ // Whether text sits in any closed details element outside its summary.
798
+ function isInClosedDetails(text: Node, parent: Element): boolean {
799
+ let details = parent.closest('details:not([open])');
800
+ while (details) {
801
+ const summary = Array.from(details.children).filter(
802
+ child => child.tagName === 'SUMMARY'
803
+ )[0];
804
+ if (!summary || !summary.contains(text)) {
805
+ return true;
806
+ }
807
+ details = details.parentElement
808
+ ? details.parentElement.closest('details:not([open])')
809
+ : null;
810
+ }
811
+ return false;
812
+ }
813
+
814
+ // Whether the browser shows an element through every ancestor it renders
815
+ // under, shadow trees included, which a walk of light ancestors can't see
816
+ // into when a shadow root is closed. Where it can't say (checkVisibility
817
+ // arrived in Chrome 105, Firefox 125 and Safari 17.4), the element counts as
818
+ // not shown, so option rows name no group in older browsers.
819
+ function isComposedVisible(element: Element): boolean {
820
+ const check = (element as Element & {
821
+ checkVisibility?: (options: { [key: string]: boolean }) => boolean;
822
+ }).checkVisibility;
823
+ return (
824
+ isFunction(check) &&
825
+ // Older Chrome versions read the first two names, newer browsers the rest.
826
+ check.call(element, {
827
+ checkOpacity: true,
828
+ checkVisibilityCSS: true,
829
+ opacityProperty: true,
830
+ visibilityProperty: true,
831
+ })
832
+ );
833
+ }
834
+
835
+ // Whether the browser lays a text node out in a box of its own. Text it never
836
+ // draws, such as an SVG definition, canvas fallback, closed details or an
837
+ // unslotted child of a shadow host, has none. Where boxes can't be read, the
838
+ // text counts as not drawn.
839
+ function isDrawn(text: Node): boolean {
840
+ const range = isFunction(document.createRange)
841
+ ? document.createRange()
842
+ : null;
843
+ if (!range || !isFunction(range.getClientRects)) {
844
+ return false;
845
+ }
846
+ range.selectNodeContents(text);
847
+ return Array.from(range.getClientRects()).some(
848
+ box => box.width > 0 && box.height > 0
849
+ );
850
+ }
851
+
852
+ // Whether a row shows text of its own: a drawn, shown, non-transparent text
853
+ // node, outside any form field, editable area or opted-out part, beside
854
+ // nothing but the row's own input. Not a row with a shadow root, slot or
855
+ // custom element, whose rendered text a walk of its light children can't see.
856
+ function showsText(row: Element, own: Element): boolean {
857
+ if (
858
+ isUnsafeTextSource(row, own) ||
859
+ hasComposedAncestor(own, '[data-mp-no-track]') ||
860
+ row.shadowRoot ||
861
+ row.tagName.indexOf('-') !== -1 ||
862
+ row.querySelector('slot')
863
+ ) {
864
+ return false;
865
+ }
866
+ // A row too large to check in full counts as showing nothing.
867
+ const descendants = row.querySelectorAll('*');
868
+ if (descendants.length > MAX_READS) {
869
+ return false;
870
+ }
871
+ for (let i = 0; i < descendants.length; i++) {
872
+ if (
873
+ descendants[i].shadowRoot ||
874
+ descendants[i].tagName.indexOf('-') !== -1
875
+ ) {
876
+ return false;
877
+ }
878
+ }
879
+ const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
880
+ for (let i = 0; i < MAX_READS && walker.nextNode(); i++) {
881
+ const parent = walker.currentNode.parentElement;
882
+ if (
883
+ parent &&
884
+ nonBlank(walker.currentNode.nodeValue) &&
885
+ // Undrawn content, and the body of a closed details element,
886
+ // which browsers still give layout boxes.
887
+ !parent.closest(UNDRAWN) &&
888
+ !isInClosedDetails(walker.currentNode, parent) &&
889
+ isShown(parent, row) &&
890
+ isVisible(parent) &&
891
+ !isTransparent(parent, row) &&
892
+ isComposedVisible(parent) &&
893
+ isDrawn(walker.currentNode)
894
+ ) {
895
+ return true;
896
+ }
897
+ }
898
+ return false;
899
+ }
900
+
901
+ // A native input inside a shown control of its own, such as a menu option row
902
+ // with a styled radio, which shows text of its own and doesn't also hold the
903
+ // option picked.
904
+ function showsRow(option: Element, choice: Element, group: Element): boolean {
905
+ const row =
906
+ option instanceof HTMLInputElement && option.parentElement
907
+ ? option.parentElement.closest(CONTROL_SELECTOR)
908
+ : null;
909
+ return (
910
+ !!row &&
911
+ group.contains(row) &&
912
+ !row.contains(choice) &&
913
+ isShown(row, group) &&
914
+ isVisible(row) &&
915
+ !isTransparent(row, group) &&
916
+ showsText(row, option)
917
+ );
918
+ }
919
+
920
+ // Native boxes with no name, which the browser doesn't group, answer one
921
+ // question only as menu option rows do: each in a shown row of its own, in the
922
+ // same group. A card around a "make default" checkbox and another box has no
923
+ // such rows. Rows alone can't tell a question from a card named for a person
924
+ // around separate settings, so only a group that shows its question and holds
925
+ // the rows directly counts.
926
+ function unnamedOptionRows(
927
+ option: Element,
928
+ choice: Element,
929
+ group: Element
930
+ ): boolean {
931
+ return (
932
+ option instanceof HTMLInputElement &&
933
+ choice instanceof HTMLInputElement &&
934
+ option.type === choice.type &&
935
+ !option.getAttribute('name') &&
936
+ !choice.getAttribute('name') &&
937
+ option.form === choice.form &&
938
+ option.closest(NAMED_GROUP) === group &&
939
+ choice.closest(NAMED_GROUP) === group &&
940
+ showsRow(option, choice, group) &&
941
+ showsRow(choice, option, group) &&
942
+ showsQuestion(group)
943
+ );
944
+ }
945
+
946
+ // A native input the page hides, styling its label as the option instead, is
947
+ // shown by that label: one of its own, in the group, that the browser draws
948
+ // and that shows text of its own, as an option row must. Neither an opted-out
949
+ // input nor an opted-out label counts.
950
+ function showsLabel(option: Element, choice: Element, group: Element): boolean {
951
+ return (
952
+ option instanceof HTMLInputElement &&
953
+ !!option.labels &&
954
+ !hasComposedAncestor(option, '[data-mp-no-track]') &&
955
+ Array.from(option.labels).some(
956
+ label =>
957
+ group.contains(label) &&
958
+ !label.contains(choice) &&
959
+ !hasComposedAncestor(label, '[data-mp-no-track]') &&
960
+ isShown(label, group) &&
961
+ isVisible(label) &&
962
+ !isTransparent(label, group) &&
963
+ isComposedVisible(label) &&
964
+ showsText(label, option)
965
+ )
966
+ );
967
+ }
968
+
743
969
  // Whether a group offers another answer to the question picked: a shown option
744
970
  // to the same question that isn't part of the one picked. A card around one
745
971
  // address radio and a "make default" checkbox, or around a role radio and the
@@ -751,16 +977,16 @@ function holdsOptions(group: Element, choice: Element): boolean {
751
977
  if (
752
978
  !option.contains(choice) &&
753
979
  !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
- )
980
+ (sameQuestion(option, choice) ||
981
+ unnamedOptionRows(option, choice, group)) &&
982
+ ((isShown(option, group) &&
983
+ isVisible(option) &&
984
+ // A fully transparent option, as a native input behind a
985
+ // styled one often is, shows the shopper no second answer,
986
+ // unless a shown row around it does.
987
+ (!isTransparent(option, group) ||
988
+ showsRow(option, choice, group))) ||
989
+ showsLabel(option, choice, group))
764
990
  ) {
765
991
  return true;
766
992
  }
@@ -788,51 +1014,109 @@ function optionLabels(option: Element, root: Document | ShadowRoot): Element[] {
788
1014
  return labels;
789
1015
  }
790
1016
 
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 {
1017
+ // The name of the group asking the question a pick answers. A role radio's
1018
+ // group is its radiogroup and an option's its list box; a role toggle, such as
1019
+ // a switch, stands alone. A native box's is the nearest group offering other
1020
+ // answers, since a group around one option, such as a saved address card, can
1021
+ // be named by that option. A region with no usable name is passed over, so a
1022
+ // group around it can still name the pick. Each group's name is read once.
1023
+ function groupName(choice: Element): string | undefined {
797
1024
  if (!(choice instanceof HTMLInputElement)) {
798
1025
  const container = choice.matches('[role~="option"]')
799
1026
  ? choice.closest('[role="listbox"]')
800
1027
  : choice.matches('[role~="radio"]')
801
1028
  ? choice.closest('[role="radiogroup"]')
802
1029
  : null;
803
- return container && holdsOptions(container, choice) ? container : null;
1030
+ return container && holdsOptions(container, choice)
1031
+ ? nameOf(container)
1032
+ : undefined;
804
1033
  }
805
1034
  let group = choice.closest(NAMED_GROUP);
806
- while (group && !holdsOptions(group, choice)) {
1035
+ while (group) {
1036
+ if (holdsOptions(group, choice)) {
1037
+ const name = nameOf(group);
1038
+ if (name || !group.matches('[role="region"]')) {
1039
+ return name;
1040
+ }
1041
+ }
807
1042
  group = group.parentElement
808
1043
  ? group.parentElement.closest(NAMED_GROUP)
809
1044
  : null;
810
1045
  }
811
- return group;
1046
+ return undefined;
812
1047
  }
813
1048
 
814
- function groupName(choice: Element): string | undefined {
815
- const group = questionGroup(choice);
816
- if (!group) {
817
- return undefined;
1049
+ // Whether what names a group is shown as its question: a fieldset's own
1050
+ // legend, or the header that opens a section. nameOf reads an aria-label first,
1051
+ // so a group carrying one is named by that instead.
1052
+ function showsQuestion(group: Element): boolean {
1053
+ if (group.tagName === 'FIELDSET') {
1054
+ return namedByLegend(group);
818
1055
  }
1056
+ return (
1057
+ !nonBlank(group.getAttribute('aria-label')) &&
1058
+ opensRegion(group, labelledBy(group))
1059
+ );
1060
+ }
1061
+
1062
+ // A fieldset's own legend, a direct child, as the browser reads it.
1063
+ function legendOf(group: Element): Element | undefined {
1064
+ return group.tagName === 'FIELDSET'
1065
+ ? Array.from(group.children).filter(
1066
+ child => child.tagName === 'LEGEND'
1067
+ )[0]
1068
+ : undefined;
1069
+ }
1070
+
1071
+ // Whether a fieldset is named by its own legend, which nameOf reads only when
1072
+ // no aria-label overrides it and any aria-labelledby points at that legend.
1073
+ function namedByLegend(group: Element): boolean {
1074
+ const legend = legendOf(group);
1075
+ return (
1076
+ !!legend &&
1077
+ !nonBlank(group.getAttribute('aria-label')) &&
1078
+ (!group.getAttribute('aria-labelledby') || labelledBy(group) === legend)
1079
+ );
1080
+ }
1081
+
1082
+ // The first element a group's aria-labelledby points at.
1083
+ function labelledBy(group: Element): Element | null {
1084
+ const ids = group.getAttribute('aria-labelledby');
1085
+ const root = (isFunction(group.getRootNode)
1086
+ ? group.getRootNode()
1087
+ : document) as Document | ShadowRoot;
1088
+ return ids && root.getElementById
1089
+ ? root.getElementById(ids.trim().split(/\s+/)[0])
1090
+ : null;
1091
+ }
1092
+
1093
+ // Whether a region is opened by this accordion header: a tab outside any tab
1094
+ // list, holding no control or option of its own.
1095
+ function opensRegion(group: Element, header: Element | null): boolean {
1096
+ return (
1097
+ !!header &&
1098
+ group.matches('[role="region"]') &&
1099
+ (header.getAttribute('role') || '').trim() === 'tab' &&
1100
+ !hasComposedAncestor(header, '[role~="tablist"]') &&
1101
+ !header.querySelector(NOT_A_QUESTION)
1102
+ );
1103
+ }
1104
+
1105
+ function nameOf(group: Element): string | undefined {
819
1106
  let raw = nonBlank(group.getAttribute('aria-label'));
820
1107
  if (!raw) {
821
1108
  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;
1109
+ const legend = legendOf(group);
828
1110
  const root = (isFunction(group.getRootNode)
829
1111
  ? group.getRootNode()
830
1112
  : document) as Document | ShadowRoot;
831
- const source = ids
832
- ? root.getElementById
833
- ? root.getElementById(ids.trim().split(/\s+/)[0])
834
- : null
835
- : legend || null;
1113
+ const target = ids ? labelledBy(group) : legend || null;
1114
+ // A region opened by an accordion header is named by the header's
1115
+ // first heading, never the rest of what it shows.
1116
+ const header = ids && opensRegion(group, target) ? target : null;
1117
+ const source = header
1118
+ ? header.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]')
1119
+ : target;
836
1120
  // A label that is, holds or sits in an option or a control, or in an
837
1121
  // option's label, would name the answer.
838
1122
  const holder = source ? source.closest('label') : null;
@@ -848,7 +1132,10 @@ function groupName(choice: Element): string | undefined {
848
1132
  raw =
849
1133
  source &&
850
1134
  !labelsOption &&
851
- !hasComposedAncestor(source, NOT_A_QUESTION) &&
1135
+ !someComposedAncestor(
1136
+ source,
1137
+ node => node !== header && node.matches(NOT_A_QUESTION)
1138
+ ) &&
852
1139
  !source.querySelector(NOT_A_QUESTION) &&
853
1140
  !(holder && (holder.control || holder.querySelector(CHOICE)))
854
1141
  ? textOf(source)
@@ -877,9 +1164,9 @@ function isPaymentQuestion(radio: Element): boolean {
877
1164
  return question.some(words => PAYMENT_QUESTION.test(nameWords(words)));
878
1165
  }
879
1166
 
880
- // What names a payment option: the radio's accessible name, the text and
881
- // image of each label, and its hand-written ids, text masked like a label.
882
- function optionNames(radio: Element): string[] {
1167
+ // What names a payment option to shoppers: the radio's accessible name and the
1168
+ // text and image of each label, masked like a label.
1169
+ function shownOptionNames(radio: Element): Array<string | undefined> {
883
1170
  const names: Array<string | undefined> = [
884
1171
  maskLabel(accessibleName(radio).raw),
885
1172
  ];
@@ -893,15 +1180,26 @@ function optionNames(radio: Element): string[] {
893
1180
  names.push(maskLabel(shownImageAlt(label)));
894
1181
  }
895
1182
  });
896
- names.push(
897
- stableIdentifier(radio.getAttribute('id')),
898
- firstIdentifier(radio, TEST_ID_ATTRIBUTES)
899
- );
1183
+ return names;
1184
+ }
1185
+
1186
+ function distinctNames(names: Array<string | undefined>): string[] {
900
1187
  return names.filter(
901
1188
  (name, index) => !!name && names.indexOf(name) === index
902
1189
  );
903
1190
  }
904
1191
 
1192
+ // What names a payment option: the names shoppers read, then its hand-written
1193
+ // ids.
1194
+ function optionNames(radio: Element): string[] {
1195
+ return distinctNames(
1196
+ shownOptionNames(radio).concat(
1197
+ stableIdentifier(radio.getAttribute('id')),
1198
+ firstIdentifier(radio, TEST_ID_ATTRIBUTES)
1199
+ )
1200
+ );
1201
+ }
1202
+
905
1203
  // The payment radio a click picks and the names of its option, for the Rokt
906
1204
  // kit alone to read a payment method from. A radio's option is the shopper's
907
1205
  // answer, so these names never go into the logged click.
@@ -926,6 +1224,44 @@ export function describePaymentChoice(
926
1224
  return names.length ? { radio: choice, names } : undefined;
927
1225
  }
928
1226
 
1227
+ const CHECKED_RADIO =
1228
+ 'input[type="radio"]:checked, [role~="radio"][aria-checked="true"], [role~="menuitemradio"][aria-checked="true"]';
1229
+
1230
+ // The payment radio the page holds checked, such as a card option checked by
1231
+ // default that the shopper never clicked. A page holding more than one option
1232
+ // names no single method, so it describes nothing. One option drawn twice
1233
+ // counts once: a role radio wrapping its native input, a hidden input a
1234
+ // component library keeps beside its role radio, or one group rendered for two
1235
+ // layouts, whose copies share the names shoppers read but not their ids. An
1236
+ // option shoppers see no name for is told apart by its ids.
1237
+ export function describeCheckedPaymentChoice(): IPaymentChoice | undefined {
1238
+ const radios = Array.from(document.querySelectorAll(CHECKED_RADIO)).filter(
1239
+ radio =>
1240
+ isPicked(radio) &&
1241
+ radio.getAttribute('aria-hidden') !== 'true' &&
1242
+ !hasComposedAncestor(radio, '[data-mp-no-track]') &&
1243
+ isPaymentQuestion(radio)
1244
+ );
1245
+ const choices = radios
1246
+ .filter(
1247
+ radio =>
1248
+ !radios.some(other => other !== radio && radio.contains(other))
1249
+ )
1250
+ .map(radio => {
1251
+ const shown = distinctNames(shownOptionNames(radio));
1252
+ const names = optionNames(radio);
1253
+ return {
1254
+ radio,
1255
+ names,
1256
+ option: (shown.length ? shown : names).join('\n'),
1257
+ };
1258
+ });
1259
+ const options = new Set(choices.map(choice => choice.option));
1260
+ return options.size === 1 && choices[0].names.length
1261
+ ? { radio: choices[0].radio, names: choices[0].names }
1262
+ : undefined;
1263
+ }
1264
+
929
1265
  // Read once the page has handled the click, which may have cancelled it.
930
1266
  export function isPicked(radio: Element): boolean {
931
1267
  return radio instanceof HTMLInputElement