@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.
- package/README.md +2 -2
- package/dist/mparticle.common.js +1 -1
- package/dist/mparticle.esm.js +1 -1
- package/dist/mparticle.js +341 -108
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +2 -1
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +0 -6
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +2 -0
- package/dist/types/src/pathMasking.d.ts +2 -0
- package/package.json +1 -1
- package/src/batchUploader.ts +25 -9
- package/src/buttonTracking/buttonDescription.ts +266 -46
- package/src/buttonTracking/buttonTracker.ts +22 -62
- package/src/buttonTracking/checkoutIntent.ts +56 -2
- package/src/pageViewTracker.ts +3 -1
- package/src/pathMasking.ts +116 -9
|
@@ -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
package/src/batchUploader.ts
CHANGED
|
@@ -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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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 =
|
|
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
|
-
|
|
754
|
-
|
|
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
|
|
786
|
-
// radiogroup and an option's its list box; a role toggle, such as
|
|
787
|
-
// stands alone. A native box's is the nearest group offering other
|
|
788
|
-
// since a group around one option, such as a saved address card, can
|
|
789
|
-
// named by that option.
|
|
790
|
-
|
|
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)
|
|
948
|
+
return container && holdsOptions(container, choice)
|
|
949
|
+
? nameOf(container)
|
|
950
|
+
: undefined;
|
|
798
951
|
}
|
|
799
952
|
let group = choice.closest(NAMED_GROUP);
|
|
800
|
-
while (group
|
|
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
|
|
964
|
+
return undefined;
|
|
806
965
|
}
|
|
807
966
|
|
|
808
|
-
function
|
|
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
|
|
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
|
-
!
|
|
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
|
|
875
|
-
// image of each label,
|
|
876
|
-
function
|
|
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
|
|
891
|
-
|
|
892
|
-
|
|
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
|