@mparticle/web-sdk 3.28.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.
@@ -203,12 +203,19 @@ function textOf(element: Element | null): string | undefined {
203
203
  : '';
204
204
  }
205
205
 
206
- // 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.
207
208
  function shownImageAlt(control: Element): string | undefined {
208
- const images = control.querySelectorAll('img[alt]');
209
+ const images = control.querySelectorAll(
210
+ 'img[alt], svg[aria-label], [role="img"][aria-label]'
211
+ );
209
212
  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'));
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;
212
219
  }
213
220
  }
214
221
  return undefined;
@@ -388,7 +395,7 @@ function questionName(value: string | null): string | undefined {
388
395
  return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
389
396
  }
390
397
 
391
- const TEST_ID_ATTRIBUTES = [
398
+ export const TEST_ID_ATTRIBUTES = [
392
399
  'data-testid',
393
400
  'data-test-id',
394
401
  'data-test',
@@ -691,7 +698,9 @@ export function describeButtonClick(
691
698
  // aria-label, aria-labelledby or legend, masked like a label and cut at its
692
699
  // first colon, since a legend can repeat the pick ("Color: Navy"). Never the
693
700
  // option's own text, which is the shopper's answer.
694
- 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]';
695
704
  // A control such as the button that opens a list box shows the option picked.
696
705
  const NOT_A_QUESTION =
697
706
  CHOICE +
@@ -740,6 +749,144 @@ function sameQuestion(option: Element, choice: Element): boolean {
740
749
  );
741
750
  }
742
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
+
743
890
  // Whether a group offers another answer to the question picked: a shown option
744
891
  // to the same question that isn't part of the one picked. A card around one
745
892
  // address radio and a "make default" checkbox, or around a role radio and the
@@ -755,12 +902,9 @@ function holdsOptions(group: Element, choice: Element): boolean {
755
902
  isShown(option, group) &&
756
903
  isVisible(option) &&
757
904
  // 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
- )
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))
764
908
  ) {
765
909
  return true;
766
910
  }
@@ -788,34 +932,39 @@ function optionLabels(option: Element, root: Document | ShadowRoot): Element[] {
788
932
  return labels;
789
933
  }
790
934
 
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 {
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 {
797
942
  if (!(choice instanceof HTMLInputElement)) {
798
943
  const container = choice.matches('[role~="option"]')
799
944
  ? choice.closest('[role="listbox"]')
800
945
  : choice.matches('[role~="radio"]')
801
946
  ? choice.closest('[role="radiogroup"]')
802
947
  : null;
803
- return container && holdsOptions(container, choice) ? container : null;
948
+ return container && holdsOptions(container, choice)
949
+ ? nameOf(container)
950
+ : undefined;
804
951
  }
805
952
  let group = choice.closest(NAMED_GROUP);
806
- 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
+ }
807
960
  group = group.parentElement
808
961
  ? group.parentElement.closest(NAMED_GROUP)
809
962
  : null;
810
963
  }
811
- return group;
964
+ return undefined;
812
965
  }
813
966
 
814
- function groupName(choice: Element): string | undefined {
815
- const group = questionGroup(choice);
816
- if (!group) {
817
- return undefined;
818
- }
967
+ function nameOf(group: Element): string | undefined {
819
968
  let raw = nonBlank(group.getAttribute('aria-label'));
820
969
  if (!raw) {
821
970
  const ids = group.getAttribute('aria-labelledby');
@@ -828,11 +977,24 @@ function groupName(choice: Element): string | undefined {
828
977
  const root = (isFunction(group.getRootNode)
829
978
  ? group.getRootNode()
830
979
  : document) as Document | ShadowRoot;
831
- const source = ids
980
+ const target = ids
832
981
  ? root.getElementById
833
982
  ? root.getElementById(ids.trim().split(/\s+/)[0])
834
983
  : null
835
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;
836
998
  // A label that is, holds or sits in an option or a control, or in an
837
999
  // option's label, would name the answer.
838
1000
  const holder = source ? source.closest('label') : null;
@@ -848,7 +1010,10 @@ function groupName(choice: Element): string | undefined {
848
1010
  raw =
849
1011
  source &&
850
1012
  !labelsOption &&
851
- !hasComposedAncestor(source, NOT_A_QUESTION) &&
1013
+ !someComposedAncestor(
1014
+ source,
1015
+ node => node !== header && node.matches(NOT_A_QUESTION)
1016
+ ) &&
852
1017
  !source.querySelector(NOT_A_QUESTION) &&
853
1018
  !(holder && (holder.control || holder.querySelector(CHOICE)))
854
1019
  ? textOf(source)
@@ -877,9 +1042,9 @@ function isPaymentQuestion(radio: Element): boolean {
877
1042
  return question.some(words => PAYMENT_QUESTION.test(nameWords(words)));
878
1043
  }
879
1044
 
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[] {
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> {
883
1048
  const names: Array<string | undefined> = [
884
1049
  maskLabel(accessibleName(radio).raw),
885
1050
  ];
@@ -893,15 +1058,26 @@ function optionNames(radio: Element): string[] {
893
1058
  names.push(maskLabel(shownImageAlt(label)));
894
1059
  }
895
1060
  });
896
- names.push(
897
- stableIdentifier(radio.getAttribute('id')),
898
- firstIdentifier(radio, TEST_ID_ATTRIBUTES)
899
- );
1061
+ return names;
1062
+ }
1063
+
1064
+ function distinctNames(names: Array<string | undefined>): string[] {
900
1065
  return names.filter(
901
1066
  (name, index) => !!name && names.indexOf(name) === index
902
1067
  );
903
1068
  }
904
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
+
905
1081
  // The payment radio a click picks and the names of its option, for the Rokt
906
1082
  // kit alone to read a payment method from. A radio's option is the shopper's
907
1083
  // answer, so these names never go into the logged click.
@@ -926,6 +1102,44 @@ export function describePaymentChoice(
926
1102
  return names.length ? { radio: choice, names } : undefined;
927
1103
  }
928
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
+
929
1143
  // Read once the page has handled the click, which may have cancelled it.
930
1144
  export function isPicked(radio: Element): boolean {
931
1145
  return radio instanceof HTMLInputElement
@@ -3,6 +3,7 @@ import { isFunction } from '../utils';
3
3
  import { buildButtonClickEvent, buildLinkClickEvent } from './buttonClickEvent';
4
4
  import {
5
5
  describeButtonClick,
6
+ describeCheckedPaymentChoice,
6
7
  describePaymentChoice,
7
8
  IButtonClick,
8
9
  IPaymentChoice,
@@ -14,6 +15,7 @@ import {
14
15
  CheckoutIntentSource,
15
16
  classifyIntent,
16
17
  classifyPaymentFrame,
18
+ namesWallet,
17
19
  PaymentFrameFamily,
18
20
  } from './checkoutIntent';
19
21
  import { findControl, findRoleControl, isExcludedPath } from './controls';
@@ -390,6 +392,7 @@ export class ButtonTracker {
390
392
  let source: CheckoutIntentSource | undefined;
391
393
  let adds: true | undefined;
392
394
  let choice: IPaymentChoice | undefined;
395
+ let checkedNames: string[] | undefined;
393
396
  let family: PaymentFrameFamily | undefined;
394
397
  let unconfirmedCommit = false;
395
398
  if (layer === 'click') {
@@ -443,6 +446,19 @@ export class ButtonTracker {
443
446
  choice = signalsKit
444
447
  ? describePaymentChoice(control, path)
445
448
  : undefined;
449
+ // A confirm or checkout-step click picks no radio itself, so it
450
+ // carries the one the page holds checked as read here. It is not
451
+ // read again at delivery: the page's own handlers may clear or
452
+ // replace it by then. A wallet tap names its own method, and
453
+ // so does a confirm that names a wallet.
454
+ if (
455
+ !choice &&
456
+ (kind === 'commit' || kind === 'checkout_step') &&
457
+ !namesWallet(control, click)
458
+ ) {
459
+ const checked = describeCheckedPaymentChoice();
460
+ checkedNames = checked ? checked.names : undefined;
461
+ }
446
462
  }
447
463
  this.lastClick = { t, excluded, kind, source };
448
464
  } else if (layer === 'submit') {
@@ -503,6 +519,8 @@ export class ButtonTracker {
503
519
  // with this pick.
504
520
  if (choice && isPicked(choice.radio)) {
505
521
  this.pickPayment(t, pathname, choice.names);
522
+ } else if (checkedNames) {
523
+ this.pickPayment(t, pathname, checkedNames);
506
524
  } else if (family) {
507
525
  this.pickPayment(t, pathname, undefined, family);
508
526
  }
@@ -7,6 +7,7 @@ import {
7
7
  isWalletNamedControl,
8
8
  vendorButtonKind,
9
9
  } from './controls';
10
+ import { IButtonClick, TEST_ID_ATTRIBUTES } from './buttonDescription';
10
11
 
11
12
  // Classifies a click, submit or frame focus as checkout intent for the Rokt kit.
12
13
  // Reads only short control labels, never form field values, and stores nothing.
@@ -126,6 +127,9 @@ const walletBrands = new RegExp(
126
127
  '^(?:' + WALLET_NAMES + ')(?: (?:checkout|check out|express(?: checkout)?|express pay|buy now|pay now|pay|now)' +
127
128
  '| (?:logo|icon|mark|button)(?: (?:logo|icon|mark|button))*)?$'
128
129
  );
130
+ // The wallets the Rokt kit reads from a click's label or identifiers, as words:
131
+ // "Place order with PayPal!" and "paypal-pay-button" both name PayPal.
132
+ const KIT_WALLET_WORDS = /(?:^| )(?:apple ?pay|google ?pay|g ?pay|android ?pay|venmo|pay ?pal|amazon ?pay|shop ?pay|klarna|after ?pay|clear ?pay|affirm|cash ?app|sezzle|samsung ?pay)(?= |$)/;
129
133
  // The payment method a frame's focus picks, in the Rokt kit's family names.
130
134
  export type PaymentFrameFamily =
131
135
  | 'apple_pay'
@@ -393,6 +397,38 @@ export function classifyIntent(
393
397
  return cart;
394
398
  }
395
399
 
400
+ // The Rokt kit's own word split, so "PayPal!" and "paypal2" both read as
401
+ // "paypal" here as they do there.
402
+ function kitWords(value: string | null | undefined): string {
403
+ return (value || '')
404
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
405
+ .toLowerCase()
406
+ .replace(/[^a-z]+/g, ' ')
407
+ .trim();
408
+ }
409
+
410
+ // A confirm control may name its wallet, so "Place order with PayPal" and a
411
+ // "Place order" button with id paypal-pay-button are confirm clicks that still
412
+ // name their own method. A logged click is judged on the fields the kit reads
413
+ // a method from; a click that isn't logged, on the control's own label and
414
+ // identifiers.
415
+ export function namesWallet(control: Element, click?: IButtonClick): boolean {
416
+ const values = click
417
+ ? [
418
+ click.label,
419
+ click.ariaLabel,
420
+ click.elementId,
421
+ click.elementName,
422
+ click.testId,
423
+ ]
424
+ : labelSources(control).concat(
425
+ ['id', 'name']
426
+ .concat(TEST_ID_ATTRIBUTES)
427
+ .map(attribute => control.getAttribute(attribute))
428
+ );
429
+ return values.some(value => KIT_WALLET_WORDS.test(kitWords(value)));
430
+ }
431
+
396
432
  export function classifyIntentControl(
397
433
  control: Element,
398
434
  pathname: string