@mparticle/web-sdk 3.26.0 → 3.27.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/dist/mparticle.common.js +1 -1
- package/dist/mparticle.esm.js +1 -1
- package/dist/mparticle.js +500 -49
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +4 -1
- package/dist/types/src/buttonTracking/controls.d.ts +2 -1
- package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
- package/dist/types/src/pageUrl.d.ts +1 -0
- package/dist/types/src/pathMasking.d.ts +7 -0
- package/package.json +1 -1
- package/src/buttonTracking/buttonClickEvent.ts +9 -3
- package/src/buttonTracking/buttonDescription.ts +319 -17
- package/src/buttonTracking/buttonTracker.ts +21 -10
- package/src/buttonTracking/checkoutIntent.ts +44 -8
- package/src/buttonTracking/controls.ts +54 -2
- package/src/buttonTracking/labelMasking.ts +6 -0
- package/src/pageUrl.ts +58 -9
- package/src/pageViewTracker.ts +6 -2
- package/src/pathMasking.ts +199 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet' | 'link';
|
|
2
2
|
export type LinkType = 'same_page' | 'internal' | 'outbound' | 'download';
|
|
3
|
-
export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'alt' | 'title' | 'value' | 'none';
|
|
3
|
+
export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'alt' | 'title' | 'value' | 'heading' | 'none';
|
|
4
4
|
export interface IButtonClick {
|
|
5
5
|
label?: string;
|
|
6
6
|
labelSource: ButtonLabelSource;
|
|
@@ -11,12 +11,15 @@ export interface IButtonClick {
|
|
|
11
11
|
testId?: string;
|
|
12
12
|
controls?: string;
|
|
13
13
|
form?: string;
|
|
14
|
+
group?: string;
|
|
14
15
|
element: ButtonElementKind;
|
|
15
16
|
type: string;
|
|
16
17
|
linkType?: LinkType;
|
|
17
18
|
linkDomain?: string;
|
|
19
|
+
linkPath?: string;
|
|
18
20
|
fileExtension?: string;
|
|
19
21
|
checked?: boolean;
|
|
22
|
+
pick?: true;
|
|
20
23
|
}
|
|
21
24
|
export declare function isLinkClick(click: IButtonClick): boolean;
|
|
22
25
|
export declare function describeButtonClick(control: Element, path?: Array<EventTarget | null>): IButtonClick | undefined;
|
|
@@ -9,5 +9,6 @@ export declare function isWalletElement(element: Element): boolean;
|
|
|
9
9
|
export declare function isQuietIntentControl(control: Element, isVendorWallet: boolean, isCart?: boolean): boolean;
|
|
10
10
|
export declare function isWalletNamedControl(element: Element): boolean;
|
|
11
11
|
export declare function isExcludedPath(path: Array<EventTarget | null>): boolean;
|
|
12
|
-
export declare function findControl(path: Array<EventTarget | null>, included?: boolean): Element | undefined;
|
|
12
|
+
export declare function findControl(path: Array<EventTarget | null>, included?: boolean, limit?: number): Element | undefined;
|
|
13
|
+
export declare function findRoleControl(path: Array<EventTarget | null>, limit: number): Element | undefined;
|
|
13
14
|
export declare function isUnsafeTextSource(element: Element, own?: Element): boolean;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const LEFTOVER_ESCAPE: RegExp;
|
|
2
|
+
export declare function hidesNextSegment(route: string): boolean;
|
|
3
|
+
export declare function decodeFully(text: string): string | undefined;
|
|
4
|
+
export declare function routeSegment(segments: string[], i: number): string;
|
|
5
|
+
export declare function isTokenSegment(segment: string): boolean;
|
|
6
|
+
export declare function pagePath(pathname: string): string;
|
|
7
|
+
export declare function pageUrlPath(pathname: string): string;
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import KitFilterHelper from '../kitFilterHelper';
|
|
|
5
5
|
import { BaseEvent, SDKEvent } from '../sdkRuntimeModels';
|
|
6
6
|
import { EventType, MessageType } from '../types';
|
|
7
7
|
import { IButtonClick } from './buttonDescription';
|
|
8
|
+
import { pagePath } from '../pathMasking';
|
|
8
9
|
|
|
9
10
|
// Marks an automatically logged button click, like `is_auto_page_view`.
|
|
10
11
|
export const AUTO_BUTTON_ATTRIBUTE = 'is_auto_button_click';
|
|
@@ -27,7 +28,7 @@ const clickAttributes = (
|
|
|
27
28
|
label_source: click.labelSource,
|
|
28
29
|
element: click.element,
|
|
29
30
|
type: click.type,
|
|
30
|
-
path: pathname,
|
|
31
|
+
path: pagePath(pathname),
|
|
31
32
|
hostname: window.location.hostname,
|
|
32
33
|
};
|
|
33
34
|
// Optional fields are left out when the control has no safe value for them.
|
|
@@ -40,8 +41,10 @@ const clickAttributes = (
|
|
|
40
41
|
test_id: click.testId,
|
|
41
42
|
controls: click.controls,
|
|
42
43
|
form: click.form,
|
|
44
|
+
group: click.group,
|
|
43
45
|
link_type: click.linkType,
|
|
44
46
|
link_domain: click.linkDomain,
|
|
47
|
+
link_path: click.linkPath,
|
|
45
48
|
file_extension: click.fileExtension,
|
|
46
49
|
};
|
|
47
50
|
for (const key of Object.keys(optional)) {
|
|
@@ -72,8 +75,9 @@ export const buildButtonClickEvent = (
|
|
|
72
75
|
|
|
73
76
|
// One custom event per click on a link that leaves the page. Typed Other, not
|
|
74
77
|
// Navigation, so no kit reads it as a page change. A plain link to another page
|
|
75
|
-
// of the site goes without its text, which can name the shopper ("Hi, Jane")
|
|
76
|
-
// a button or wallet acting as a link keeps
|
|
78
|
+
// of the site goes without its text, which can name the shopper ("Hi, Jane"), and
|
|
79
|
+
// with its destination path instead; a button or wallet acting as a link keeps
|
|
80
|
+
// its name.
|
|
77
81
|
export const buildLinkClickEvent = (
|
|
78
82
|
click: IButtonClick,
|
|
79
83
|
pathname: string
|
|
@@ -114,8 +118,10 @@ const CLICK_ATTRIBUTES = [
|
|
|
114
118
|
'test_id',
|
|
115
119
|
'controls',
|
|
116
120
|
'form',
|
|
121
|
+
'group',
|
|
117
122
|
'link_type',
|
|
118
123
|
'link_domain',
|
|
124
|
+
'link_path',
|
|
119
125
|
'file_extension',
|
|
120
126
|
'checked',
|
|
121
127
|
];
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { isFunction } from '../utils';
|
|
2
2
|
import {
|
|
3
|
+
CONTROL_SELECTOR,
|
|
3
4
|
hasComposedAncestor,
|
|
4
5
|
isUnsafeTextSource,
|
|
5
6
|
isWalletElement,
|
|
6
7
|
nameWords,
|
|
7
8
|
someComposedAncestor,
|
|
8
9
|
} from './controls';
|
|
9
|
-
import { maskLabel, MAX_RAW_LABEL_LENGTH } from './labelMasking';
|
|
10
|
+
import { maskLabel, maskNumbers, MAX_RAW_LABEL_LENGTH } from './labelMasking';
|
|
11
|
+
import {
|
|
12
|
+
decodeFully,
|
|
13
|
+
hidesNextSegment,
|
|
14
|
+
isTokenSegment,
|
|
15
|
+
LEFTOVER_ESCAPE,
|
|
16
|
+
routeSegment,
|
|
17
|
+
} from '../pathMasking';
|
|
10
18
|
|
|
11
19
|
// Describes a clicked button for auto button tracking: its masked name, icon,
|
|
12
20
|
// hand-written identifiers and kind. Never reads form field values.
|
|
@@ -25,6 +33,7 @@ export type ButtonLabelSource =
|
|
|
25
33
|
| 'alt'
|
|
26
34
|
| 'title'
|
|
27
35
|
| 'value'
|
|
36
|
+
| 'heading'
|
|
28
37
|
| 'none';
|
|
29
38
|
|
|
30
39
|
export interface IButtonClick {
|
|
@@ -37,12 +46,16 @@ export interface IButtonClick {
|
|
|
37
46
|
testId?: string;
|
|
38
47
|
controls?: string;
|
|
39
48
|
form?: string;
|
|
49
|
+
group?: string;
|
|
40
50
|
element: ButtonElementKind;
|
|
41
51
|
type: string;
|
|
42
52
|
linkType?: LinkType;
|
|
43
53
|
linkDomain?: string;
|
|
54
|
+
linkPath?: string;
|
|
44
55
|
fileExtension?: string;
|
|
45
56
|
checked?: boolean;
|
|
57
|
+
// A pick among options or tabs, which waits for the batch interval.
|
|
58
|
+
pick?: true;
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
function elementKind(control: Element): ButtonElementKind {
|
|
@@ -53,7 +66,13 @@ function elementKind(control: Element): ButtonElementKind {
|
|
|
53
66
|
if (tag === 'button' || tag === 'input') {
|
|
54
67
|
return tag;
|
|
55
68
|
}
|
|
56
|
-
|
|
69
|
+
// A link run by script reads as a link, as an anchor does, so whoever reads
|
|
70
|
+
// the click treats it the same.
|
|
71
|
+
return control.matches(
|
|
72
|
+
'a[href]:not([role="button"]), [role~="link"]:not([role~="button"])'
|
|
73
|
+
)
|
|
74
|
+
? 'link'
|
|
75
|
+
: 'role';
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
const NOT_RENDERED =
|
|
@@ -253,7 +272,7 @@ function accessibleName(
|
|
|
253
272
|
} else if (readable && isShown(control)) {
|
|
254
273
|
const text = renderedText(control);
|
|
255
274
|
if (text === undefined) {
|
|
256
|
-
return { source: 'none' };
|
|
275
|
+
return headingName(control) || { source: 'none' };
|
|
257
276
|
}
|
|
258
277
|
if (text) {
|
|
259
278
|
return { raw: text, source: 'text' };
|
|
@@ -263,6 +282,12 @@ function accessibleName(
|
|
|
263
282
|
return { raw: alt, source: 'alt' };
|
|
264
283
|
}
|
|
265
284
|
}
|
|
285
|
+
if (!(control instanceof HTMLInputElement) && isShown(control)) {
|
|
286
|
+
const heading = headingName(control);
|
|
287
|
+
if (heading) {
|
|
288
|
+
return heading;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
266
291
|
const title = nonBlank(control.getAttribute('title'));
|
|
267
292
|
if (title) {
|
|
268
293
|
return { raw: title, source: 'title' };
|
|
@@ -270,6 +295,33 @@ function accessibleName(
|
|
|
270
295
|
return { source: 'none' };
|
|
271
296
|
}
|
|
272
297
|
|
|
298
|
+
// A card whose text runs too long to keep, or has too many parts to read, is
|
|
299
|
+
// named by its first heading, as a product or menu card usually is. Not a card
|
|
300
|
+
// holding a form field, nor one that takes in slotted content or holds a
|
|
301
|
+
// shadow root, whose fields the form field check can't see.
|
|
302
|
+
function headingName(
|
|
303
|
+
control: Element
|
|
304
|
+
): { raw: string; source: ButtonLabelSource } | undefined {
|
|
305
|
+
if (
|
|
306
|
+
isUnsafeTextSource(control) ||
|
|
307
|
+
control.shadowRoot ||
|
|
308
|
+
control.querySelector('slot') ||
|
|
309
|
+
// A custom element can hide fields in a closed shadow root, which
|
|
310
|
+
// nothing outside it can see.
|
|
311
|
+
control.tagName.indexOf('-') !== -1 ||
|
|
312
|
+
Array.from(control.querySelectorAll('*')).some(
|
|
313
|
+
element =>
|
|
314
|
+
!!element.shadowRoot || element.tagName.indexOf('-') !== -1
|
|
315
|
+
)
|
|
316
|
+
) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
const text = textOf(
|
|
320
|
+
control.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]')
|
|
321
|
+
);
|
|
322
|
+
return text ? { raw: text, source: 'heading' } : undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
273
325
|
const ICON_CLASS = /^(?:fa|fas|far|fal|icon|bi|glyphicon|ti|mdi|ri|la|lucide|ion|feather|ph)-([a-z][a-z-]{1,31})$/;
|
|
274
326
|
// Size, style and animation classes that share the icon prefixes.
|
|
275
327
|
const ICON_MODIFIER = /^(?:solid|regular|light|thin|duotone|brands|sharp|fw|lg|xs|sm|xl|spin|pulse|border|inverse|stack|flip(?:-[a-z]+)?|rotate(?:-[a-z]+)?|pull-[a-z]+)$/;
|
|
@@ -331,6 +383,13 @@ function stableIdentifier(value: string | null): string | undefined {
|
|
|
331
383
|
: undefined;
|
|
332
384
|
}
|
|
333
385
|
|
|
386
|
+
// A picker's input name, which names its question. Sites often end it with the
|
|
387
|
+
// id of the product it belongs to (`color-swatches-48213907`); without that
|
|
388
|
+
// number it still names the question.
|
|
389
|
+
function questionName(value: string | null): string | undefined {
|
|
390
|
+
return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
|
|
391
|
+
}
|
|
392
|
+
|
|
334
393
|
const TEST_ID_ATTRIBUTES = [
|
|
335
394
|
'data-testid',
|
|
336
395
|
'data-test-id',
|
|
@@ -378,24 +437,79 @@ function formIdentifier(control: Element): string | undefined {
|
|
|
378
437
|
|
|
379
438
|
// The browser's normalised type: a button defaults to 'submit' and an unknown
|
|
380
439
|
// input type reads as 'text'. A native button or input reports its own type, a
|
|
381
|
-
// wallet-named one included
|
|
382
|
-
//
|
|
440
|
+
// wallet-named one included. A role control that isn't a button reads as its
|
|
441
|
+
// role: 'radio', 'option', 'tab', 'link' and so on. Any other control, such as
|
|
442
|
+
// a role button or a vendor's wallet element, reads as 'button'.
|
|
443
|
+
const CONTROL_ROLES = [
|
|
444
|
+
'radio',
|
|
445
|
+
'menuitemradio',
|
|
446
|
+
'checkbox',
|
|
447
|
+
'menuitemcheckbox',
|
|
448
|
+
'switch',
|
|
449
|
+
'option',
|
|
450
|
+
'tab',
|
|
451
|
+
'link',
|
|
452
|
+
];
|
|
453
|
+
|
|
383
454
|
function controlType(control: Element): string {
|
|
384
455
|
const tag = control.tagName.toLowerCase();
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
456
|
+
if (tag === 'button' || tag === 'input') {
|
|
457
|
+
return (control as HTMLButtonElement | HTMLInputElement).type;
|
|
458
|
+
}
|
|
459
|
+
if (control.matches('[role~="button"]')) {
|
|
460
|
+
return 'button';
|
|
461
|
+
}
|
|
462
|
+
const roles = (control.getAttribute('role') || '').trim().split(/\s+/);
|
|
463
|
+
for (const role of roles) {
|
|
464
|
+
if (CONTROL_ROLES.indexOf(role) !== -1) {
|
|
465
|
+
return role;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return 'button';
|
|
388
469
|
}
|
|
389
470
|
|
|
390
471
|
interface ILinkDetails {
|
|
391
472
|
type: LinkType;
|
|
392
473
|
domain?: string;
|
|
474
|
+
path?: string;
|
|
393
475
|
fileExtension?: string;
|
|
394
476
|
}
|
|
395
477
|
|
|
478
|
+
// The page a link to another page of the site opens: its decoded path alone,
|
|
479
|
+
// since an id in a path can point at one shopper. A segment holding a token,
|
|
480
|
+
// or following a route word that names a person or carries a code, goes as
|
|
481
|
+
// `*`, and numbers in any script are masked as in labels. Dropped when it
|
|
482
|
+
// holds an @, `;` or `=` (session ids and matrix parameters), is too long to
|
|
483
|
+
// be a route, or the browser can't mask every script's digits.
|
|
484
|
+
function linkPath(link: HTMLAnchorElement): string | undefined {
|
|
485
|
+
// Each segment decodes on its own, so a literal `%` in one never stops the
|
|
486
|
+
// next from decoding.
|
|
487
|
+
const decoded = link.pathname.split('/').map(decodeFully);
|
|
488
|
+
if (decoded.some(segment => segment === undefined)) {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
const path = decoded.join('/');
|
|
492
|
+
if (
|
|
493
|
+
path.length > MAX_RAW_LABEL_LENGTH ||
|
|
494
|
+
/[@;=]/.test(path) ||
|
|
495
|
+
LEFTOVER_ESCAPE.test(path)
|
|
496
|
+
) {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
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
|
+
);
|
|
506
|
+
return maskNumbers(masked.join('/'));
|
|
507
|
+
}
|
|
508
|
+
|
|
396
509
|
// Reads the link's own resolved URL parts, never its full href: only the hostname of
|
|
397
|
-
// a link that leaves the site
|
|
398
|
-
// mailto:, tel: and other schemes give
|
|
510
|
+
// a link that leaves the site, the path of one to another page of the site and the
|
|
511
|
+
// extension of a file it downloads travel. mailto:, tel: and other schemes give
|
|
512
|
+
// undefined.
|
|
399
513
|
function describeLink(link: Element): ILinkDetails | undefined {
|
|
400
514
|
const href = (link.getAttribute('href') || '').trim();
|
|
401
515
|
if (!href || href.charAt(0) === '#') {
|
|
@@ -429,7 +543,7 @@ function describeLink(link: Element): ILinkDetails | undefined {
|
|
|
429
543
|
return link.pathname === window.location.pathname &&
|
|
430
544
|
link.search === window.location.search
|
|
431
545
|
? { type: 'same_page' }
|
|
432
|
-
: { type: 'internal' };
|
|
546
|
+
: { type: 'internal', path: linkPath(link) };
|
|
433
547
|
}
|
|
434
548
|
|
|
435
549
|
// Whether a described click is on a link that leaves the page, logged as a link
|
|
@@ -442,7 +556,7 @@ export function isLinkClick(click: IButtonClick): boolean {
|
|
|
442
556
|
// fallbacks, so any of its tokens counts.
|
|
443
557
|
const CHOICE =
|
|
444
558
|
'input[type="checkbox"], input[type="radio"], [role~="checkbox"], [role~="radio"], ' +
|
|
445
|
-
'[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"]';
|
|
559
|
+
'[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"], [role~="option"]';
|
|
446
560
|
const RADIO_ROLE = '[role~="radio"], [role~="menuitemradio"]';
|
|
447
561
|
|
|
448
562
|
function boxOf(element: Element): Element | null {
|
|
@@ -512,43 +626,231 @@ export function describeButtonClick(
|
|
|
512
626
|
// radio's id, test id and the panel it opens name the option picked, so
|
|
513
627
|
// only the name of its input, the question, goes with it. A wrapper's own
|
|
514
628
|
// name, or a role radio's, can name the option instead.
|
|
515
|
-
|
|
629
|
+
// An option of a list box, like a radio, names the answer picked.
|
|
630
|
+
const radio =
|
|
631
|
+
isRadio(choice) || (!!choice && choice.matches('[role~="option"]'));
|
|
516
632
|
const named = !radio
|
|
517
633
|
? control
|
|
518
|
-
: choice instanceof HTMLInputElement
|
|
634
|
+
: choice instanceof HTMLInputElement &&
|
|
635
|
+
!choice.matches('[role~="option"]')
|
|
519
636
|
? choice
|
|
520
637
|
: null;
|
|
521
|
-
|
|
638
|
+
// A link run by script has no address to say where it goes, so it goes
|
|
639
|
+
// without its text, as a plain link to another page of the site does: the
|
|
640
|
+
// text can name the shopper ("Hi, Jane").
|
|
641
|
+
// A native button keeps its name whatever its role, as it always has.
|
|
642
|
+
const textless =
|
|
643
|
+
!!choice ||
|
|
644
|
+
(!link &&
|
|
645
|
+
element !== 'wallet' &&
|
|
646
|
+
control.matches('[role~="link"]') &&
|
|
647
|
+
!control.matches(CONTROL_SELECTOR));
|
|
648
|
+
const name = textless ? undefined : accessibleName(control);
|
|
522
649
|
const label = name ? maskLabel(name.raw) : undefined;
|
|
523
650
|
return {
|
|
524
651
|
label,
|
|
525
652
|
labelSource: label ? name.source : 'none',
|
|
526
653
|
// Kept on its own as well: it often says more than the visible text,
|
|
527
654
|
// such as which item a remove button removes.
|
|
528
|
-
ariaLabel:
|
|
655
|
+
ariaLabel: textless
|
|
529
656
|
? undefined
|
|
530
657
|
: maskLabel(control.getAttribute('aria-label')),
|
|
531
658
|
icon: choice ? undefined : iconHint(control),
|
|
532
659
|
elementId: radio
|
|
533
660
|
? undefined
|
|
534
661
|
: stableIdentifier(control.getAttribute('id')),
|
|
535
|
-
elementName:
|
|
662
|
+
elementName: (choice ? questionName : stableIdentifier)(
|
|
663
|
+
named && named.getAttribute('name')
|
|
664
|
+
),
|
|
536
665
|
testId: radio
|
|
537
666
|
? undefined
|
|
538
667
|
: firstIdentifier(control, TEST_ID_ATTRIBUTES),
|
|
539
668
|
controls: radio ? undefined : controlsIdentifier(control),
|
|
540
669
|
form: formIdentifier(control),
|
|
670
|
+
group: choice ? groupName(choice) : undefined,
|
|
541
671
|
element,
|
|
542
672
|
type: controlType(control),
|
|
543
673
|
linkType: link ? link.type : undefined,
|
|
544
674
|
linkDomain: link ? link.domain : undefined,
|
|
675
|
+
linkPath: link ? link.path : undefined,
|
|
545
676
|
fileExtension: link ? link.fileExtension : undefined,
|
|
546
677
|
// The box as the click left it: the browser toggles it before the click
|
|
547
678
|
// reaches listeners, and puts it back if the click is cancelled.
|
|
548
679
|
checked: box ? box.checked : undefined,
|
|
680
|
+
pick: choice || control.matches('[role~="tab"]') ? true : undefined,
|
|
549
681
|
};
|
|
550
682
|
}
|
|
551
683
|
|
|
684
|
+
// The question a picker answers, from the group around it: the group's
|
|
685
|
+
// aria-label, aria-labelledby or legend, masked like a label and cut at its
|
|
686
|
+
// first colon, since a legend can repeat the pick ("Color: Navy"). Never the
|
|
687
|
+
// option's own text, which is the shopper's answer.
|
|
688
|
+
const NAMED_GROUP = '[role="radiogroup"], [role="listbox"], [role="group"], fieldset';
|
|
689
|
+
// A control such as the button that opens a list box shows the option picked.
|
|
690
|
+
const NOT_A_QUESTION =
|
|
691
|
+
CHOICE +
|
|
692
|
+
', ' +
|
|
693
|
+
CONTROL_SELECTOR +
|
|
694
|
+
', a[href], input, select, textarea, slot, [aria-haspopup], [role~="button"], ' +
|
|
695
|
+
'[role~="link"], [role~="tab"], [role~="combobox"]';
|
|
696
|
+
|
|
697
|
+
const OPTION_KINDS = [
|
|
698
|
+
'radio',
|
|
699
|
+
'menuitemradio',
|
|
700
|
+
'checkbox',
|
|
701
|
+
'menuitemcheckbox',
|
|
702
|
+
'switch',
|
|
703
|
+
'option',
|
|
704
|
+
];
|
|
705
|
+
|
|
706
|
+
function optionKind(option: Element): string | undefined {
|
|
707
|
+
if (option instanceof HTMLInputElement) {
|
|
708
|
+
return option.type;
|
|
709
|
+
}
|
|
710
|
+
const roles = (option.getAttribute('role') || '').trim().split(/\s+/);
|
|
711
|
+
return roles.filter(role => OPTION_KINDS.indexOf(role) !== -1)[0];
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Whether two options answer the same question. A native box shares its
|
|
715
|
+
// question only with a box of the same name in the same form, as the browser
|
|
716
|
+
// groups radios, so separate shipping and billing radios ask two.
|
|
717
|
+
function sameQuestion(option: Element, choice: Element): boolean {
|
|
718
|
+
if (!(choice instanceof HTMLInputElement)) {
|
|
719
|
+
// A role option answers only within its own radio group or list box.
|
|
720
|
+
const container = '[role="radiogroup"], [role="listbox"]';
|
|
721
|
+
return (
|
|
722
|
+
!(option instanceof HTMLInputElement) &&
|
|
723
|
+
optionKind(option) === optionKind(choice) &&
|
|
724
|
+
option.closest(container) === choice.closest(container)
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
const name = choice.getAttribute('name');
|
|
728
|
+
return (
|
|
729
|
+
option instanceof HTMLInputElement &&
|
|
730
|
+
option.type === choice.type &&
|
|
731
|
+
!!name &&
|
|
732
|
+
option.getAttribute('name') === name &&
|
|
733
|
+
option.form === choice.form
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Whether a group offers another answer to the question picked: a shown option
|
|
738
|
+
// to the same question that isn't part of the one picked. A card around one
|
|
739
|
+
// address radio and a "make default" checkbox, or around a role radio and the
|
|
740
|
+
// hidden input behind it, offers none.
|
|
741
|
+
function holdsOptions(group: Element, choice: Element): boolean {
|
|
742
|
+
const options = group.querySelectorAll(CHOICE);
|
|
743
|
+
for (let i = 0; i < options.length; i++) {
|
|
744
|
+
const option = options[i];
|
|
745
|
+
if (
|
|
746
|
+
!option.contains(choice) &&
|
|
747
|
+
!choice.contains(option) &&
|
|
748
|
+
sameQuestion(option, choice) &&
|
|
749
|
+
isShown(option, group) &&
|
|
750
|
+
isVisible(option) &&
|
|
751
|
+
// 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
|
+
)
|
|
758
|
+
) {
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// The elements an option is named by: those its aria-labelledby points at and
|
|
766
|
+
// a native box's labels.
|
|
767
|
+
function optionLabels(option: Element, root: Document | ShadowRoot): Element[] {
|
|
768
|
+
const labels: Element[] =
|
|
769
|
+
option instanceof HTMLInputElement && option.labels
|
|
770
|
+
? Array.from(option.labels)
|
|
771
|
+
: [];
|
|
772
|
+
(option.getAttribute('aria-labelledby') || '')
|
|
773
|
+
.trim()
|
|
774
|
+
.split(/\s+/)
|
|
775
|
+
.forEach(ref => {
|
|
776
|
+
const target =
|
|
777
|
+
ref && root.getElementById ? root.getElementById(ref) : null;
|
|
778
|
+
if (target) {
|
|
779
|
+
labels.push(target);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
return labels;
|
|
783
|
+
}
|
|
784
|
+
|
|
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 {
|
|
791
|
+
if (!(choice instanceof HTMLInputElement)) {
|
|
792
|
+
const container = choice.matches('[role~="option"]')
|
|
793
|
+
? choice.closest('[role="listbox"]')
|
|
794
|
+
: choice.matches('[role~="radio"]')
|
|
795
|
+
? choice.closest('[role="radiogroup"]')
|
|
796
|
+
: null;
|
|
797
|
+
return container && holdsOptions(container, choice) ? container : null;
|
|
798
|
+
}
|
|
799
|
+
let group = choice.closest(NAMED_GROUP);
|
|
800
|
+
while (group && !holdsOptions(group, choice)) {
|
|
801
|
+
group = group.parentElement
|
|
802
|
+
? group.parentElement.closest(NAMED_GROUP)
|
|
803
|
+
: null;
|
|
804
|
+
}
|
|
805
|
+
return group;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function groupName(choice: Element): string | undefined {
|
|
809
|
+
const group = questionGroup(choice);
|
|
810
|
+
if (!group) {
|
|
811
|
+
return undefined;
|
|
812
|
+
}
|
|
813
|
+
let raw = nonBlank(group.getAttribute('aria-label'));
|
|
814
|
+
if (!raw) {
|
|
815
|
+
const ids = group.getAttribute('aria-labelledby');
|
|
816
|
+
const legend =
|
|
817
|
+
group.tagName === 'FIELDSET'
|
|
818
|
+
? Array.from(group.children).filter(
|
|
819
|
+
child => child.tagName === 'LEGEND'
|
|
820
|
+
)[0]
|
|
821
|
+
: undefined;
|
|
822
|
+
const root = (isFunction(group.getRootNode)
|
|
823
|
+
? group.getRootNode()
|
|
824
|
+
: document) as Document | ShadowRoot;
|
|
825
|
+
const source = ids
|
|
826
|
+
? root.getElementById
|
|
827
|
+
? root.getElementById(ids.trim().split(/\s+/)[0])
|
|
828
|
+
: null
|
|
829
|
+
: legend || null;
|
|
830
|
+
// A label that is, holds or sits in an option or a control, or in an
|
|
831
|
+
// option's label, would name the answer.
|
|
832
|
+
const holder = source ? source.closest('label') : null;
|
|
833
|
+
// Nor one that holds, or sits in, what an option of the group names
|
|
834
|
+
// itself by.
|
|
835
|
+
const labelsOption =
|
|
836
|
+
!!source &&
|
|
837
|
+
Array.from(group.querySelectorAll(CHOICE)).some(option =>
|
|
838
|
+
optionLabels(option, root).some(
|
|
839
|
+
label => source.contains(label) || label.contains(source)
|
|
840
|
+
)
|
|
841
|
+
);
|
|
842
|
+
raw =
|
|
843
|
+
source &&
|
|
844
|
+
!labelsOption &&
|
|
845
|
+
!hasComposedAncestor(source, NOT_A_QUESTION) &&
|
|
846
|
+
!source.querySelector(NOT_A_QUESTION) &&
|
|
847
|
+
!(holder && (holder.control || holder.querySelector(CHOICE)))
|
|
848
|
+
? textOf(source)
|
|
849
|
+
: undefined;
|
|
850
|
+
}
|
|
851
|
+
return raw ? maskLabel(raw.split(':')[0]) : undefined;
|
|
852
|
+
}
|
|
853
|
+
|
|
552
854
|
// A question about payment, read from the names its author gave it, so
|
|
553
855
|
// "checkout[payment_gateway]", "paymentMethod" and "tender_type" all count and
|
|
554
856
|
// "payee" does not. A radio's input name is its question; its group or
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
classifyPaymentFrame,
|
|
17
17
|
PaymentFrameFamily,
|
|
18
18
|
} from './checkoutIntent';
|
|
19
|
-
import { findControl, isExcludedPath } from './controls';
|
|
19
|
+
import { findControl, findRoleControl, isExcludedPath } from './controls';
|
|
20
20
|
|
|
21
21
|
// State lives on `window`, like APV, so a re-executed bundle never doubles the
|
|
22
22
|
// listeners. Inspect `window.__mpBtn__` in a console to see if one is live.
|
|
@@ -26,7 +26,9 @@ const THROTTLE_MS = 2000;
|
|
|
26
26
|
const SUBMIT_AFTER_COMMIT_CLICK_MS = 1000;
|
|
27
27
|
// Apple Pay must begin inside the tap's own handler, so the tap is this recent.
|
|
28
28
|
const APPLE_PAY_AFTER_CLICK_MS = 1000;
|
|
29
|
-
|
|
29
|
+
// How far up from the clicked element a control is looked for. Checkout intent
|
|
30
|
+
// keeps findControl's own 12; logging looks further, past deep product cards.
|
|
31
|
+
const PATH_LIMIT = 20;
|
|
30
32
|
// A ceiling, not a dedupe: a held Enter key or browser automation fires a
|
|
31
33
|
// trusted click per repeat, and each one is forwarded to every kit.
|
|
32
34
|
const PAGE_EVENT_LIMIT = 100;
|
|
@@ -411,12 +413,18 @@ export class ButtonTracker {
|
|
|
411
413
|
: [];
|
|
412
414
|
const excluded = isExcludedPath(path);
|
|
413
415
|
const control = excluded ? undefined : findControl(path, true);
|
|
414
|
-
|
|
416
|
+
const logged =
|
|
417
|
+
control ||
|
|
418
|
+
(excluded
|
|
419
|
+
? undefined
|
|
420
|
+
: findControl(path, true, PATH_LIMIT) ||
|
|
421
|
+
findRoleControl(path, PATH_LIMIT));
|
|
422
|
+
if (logged) {
|
|
415
423
|
// With links alone, only an anchor can be logged.
|
|
416
424
|
click =
|
|
417
425
|
logsEvents &&
|
|
418
|
-
(this.logButtons ||
|
|
419
|
-
? describeButtonClick(
|
|
426
|
+
(this.logButtons || logged.matches('a[href]'))
|
|
427
|
+
? describeButtonClick(logged, path)
|
|
420
428
|
: undefined;
|
|
421
429
|
if (click) {
|
|
422
430
|
asLink = this.logLinks && isLinkClick(click);
|
|
@@ -428,15 +436,17 @@ export class ButtonTracker {
|
|
|
428
436
|
!asLink &&
|
|
429
437
|
!(
|
|
430
438
|
click.linkType === 'internal' &&
|
|
431
|
-
|
|
439
|
+
logged.matches('a[href]:not([role="button"])')
|
|
432
440
|
);
|
|
433
441
|
if (!asLink && !asButton) {
|
|
434
442
|
click = undefined;
|
|
435
443
|
}
|
|
436
444
|
}
|
|
437
445
|
if (click && click.checked !== undefined) {
|
|
438
|
-
box =
|
|
446
|
+
box = logged as HTMLInputElement;
|
|
439
447
|
}
|
|
448
|
+
}
|
|
449
|
+
if (control) {
|
|
440
450
|
// Checkout intent is only worked out when the kit can use it.
|
|
441
451
|
const intent = signalsKit
|
|
442
452
|
? classifyIntent(control, pathname)
|
|
@@ -546,11 +556,12 @@ export class ButtonTracker {
|
|
|
546
556
|
);
|
|
547
557
|
// Only a click waiting for the batch interval is sent early: a held
|
|
548
558
|
// one goes out when released, and with batching off it already has.
|
|
549
|
-
// A checkbox
|
|
550
|
-
// doesn't leave the page the way a confirm
|
|
559
|
+
// A pick, such as a checkbox, a radio or a tab, waits for the batch,
|
|
560
|
+
// since choosing an option doesn't leave the page the way a confirm
|
|
561
|
+
// click can.
|
|
551
562
|
if (
|
|
552
563
|
!this.leaving &&
|
|
553
|
-
click.
|
|
564
|
+
!click.pick &&
|
|
554
565
|
this.queuedForUpload() > queued
|
|
555
566
|
) {
|
|
556
567
|
this.uploadNow();
|