@mparticle/web-sdk 3.23.0 → 3.25.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 -1688
- package/dist/mparticle.esm.js +1 -1688
- package/dist/mparticle.js +1015 -253
- package/dist/types/src/buttonTracking/buttonClickEvent.d.ts +7 -0
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +10 -3
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +8 -0
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +8 -3
- package/dist/types/src/buttonTracking/controls.d.ts +8 -4
- package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
- package/dist/types/src/configAPIClient.d.ts +1 -0
- package/dist/types/src/constants.d.ts +1 -0
- package/dist/types/src/cookieConsentManager.d.ts +17 -3
- package/dist/types/src/forwarder-utils.d.ts +3 -0
- package/dist/types/src/pageViewTracker.d.ts +19 -3
- package/dist/types/src/roktLauncherOptions.d.ts +1 -0
- package/dist/types/src/roktManager.d.ts +13 -2
- package/dist/types/src/sdkRuntimeModels.d.ts +1 -0
- package/dist/types/src/store.d.ts +3 -0
- package/package.json +21 -14
- package/src/buttonTracking/buttonClickEvent.ts +126 -8
- package/src/buttonTracking/buttonDescription.ts +336 -53
- package/src/buttonTracking/buttonTracker.ts +231 -19
- package/src/buttonTracking/checkoutIntent.ts +117 -23
- package/src/buttonTracking/controls.ts +169 -55
- package/src/buttonTracking/labelMasking.ts +4 -1
- package/src/configAPIClient.ts +1 -0
- package/src/constants.ts +1 -0
- package/src/cookieConsentManager.ts +23 -3
- package/src/cookieSyncManager.ts +11 -3
- package/src/events.ts +9 -3
- package/src/forwarder-utils.ts +38 -0
- package/src/forwarders.ts +2 -28
- package/src/mp-instance.ts +41 -7
- package/src/pageUrl.ts +43 -2
- package/src/pageViewTracker.ts +193 -111
- package/src/roktLauncherOptions.ts +1 -0
- package/src/roktManager.ts +66 -11
- package/src/sdkRuntimeModels.ts +1 -0
- package/src/store.ts +17 -0
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { isFunction } from '../utils';
|
|
2
2
|
import {
|
|
3
3
|
hasComposedAncestor,
|
|
4
|
-
isUnsafeLabelSource,
|
|
5
4
|
isUnsafeTextSource,
|
|
6
5
|
isWalletElement,
|
|
6
|
+
nameWords,
|
|
7
|
+
someComposedAncestor,
|
|
7
8
|
} from './controls';
|
|
8
|
-
import { maskLabel } from './labelMasking';
|
|
9
|
+
import { maskLabel, MAX_RAW_LABEL_LENGTH } from './labelMasking';
|
|
9
10
|
|
|
10
11
|
// Describes a clicked button for auto button tracking: its masked name, icon,
|
|
11
12
|
// hand-written identifiers and kind. Never reads form field values.
|
|
12
13
|
export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet' | 'link';
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
|
|
15
|
+
// A same_page link acts as a button. The others leave the page: to another page
|
|
16
|
+
// of the site, another site or a file.
|
|
17
|
+
export type LinkType = 'same_page' | 'internal' | 'outbound' | 'download';
|
|
16
18
|
|
|
17
19
|
const DOWNLOAD_EXTENSIONS = /\.(pdf|zip|csv|txt|ics|docx?|xlsx?|pptx?|dmg|exe|apk|mp3|mp4)$/i;
|
|
18
20
|
|
|
@@ -20,7 +22,6 @@ export type ButtonLabelSource =
|
|
|
20
22
|
| 'labelledby'
|
|
21
23
|
| 'aria'
|
|
22
24
|
| 'text'
|
|
23
|
-
| 'label'
|
|
24
25
|
| 'alt'
|
|
25
26
|
| 'title'
|
|
26
27
|
| 'value'
|
|
@@ -55,21 +56,155 @@ function elementKind(control: Element): ButtonElementKind {
|
|
|
55
56
|
return control.matches('a[href]:not([role="button"])') ? 'link' : 'role';
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
const NOT_RENDERED =
|
|
60
|
+
'script, style, template, [hidden], [aria-hidden="true"], [inert]';
|
|
61
|
+
|
|
62
|
+
// The most nodes read for one label source, and images looked at for one
|
|
63
|
+
// button. Each costs a style lookup in the click handler.
|
|
64
|
+
const MAX_READS = 64;
|
|
65
|
+
|
|
66
|
+
function assignedNodes(element: Element): Node[] {
|
|
67
|
+
const slot = element as HTMLSlotElement;
|
|
68
|
+
return isFunction(slot.assignedNodes) ? slot.assignedNodes() : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Left out of what is drawn by its parent: a light child of an open shadow host
|
|
72
|
+
// that no slot takes, or a slot's own child while nodes are assigned to the
|
|
73
|
+
// slot. A closed shadow root can't be seen, so its host's children count.
|
|
74
|
+
function isUnslotted(node: Element | Text): boolean {
|
|
75
|
+
const parent = node.parentElement;
|
|
76
|
+
if (!parent) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return parent.shadowRoot
|
|
80
|
+
? !node.assignedSlot
|
|
81
|
+
: assignedNodes(parent).length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Hides the element and all it holds, whatever their own style says.
|
|
85
|
+
// Screen-reader-only text is clipped rather than hidden, so it is not. An SVG
|
|
86
|
+
// title is never drawn but names its icon, so it is not either.
|
|
87
|
+
function hidesSubtree(element: Element): boolean {
|
|
88
|
+
if (element.matches(NOT_RENDERED) || isUnslotted(element)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
!(element instanceof SVGTitleElement) &&
|
|
93
|
+
window.getComputedStyle(element).display === 'none'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Visibility is inherited and a child can set it back to visible, so only the
|
|
98
|
+
// element's own counts.
|
|
99
|
+
function isVisible(element: Element): boolean {
|
|
100
|
+
const visibility = window.getComputedStyle(element).visibility;
|
|
101
|
+
return visibility !== 'hidden' && visibility !== 'collapse';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isShown(element: Element, until?: Element): boolean {
|
|
105
|
+
return !someComposedAncestor(element, hidesSubtree, until);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A shadow host shows a light child only through a slot, and only while
|
|
109
|
+
// nothing from that slot up to the host hides it.
|
|
110
|
+
function isShownThroughSlot(node: Element | Text, host: Element): boolean {
|
|
111
|
+
const slot = node.assignedSlot;
|
|
112
|
+
return !!slot && isShown(slot, host);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Whitespace hides nothing, so a node holding only whitespace is taken as it
|
|
116
|
+
// is, without a style lookup: an icon's paths cost nothing. A slot's text can
|
|
117
|
+
// sit in the nodes assigned to it, so a slot is never blank.
|
|
118
|
+
function isBlank(node: Element | Text): boolean {
|
|
119
|
+
return (
|
|
120
|
+
!/\S/.test(node.textContent || '') &&
|
|
121
|
+
!(
|
|
122
|
+
node instanceof Element &&
|
|
123
|
+
(node.matches('slot') || !!node.querySelector('slot'))
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The text a sighted user or a screen reader gets from an element, whitespace
|
|
129
|
+
// collapsed. Undefined when that runs past what maskLabel keeps, takes more
|
|
130
|
+
// than MAX_READS nodes to read, or takes in a form field through a slot, so a
|
|
131
|
+
// label is never cut short at a node boundary.
|
|
132
|
+
function renderedText(root: Element): string | undefined {
|
|
133
|
+
let text = '';
|
|
134
|
+
let reads = 0;
|
|
135
|
+
const append = (value: string) => {
|
|
136
|
+
text = (text + value).replace(/\s+/g, ' ');
|
|
137
|
+
};
|
|
138
|
+
const read = (element: Element): boolean => {
|
|
139
|
+
if (hidesSubtree(element)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
const assigned = assignedNodes(element);
|
|
143
|
+
const nodes = assigned.length ? assigned : element.childNodes;
|
|
144
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
145
|
+
const node = nodes[i] as Element | Text;
|
|
146
|
+
const isText = node.nodeType === Node.TEXT_NODE;
|
|
147
|
+
if (!isText && !(node instanceof Element)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (isBlank(node)) {
|
|
151
|
+
append(node.textContent);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (++reads > MAX_READS) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (element.shadowRoot && !isShownThroughSlot(node, element)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (isText) {
|
|
161
|
+
if (isVisible(node.assignedSlot || element)) {
|
|
162
|
+
append(node.nodeValue);
|
|
163
|
+
}
|
|
164
|
+
} else if (
|
|
165
|
+
// Assigned nodes sit outside the label source, where its own
|
|
166
|
+
// form field check never looked.
|
|
167
|
+
(assigned.length && isUnsafeTextSource(node as Element)) ||
|
|
168
|
+
!read(node as Element)
|
|
169
|
+
) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
if (text.trim().length > MAX_RAW_LABEL_LENGTH) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
};
|
|
178
|
+
return read(root) ? text.trim() : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// '' when the element can't name the button, undefined when its text can't be
|
|
182
|
+
// read in full.
|
|
183
|
+
function textOf(element: Element | null): string | undefined {
|
|
184
|
+
return element && !isUnsafeTextSource(element) && isShown(element)
|
|
185
|
+
? renderedText(element)
|
|
61
186
|
: '';
|
|
62
187
|
}
|
|
63
188
|
|
|
189
|
+
// The control itself is already known to be shown.
|
|
190
|
+
function shownImageAlt(control: Element): string | undefined {
|
|
191
|
+
const images = control.querySelectorAll('img[alt]');
|
|
192
|
+
for (let i = 0; i < images.length && i < MAX_READS; i++) {
|
|
193
|
+
if (isShown(images[i], control) && isVisible(images[i])) {
|
|
194
|
+
return nonBlank(images[i].getAttribute('alt'));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
64
200
|
function nonBlank(value: string | null): string | undefined {
|
|
65
201
|
return value && value.trim() ? value : undefined;
|
|
66
202
|
}
|
|
67
203
|
|
|
68
204
|
// The button's name as a screen reader would read it, simplified: the first of
|
|
69
|
-
// aria-labelledby, aria-label, own text (or an inner image's alt), title,
|
|
70
|
-
// inputs their value
|
|
71
|
-
//
|
|
72
|
-
// children, so a typed value is never read.
|
|
205
|
+
// aria-labelledby, aria-label, own text (or an inner image's alt), title, and
|
|
206
|
+
// for inputs their value. The text sources are skipped for controls that hold
|
|
207
|
+
// form fields or more than four children, so a typed value is never read.
|
|
73
208
|
function accessibleName(
|
|
74
209
|
control: Element
|
|
75
210
|
): { raw?: string; source: ButtonLabelSource } {
|
|
@@ -78,13 +213,15 @@ function accessibleName(
|
|
|
78
213
|
const root = (isFunction(control.getRootNode)
|
|
79
214
|
? control.getRootNode()
|
|
80
215
|
: document) as Document | ShadowRoot;
|
|
81
|
-
const
|
|
216
|
+
const parts = labelledBy
|
|
82
217
|
.split(/\s+/)
|
|
83
218
|
.map(id =>
|
|
84
219
|
textOf(root.getElementById ? root.getElementById(id) : null)
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
|
|
220
|
+
);
|
|
221
|
+
if (parts.indexOf(undefined) !== -1) {
|
|
222
|
+
return { source: 'none' };
|
|
223
|
+
}
|
|
224
|
+
const text = parts.filter(part => !!part).join(' ');
|
|
88
225
|
if (text) {
|
|
89
226
|
return { raw: text, source: 'labelledby' };
|
|
90
227
|
}
|
|
@@ -112,24 +249,16 @@ function accessibleName(
|
|
|
112
249
|
// What the browser shows on an input without a value.
|
|
113
250
|
return { raw: type, source: 'value' };
|
|
114
251
|
}
|
|
115
|
-
} else if (type === 'checkbox' || type === 'radio') {
|
|
116
|
-
// The label the browser ties to the box, never the box's value.
|
|
117
|
-
const label = control.labels ? control.labels[0] : undefined;
|
|
118
|
-
const text =
|
|
119
|
-
label && !isUnsafeLabelSource(label) && label.textContent
|
|
120
|
-
? label.textContent.trim()
|
|
121
|
-
: '';
|
|
122
|
-
if (text) {
|
|
123
|
-
return { raw: text, source: 'label' };
|
|
124
|
-
}
|
|
125
252
|
}
|
|
126
|
-
} else if (readable) {
|
|
127
|
-
const text =
|
|
253
|
+
} else if (readable && isShown(control)) {
|
|
254
|
+
const text = renderedText(control);
|
|
255
|
+
if (text === undefined) {
|
|
256
|
+
return { source: 'none' };
|
|
257
|
+
}
|
|
128
258
|
if (text) {
|
|
129
259
|
return { raw: text, source: 'text' };
|
|
130
260
|
}
|
|
131
|
-
const
|
|
132
|
-
const alt = image ? nonBlank(image.getAttribute('alt')) : undefined;
|
|
261
|
+
const alt = shownImageAlt(control);
|
|
133
262
|
if (alt) {
|
|
134
263
|
return { raw: alt, source: 'alt' };
|
|
135
264
|
}
|
|
@@ -248,9 +377,12 @@ function formIdentifier(control: Element): string | undefined {
|
|
|
248
377
|
}
|
|
249
378
|
|
|
250
379
|
// The browser's normalised type: a button defaults to 'submit' and an unknown
|
|
251
|
-
// input type reads as 'text'.
|
|
252
|
-
|
|
253
|
-
|
|
380
|
+
// input type reads as 'text'. A native button or input reports its own type, a
|
|
381
|
+
// wallet-named one included; any other control, such as a role button or a
|
|
382
|
+
// vendor's wallet element, reads as 'button'.
|
|
383
|
+
function controlType(control: Element): string {
|
|
384
|
+
const tag = control.tagName.toLowerCase();
|
|
385
|
+
return tag === 'button' || tag === 'input'
|
|
254
386
|
? (control as HTMLButtonElement | HTMLInputElement).type
|
|
255
387
|
: 'button';
|
|
256
388
|
}
|
|
@@ -262,8 +394,8 @@ interface ILinkDetails {
|
|
|
262
394
|
}
|
|
263
395
|
|
|
264
396
|
// Reads the link's own resolved URL parts, never its full href: only the hostname of
|
|
265
|
-
// a link that leaves the site and the extension of a file it downloads travel.
|
|
266
|
-
//
|
|
397
|
+
// a link that leaves the site and the extension of a file it downloads travel.
|
|
398
|
+
// mailto:, tel: and other schemes give undefined.
|
|
267
399
|
function describeLink(link: Element): ILinkDetails | undefined {
|
|
268
400
|
const href = (link.getAttribute('href') || '').trim();
|
|
269
401
|
if (!href || href.charAt(0) === '#') {
|
|
@@ -297,13 +429,60 @@ function describeLink(link: Element): ILinkDetails | undefined {
|
|
|
297
429
|
return link.pathname === window.location.pathname &&
|
|
298
430
|
link.search === window.location.search
|
|
299
431
|
? { type: 'same_page' }
|
|
300
|
-
:
|
|
432
|
+
: { type: 'internal' };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Whether a described click is on a link that leaves the page, logged as a link
|
|
436
|
+
// click rather than a button click.
|
|
437
|
+
export function isLinkClick(click: IButtonClick): boolean {
|
|
438
|
+
return !!click.linkType && click.linkType !== 'same_page';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Checkboxes and radios, and the roles that act as them. A role can list
|
|
442
|
+
// fallbacks, so any of its tokens counts.
|
|
443
|
+
const CHOICE =
|
|
444
|
+
'input[type="checkbox"], input[type="radio"], [role~="checkbox"], [role~="radio"], ' +
|
|
445
|
+
'[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"]';
|
|
446
|
+
const RADIO_ROLE = '[role~="radio"], [role~="menuitemradio"]';
|
|
447
|
+
|
|
448
|
+
function boxOf(element: Element): Element | null {
|
|
449
|
+
const box = element instanceof HTMLLabelElement ? element.control : element;
|
|
450
|
+
return box && box.matches(CHOICE) ? box : null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isRadio(choice: Element | null): boolean {
|
|
454
|
+
return choice instanceof HTMLInputElement
|
|
455
|
+
? choice.type === 'radio'
|
|
456
|
+
: !!choice && choice.matches(RADIO_ROLE);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// The box a click on this control picks. One the click passed through below
|
|
460
|
+
// the control comes first: querySelector can't reach a slotted box, a box in a
|
|
461
|
+
// shadow root or the box of a label inside the control, and a row can hold
|
|
462
|
+
// more than one box. Else the control itself, the box it labels, or the first
|
|
463
|
+
// one it wraps, as on an option row whose name repeats the option's text.
|
|
464
|
+
function choiceOf(
|
|
465
|
+
control: Element,
|
|
466
|
+
path: Array<EventTarget | null>
|
|
467
|
+
): Element | null {
|
|
468
|
+
for (const target of path) {
|
|
469
|
+
if (target === control) {
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
const box = target instanceof Element ? boxOf(target) : null;
|
|
473
|
+
if (box) {
|
|
474
|
+
return box;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return boxOf(control) || control.querySelector(CHOICE);
|
|
301
478
|
}
|
|
302
479
|
|
|
303
|
-
// Describes a clicked control for auto button tracking. A link
|
|
304
|
-
//
|
|
480
|
+
// Describes a clicked control for auto button and link tracking. A link carries its
|
|
481
|
+
// link type, and one to another scheme is skipped. A role="button" anchor carries one
|
|
482
|
+
// only when it leaves the page; otherwise it stays a plain button.
|
|
305
483
|
export function describeButtonClick(
|
|
306
|
-
control: Element
|
|
484
|
+
control: Element,
|
|
485
|
+
path: Array<EventTarget | null> = []
|
|
307
486
|
): IButtonClick | undefined {
|
|
308
487
|
let link: ILinkDetails | undefined;
|
|
309
488
|
if (control.matches('a[href]:not([role="button"])')) {
|
|
@@ -311,33 +490,137 @@ export function describeButtonClick(
|
|
|
311
490
|
if (!link) {
|
|
312
491
|
return undefined;
|
|
313
492
|
}
|
|
493
|
+
} else if (control.matches('a[href]')) {
|
|
494
|
+
link = describeLink(control);
|
|
495
|
+
if (link && link.type === 'same_page') {
|
|
496
|
+
link = undefined;
|
|
497
|
+
}
|
|
314
498
|
}
|
|
315
499
|
const element = elementKind(control);
|
|
316
|
-
const
|
|
317
|
-
|
|
500
|
+
const box =
|
|
501
|
+
control instanceof HTMLInputElement &&
|
|
502
|
+
(control.type === 'checkbox' || control.type === 'radio')
|
|
503
|
+
? control
|
|
504
|
+
: undefined;
|
|
505
|
+
// A box's text sent with its state is the shopper's answer, and the text
|
|
506
|
+
// can be a name, an address or a sensitive choice, so a box goes by its
|
|
507
|
+
// hand-written identifiers alone. So does a control acting as one or
|
|
508
|
+
// wrapping one, and a label, whose click the browser passes on to its box
|
|
509
|
+
// as a second event.
|
|
510
|
+
const choice = box || choiceOf(control, path);
|
|
511
|
+
// A checkbox's identifiers name its question, which its state answers. A
|
|
512
|
+
// radio's id, test id and the panel it opens name the option picked, so
|
|
513
|
+
// only the name of its input, the question, goes with it. A wrapper's own
|
|
514
|
+
// name, or a role radio's, can name the option instead.
|
|
515
|
+
const radio = isRadio(choice);
|
|
516
|
+
const named = !radio
|
|
517
|
+
? control
|
|
518
|
+
: choice instanceof HTMLInputElement
|
|
519
|
+
? choice
|
|
520
|
+
: null;
|
|
521
|
+
const name = choice ? undefined : accessibleName(control);
|
|
522
|
+
const label = name ? maskLabel(name.raw) : undefined;
|
|
318
523
|
return {
|
|
319
524
|
label,
|
|
320
525
|
labelSource: label ? name.source : 'none',
|
|
321
526
|
// Kept on its own as well: it often says more than the visible text,
|
|
322
527
|
// such as which item a remove button removes.
|
|
323
|
-
ariaLabel:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
528
|
+
ariaLabel: choice
|
|
529
|
+
? undefined
|
|
530
|
+
: maskLabel(control.getAttribute('aria-label')),
|
|
531
|
+
icon: choice ? undefined : iconHint(control),
|
|
532
|
+
elementId: radio
|
|
533
|
+
? undefined
|
|
534
|
+
: stableIdentifier(control.getAttribute('id')),
|
|
535
|
+
elementName: stableIdentifier(named && named.getAttribute('name')),
|
|
536
|
+
testId: radio
|
|
537
|
+
? undefined
|
|
538
|
+
: firstIdentifier(control, TEST_ID_ATTRIBUTES),
|
|
539
|
+
controls: radio ? undefined : controlsIdentifier(control),
|
|
329
540
|
form: formIdentifier(control),
|
|
330
541
|
element,
|
|
331
|
-
type: controlType(control
|
|
542
|
+
type: controlType(control),
|
|
332
543
|
linkType: link ? link.type : undefined,
|
|
333
544
|
linkDomain: link ? link.domain : undefined,
|
|
334
545
|
fileExtension: link ? link.fileExtension : undefined,
|
|
335
546
|
// The box as the click left it: the browser toggles it before the click
|
|
336
547
|
// reaches listeners, and puts it back if the click is cancelled.
|
|
337
|
-
checked:
|
|
338
|
-
control instanceof HTMLInputElement &&
|
|
339
|
-
(control.type === 'checkbox' || control.type === 'radio')
|
|
340
|
-
? control.checked
|
|
341
|
-
: undefined,
|
|
548
|
+
checked: box ? box.checked : undefined,
|
|
342
549
|
};
|
|
343
550
|
}
|
|
551
|
+
|
|
552
|
+
// A question about payment, read from the names its author gave it, so
|
|
553
|
+
// "checkout[payment_gateway]", "paymentMethod" and "tender_type" all count and
|
|
554
|
+
// "payee" does not. A radio's input name is its question; its group or
|
|
555
|
+
// fieldset speaks only for a radio with no name, as a role radio usually is.
|
|
556
|
+
const PAYMENT_QUESTION = /(?:^| )(?:pay|payment\w*|tenders?|gateways?)(?: |$)/;
|
|
557
|
+
const QUESTION_GROUP = '[role="radiogroup"], fieldset';
|
|
558
|
+
|
|
559
|
+
function isPaymentQuestion(radio: Element): boolean {
|
|
560
|
+
const name = radio.getAttribute('name');
|
|
561
|
+
const group = name ? null : radio.closest(QUESTION_GROUP);
|
|
562
|
+
const question = group
|
|
563
|
+
? [
|
|
564
|
+
group.getAttribute('id'),
|
|
565
|
+
group.getAttribute('name'),
|
|
566
|
+
group.getAttribute('aria-label'),
|
|
567
|
+
]
|
|
568
|
+
: [name];
|
|
569
|
+
return question.some(words => PAYMENT_QUESTION.test(nameWords(words)));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// What names a payment option: the radio's accessible name, the text and
|
|
573
|
+
// image of each label, and its hand-written ids, text masked like a label.
|
|
574
|
+
function optionNames(radio: Element): string[] {
|
|
575
|
+
const names: Array<string | undefined> = [
|
|
576
|
+
maskLabel(accessibleName(radio).raw),
|
|
577
|
+
];
|
|
578
|
+
const labels =
|
|
579
|
+
radio instanceof HTMLInputElement && radio.labels
|
|
580
|
+
? Array.from(radio.labels).slice(0, 2)
|
|
581
|
+
: [];
|
|
582
|
+
labels.forEach(label => {
|
|
583
|
+
if (isShown(label) && !isUnsafeTextSource(label, radio)) {
|
|
584
|
+
names.push(maskLabel(renderedText(label)));
|
|
585
|
+
names.push(maskLabel(shownImageAlt(label)));
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
names.push(
|
|
589
|
+
stableIdentifier(radio.getAttribute('id')),
|
|
590
|
+
firstIdentifier(radio, TEST_ID_ATTRIBUTES)
|
|
591
|
+
);
|
|
592
|
+
return names.filter(
|
|
593
|
+
(name, index) => !!name && names.indexOf(name) === index
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// The payment radio a click picks and the names of its option, for the Rokt
|
|
598
|
+
// kit alone to read a payment method from. A radio's option is the shopper's
|
|
599
|
+
// answer, so these names never go into the logged click.
|
|
600
|
+
export interface IPaymentChoice {
|
|
601
|
+
radio: Element;
|
|
602
|
+
names: string[];
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function describePaymentChoice(
|
|
606
|
+
control: Element,
|
|
607
|
+
path: Array<EventTarget | null> = []
|
|
608
|
+
): IPaymentChoice | undefined {
|
|
609
|
+
const choice = choiceOf(control, path);
|
|
610
|
+
if (
|
|
611
|
+
!isRadio(choice) ||
|
|
612
|
+
hasComposedAncestor(choice, '[data-mp-no-track]') ||
|
|
613
|
+
!isPaymentQuestion(choice)
|
|
614
|
+
) {
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
const names = optionNames(choice);
|
|
618
|
+
return names.length ? { radio: choice, names } : undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Read once the page has handled the click, which may have cancelled it.
|
|
622
|
+
export function isPicked(radio: Element): boolean {
|
|
623
|
+
return radio instanceof HTMLInputElement
|
|
624
|
+
? radio.checked
|
|
625
|
+
: radio.getAttribute('aria-checked') === 'true';
|
|
626
|
+
}
|