@mparticle/web-sdk 3.26.1 → 3.28.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 +622 -110
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +3 -1
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +0 -6
- 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 +9 -0
- package/package.json +1 -1
- package/src/batchUploader.ts +25 -9
- package/src/buttonTracking/buttonClickEvent.ts +9 -3
- package/src/buttonTracking/buttonDescription.ts +325 -17
- package/src/buttonTracking/buttonTracker.ts +21 -68
- package/src/buttonTracking/checkoutIntent.ts +63 -9
- package/src/buttonTracking/controls.ts +54 -2
- package/src/buttonTracking/labelMasking.ts +6 -0
- package/src/pageUrl.ts +58 -9
- package/src/pageViewTracker.ts +9 -3
- package/src/pathMasking.ts +306 -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,10 +11,12 @@ 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;
|
|
20
22
|
}
|
|
@@ -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;
|
|
@@ -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,9 @@
|
|
|
1
|
+
export declare const LEFTOVER_ESCAPE: RegExp;
|
|
2
|
+
export declare function hidesSegmentAfter(route: string, segment: string): boolean;
|
|
3
|
+
export declare function hidesNextSegment(route: string): boolean;
|
|
4
|
+
export declare function decodeFully(text: string): string | undefined;
|
|
5
|
+
export declare function routeSegment(segments: string[], i: number): string;
|
|
6
|
+
export declare function isTokenSegment(segment: string): boolean;
|
|
7
|
+
export declare function maskTokenSegment(segment: string, text: string, route: string | undefined): string | undefined;
|
|
8
|
+
export declare function pagePath(pathname: string): string;
|
|
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
|
}
|
|
@@ -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
|
+
hidesSegmentAfter,
|
|
14
|
+
LEFTOVER_ESCAPE,
|
|
15
|
+
maskTokenSegment,
|
|
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,10 +46,12 @@ 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;
|
|
46
57
|
}
|
|
@@ -53,7 +64,13 @@ function elementKind(control: Element): ButtonElementKind {
|
|
|
53
64
|
if (tag === 'button' || tag === 'input') {
|
|
54
65
|
return tag;
|
|
55
66
|
}
|
|
56
|
-
|
|
67
|
+
// A link run by script reads as a link, as an anchor does, so whoever reads
|
|
68
|
+
// the click treats it the same.
|
|
69
|
+
return control.matches(
|
|
70
|
+
'a[href]:not([role="button"]), [role~="link"]:not([role~="button"])'
|
|
71
|
+
)
|
|
72
|
+
? 'link'
|
|
73
|
+
: 'role';
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
const NOT_RENDERED =
|
|
@@ -253,7 +270,7 @@ function accessibleName(
|
|
|
253
270
|
} else if (readable && isShown(control)) {
|
|
254
271
|
const text = renderedText(control);
|
|
255
272
|
if (text === undefined) {
|
|
256
|
-
return { source: 'none' };
|
|
273
|
+
return headingName(control) || { source: 'none' };
|
|
257
274
|
}
|
|
258
275
|
if (text) {
|
|
259
276
|
return { raw: text, source: 'text' };
|
|
@@ -263,6 +280,12 @@ function accessibleName(
|
|
|
263
280
|
return { raw: alt, source: 'alt' };
|
|
264
281
|
}
|
|
265
282
|
}
|
|
283
|
+
if (!(control instanceof HTMLInputElement) && isShown(control)) {
|
|
284
|
+
const heading = headingName(control);
|
|
285
|
+
if (heading) {
|
|
286
|
+
return heading;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
266
289
|
const title = nonBlank(control.getAttribute('title'));
|
|
267
290
|
if (title) {
|
|
268
291
|
return { raw: title, source: 'title' };
|
|
@@ -270,6 +293,33 @@ function accessibleName(
|
|
|
270
293
|
return { source: 'none' };
|
|
271
294
|
}
|
|
272
295
|
|
|
296
|
+
// A card whose text runs too long to keep, or has too many parts to read, is
|
|
297
|
+
// named by its first heading, as a product or menu card usually is. Not a card
|
|
298
|
+
// holding a form field, nor one that takes in slotted content or holds a
|
|
299
|
+
// shadow root, whose fields the form field check can't see.
|
|
300
|
+
function headingName(
|
|
301
|
+
control: Element
|
|
302
|
+
): { raw: string; source: ButtonLabelSource } | undefined {
|
|
303
|
+
if (
|
|
304
|
+
isUnsafeTextSource(control) ||
|
|
305
|
+
control.shadowRoot ||
|
|
306
|
+
control.querySelector('slot') ||
|
|
307
|
+
// A custom element can hide fields in a closed shadow root, which
|
|
308
|
+
// nothing outside it can see.
|
|
309
|
+
control.tagName.indexOf('-') !== -1 ||
|
|
310
|
+
Array.from(control.querySelectorAll('*')).some(
|
|
311
|
+
element =>
|
|
312
|
+
!!element.shadowRoot || element.tagName.indexOf('-') !== -1
|
|
313
|
+
)
|
|
314
|
+
) {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
const text = textOf(
|
|
318
|
+
control.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]')
|
|
319
|
+
);
|
|
320
|
+
return text ? { raw: text, source: 'heading' } : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
273
323
|
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
324
|
// Size, style and animation classes that share the icon prefixes.
|
|
275
325
|
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 +381,13 @@ function stableIdentifier(value: string | null): string | undefined {
|
|
|
331
381
|
: undefined;
|
|
332
382
|
}
|
|
333
383
|
|
|
384
|
+
// A picker's input name, which names its question. Sites often end it with the
|
|
385
|
+
// id of the product it belongs to (`color-swatches-48213907`); without that
|
|
386
|
+
// number it still names the question.
|
|
387
|
+
function questionName(value: string | null): string | undefined {
|
|
388
|
+
return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
|
|
389
|
+
}
|
|
390
|
+
|
|
334
391
|
const TEST_ID_ATTRIBUTES = [
|
|
335
392
|
'data-testid',
|
|
336
393
|
'data-test-id',
|
|
@@ -378,24 +435,88 @@ function formIdentifier(control: Element): string | undefined {
|
|
|
378
435
|
|
|
379
436
|
// The browser's normalised type: a button defaults to 'submit' and an unknown
|
|
380
437
|
// input type reads as 'text'. A native button or input reports its own type, a
|
|
381
|
-
// wallet-named one included
|
|
382
|
-
//
|
|
438
|
+
// wallet-named one included. A role control that isn't a button reads as its
|
|
439
|
+
// role: 'radio', 'option', 'tab', 'link' and so on. Any other control, such as
|
|
440
|
+
// a role button or a vendor's wallet element, reads as 'button'.
|
|
441
|
+
const CONTROL_ROLES = [
|
|
442
|
+
'radio',
|
|
443
|
+
'menuitemradio',
|
|
444
|
+
'checkbox',
|
|
445
|
+
'menuitemcheckbox',
|
|
446
|
+
'switch',
|
|
447
|
+
'option',
|
|
448
|
+
'tab',
|
|
449
|
+
'link',
|
|
450
|
+
];
|
|
451
|
+
|
|
383
452
|
function controlType(control: Element): string {
|
|
384
453
|
const tag = control.tagName.toLowerCase();
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
454
|
+
if (tag === 'button' || tag === 'input') {
|
|
455
|
+
return (control as HTMLButtonElement | HTMLInputElement).type;
|
|
456
|
+
}
|
|
457
|
+
if (control.matches('[role~="button"]')) {
|
|
458
|
+
return 'button';
|
|
459
|
+
}
|
|
460
|
+
const roles = (control.getAttribute('role') || '').trim().split(/\s+/);
|
|
461
|
+
for (const role of roles) {
|
|
462
|
+
if (CONTROL_ROLES.indexOf(role) !== -1) {
|
|
463
|
+
return role;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return 'button';
|
|
388
467
|
}
|
|
389
468
|
|
|
390
469
|
interface ILinkDetails {
|
|
391
470
|
type: LinkType;
|
|
392
471
|
domain?: string;
|
|
472
|
+
path?: string;
|
|
393
473
|
fileExtension?: string;
|
|
394
474
|
}
|
|
395
475
|
|
|
476
|
+
// The page a link to another page of the site opens: its decoded path alone,
|
|
477
|
+
// since an id in a path can point at one shopper. A segment holding a token,
|
|
478
|
+
// or following a route word that names a person or carries a code, goes as
|
|
479
|
+
// `*`, and numbers in any script are masked as in labels. Dropped when it
|
|
480
|
+
// holds an @, `;` or `=` (session ids and matrix parameters), is too long to
|
|
481
|
+
// be a route, or the browser can't mask every script's digits.
|
|
482
|
+
function linkPath(link: HTMLAnchorElement): string | undefined {
|
|
483
|
+
// Each segment decodes on its own, so a literal `%` in one never stops the
|
|
484
|
+
// next from decoding.
|
|
485
|
+
const decoded = link.pathname.split('/').map(decodeFully);
|
|
486
|
+
if (decoded.some(segment => segment === undefined)) {
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
const path = decoded.join('/');
|
|
490
|
+
if (
|
|
491
|
+
path.length > MAX_RAW_LABEL_LENGTH ||
|
|
492
|
+
/[@;=]/.test(path) ||
|
|
493
|
+
LEFTOVER_ESCAPE.test(path)
|
|
494
|
+
) {
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
const segments = path.split('/');
|
|
498
|
+
const masked = segments.map((segment, i) => {
|
|
499
|
+
if (
|
|
500
|
+
i > 0 &&
|
|
501
|
+
segment &&
|
|
502
|
+
hidesSegmentAfter(routeSegment(segments, i), segment)
|
|
503
|
+
) {
|
|
504
|
+
return '*';
|
|
505
|
+
}
|
|
506
|
+
const tokens = maskTokenSegment(
|
|
507
|
+
segment,
|
|
508
|
+
segment,
|
|
509
|
+
i > 0 ? routeSegment(segments, i) : undefined
|
|
510
|
+
);
|
|
511
|
+
return tokens === undefined ? segment : tokens;
|
|
512
|
+
});
|
|
513
|
+
return maskNumbers(masked.join('/'));
|
|
514
|
+
}
|
|
515
|
+
|
|
396
516
|
// 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
|
|
517
|
+
// a link that leaves the site, the path of one to another page of the site and the
|
|
518
|
+
// extension of a file it downloads travel. mailto:, tel: and other schemes give
|
|
519
|
+
// undefined.
|
|
399
520
|
function describeLink(link: Element): ILinkDetails | undefined {
|
|
400
521
|
const href = (link.getAttribute('href') || '').trim();
|
|
401
522
|
if (!href || href.charAt(0) === '#') {
|
|
@@ -429,7 +550,7 @@ function describeLink(link: Element): ILinkDetails | undefined {
|
|
|
429
550
|
return link.pathname === window.location.pathname &&
|
|
430
551
|
link.search === window.location.search
|
|
431
552
|
? { type: 'same_page' }
|
|
432
|
-
: { type: 'internal' };
|
|
553
|
+
: { type: 'internal', path: linkPath(link) };
|
|
433
554
|
}
|
|
434
555
|
|
|
435
556
|
// Whether a described click is on a link that leaves the page, logged as a link
|
|
@@ -442,7 +563,7 @@ export function isLinkClick(click: IButtonClick): boolean {
|
|
|
442
563
|
// fallbacks, so any of its tokens counts.
|
|
443
564
|
const CHOICE =
|
|
444
565
|
'input[type="checkbox"], input[type="radio"], [role~="checkbox"], [role~="radio"], ' +
|
|
445
|
-
'[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"]';
|
|
566
|
+
'[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"], [role~="option"]';
|
|
446
567
|
const RADIO_ROLE = '[role~="radio"], [role~="menuitemradio"]';
|
|
447
568
|
|
|
448
569
|
function boxOf(element: Element): Element | null {
|
|
@@ -512,36 +633,53 @@ export function describeButtonClick(
|
|
|
512
633
|
// radio's id, test id and the panel it opens name the option picked, so
|
|
513
634
|
// only the name of its input, the question, goes with it. A wrapper's own
|
|
514
635
|
// name, or a role radio's, can name the option instead.
|
|
515
|
-
|
|
636
|
+
// An option of a list box, like a radio, names the answer picked.
|
|
637
|
+
const radio =
|
|
638
|
+
isRadio(choice) || (!!choice && choice.matches('[role~="option"]'));
|
|
516
639
|
const named = !radio
|
|
517
640
|
? control
|
|
518
|
-
: choice instanceof HTMLInputElement
|
|
641
|
+
: choice instanceof HTMLInputElement &&
|
|
642
|
+
!choice.matches('[role~="option"]')
|
|
519
643
|
? choice
|
|
520
644
|
: null;
|
|
521
|
-
|
|
645
|
+
// A link run by script has no address to say where it goes, so it goes
|
|
646
|
+
// without its text, as a plain link to another page of the site does: the
|
|
647
|
+
// text can name the shopper ("Hi, Jane").
|
|
648
|
+
// A native button keeps its name whatever its role, as it always has.
|
|
649
|
+
const textless =
|
|
650
|
+
!!choice ||
|
|
651
|
+
(!link &&
|
|
652
|
+
element !== 'wallet' &&
|
|
653
|
+
control.matches('[role~="link"]') &&
|
|
654
|
+
!control.matches(CONTROL_SELECTOR));
|
|
655
|
+
const name = textless ? undefined : accessibleName(control);
|
|
522
656
|
const label = name ? maskLabel(name.raw) : undefined;
|
|
523
657
|
return {
|
|
524
658
|
label,
|
|
525
659
|
labelSource: label ? name.source : 'none',
|
|
526
660
|
// Kept on its own as well: it often says more than the visible text,
|
|
527
661
|
// such as which item a remove button removes.
|
|
528
|
-
ariaLabel:
|
|
662
|
+
ariaLabel: textless
|
|
529
663
|
? undefined
|
|
530
664
|
: maskLabel(control.getAttribute('aria-label')),
|
|
531
665
|
icon: choice ? undefined : iconHint(control),
|
|
532
666
|
elementId: radio
|
|
533
667
|
? undefined
|
|
534
668
|
: stableIdentifier(control.getAttribute('id')),
|
|
535
|
-
elementName:
|
|
669
|
+
elementName: (choice ? questionName : stableIdentifier)(
|
|
670
|
+
named && named.getAttribute('name')
|
|
671
|
+
),
|
|
536
672
|
testId: radio
|
|
537
673
|
? undefined
|
|
538
674
|
: firstIdentifier(control, TEST_ID_ATTRIBUTES),
|
|
539
675
|
controls: radio ? undefined : controlsIdentifier(control),
|
|
540
676
|
form: formIdentifier(control),
|
|
677
|
+
group: choice ? groupName(choice) : undefined,
|
|
541
678
|
element,
|
|
542
679
|
type: controlType(control),
|
|
543
680
|
linkType: link ? link.type : undefined,
|
|
544
681
|
linkDomain: link ? link.domain : undefined,
|
|
682
|
+
linkPath: link ? link.path : undefined,
|
|
545
683
|
fileExtension: link ? link.fileExtension : undefined,
|
|
546
684
|
// The box as the click left it: the browser toggles it before the click
|
|
547
685
|
// reaches listeners, and puts it back if the click is cancelled.
|
|
@@ -549,6 +687,176 @@ export function describeButtonClick(
|
|
|
549
687
|
};
|
|
550
688
|
}
|
|
551
689
|
|
|
690
|
+
// The question a picker answers, from the group around it: the group's
|
|
691
|
+
// aria-label, aria-labelledby or legend, masked like a label and cut at its
|
|
692
|
+
// first colon, since a legend can repeat the pick ("Color: Navy"). Never the
|
|
693
|
+
// option's own text, which is the shopper's answer.
|
|
694
|
+
const NAMED_GROUP = '[role="radiogroup"], [role="listbox"], [role="group"], fieldset';
|
|
695
|
+
// A control such as the button that opens a list box shows the option picked.
|
|
696
|
+
const NOT_A_QUESTION =
|
|
697
|
+
CHOICE +
|
|
698
|
+
', ' +
|
|
699
|
+
CONTROL_SELECTOR +
|
|
700
|
+
', a[href], input, select, textarea, slot, [aria-haspopup], [role~="button"], ' +
|
|
701
|
+
'[role~="link"], [role~="tab"], [role~="combobox"]';
|
|
702
|
+
|
|
703
|
+
const OPTION_KINDS = [
|
|
704
|
+
'radio',
|
|
705
|
+
'menuitemradio',
|
|
706
|
+
'checkbox',
|
|
707
|
+
'menuitemcheckbox',
|
|
708
|
+
'switch',
|
|
709
|
+
'option',
|
|
710
|
+
];
|
|
711
|
+
|
|
712
|
+
function optionKind(option: Element): string | undefined {
|
|
713
|
+
if (option instanceof HTMLInputElement) {
|
|
714
|
+
return option.type;
|
|
715
|
+
}
|
|
716
|
+
const roles = (option.getAttribute('role') || '').trim().split(/\s+/);
|
|
717
|
+
return roles.filter(role => OPTION_KINDS.indexOf(role) !== -1)[0];
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Whether two options answer the same question. A native box shares its
|
|
721
|
+
// question only with a box of the same name in the same form, as the browser
|
|
722
|
+
// groups radios, so separate shipping and billing radios ask two.
|
|
723
|
+
function sameQuestion(option: Element, choice: Element): boolean {
|
|
724
|
+
if (!(choice instanceof HTMLInputElement)) {
|
|
725
|
+
// A role option answers only within its own radio group or list box.
|
|
726
|
+
const container = '[role="radiogroup"], [role="listbox"]';
|
|
727
|
+
return (
|
|
728
|
+
!(option instanceof HTMLInputElement) &&
|
|
729
|
+
optionKind(option) === optionKind(choice) &&
|
|
730
|
+
option.closest(container) === choice.closest(container)
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
const name = choice.getAttribute('name');
|
|
734
|
+
return (
|
|
735
|
+
option instanceof HTMLInputElement &&
|
|
736
|
+
option.type === choice.type &&
|
|
737
|
+
!!name &&
|
|
738
|
+
option.getAttribute('name') === name &&
|
|
739
|
+
option.form === choice.form
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Whether a group offers another answer to the question picked: a shown option
|
|
744
|
+
// to the same question that isn't part of the one picked. A card around one
|
|
745
|
+
// address radio and a "make default" checkbox, or around a role radio and the
|
|
746
|
+
// hidden input behind it, offers none.
|
|
747
|
+
function holdsOptions(group: Element, choice: Element): boolean {
|
|
748
|
+
const options = group.querySelectorAll(CHOICE);
|
|
749
|
+
for (let i = 0; i < options.length; i++) {
|
|
750
|
+
const option = options[i];
|
|
751
|
+
if (
|
|
752
|
+
!option.contains(choice) &&
|
|
753
|
+
!choice.contains(option) &&
|
|
754
|
+
sameQuestion(option, choice) &&
|
|
755
|
+
isShown(option, group) &&
|
|
756
|
+
isVisible(option) &&
|
|
757
|
+
// A fully transparent option, as a native input behind a styled
|
|
758
|
+
// one often is, shows the shopper no second answer.
|
|
759
|
+
!someComposedAncestor(
|
|
760
|
+
option,
|
|
761
|
+
node => window.getComputedStyle(node).opacity === '0',
|
|
762
|
+
group
|
|
763
|
+
)
|
|
764
|
+
) {
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// The elements an option is named by: those its aria-labelledby points at and
|
|
772
|
+
// a native box's labels.
|
|
773
|
+
function optionLabels(option: Element, root: Document | ShadowRoot): Element[] {
|
|
774
|
+
const labels: Element[] =
|
|
775
|
+
option instanceof HTMLInputElement && option.labels
|
|
776
|
+
? Array.from(option.labels)
|
|
777
|
+
: [];
|
|
778
|
+
(option.getAttribute('aria-labelledby') || '')
|
|
779
|
+
.trim()
|
|
780
|
+
.split(/\s+/)
|
|
781
|
+
.forEach(ref => {
|
|
782
|
+
const target =
|
|
783
|
+
ref && root.getElementById ? root.getElementById(ref) : null;
|
|
784
|
+
if (target) {
|
|
785
|
+
labels.push(target);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
return labels;
|
|
789
|
+
}
|
|
790
|
+
|
|
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 {
|
|
797
|
+
if (!(choice instanceof HTMLInputElement)) {
|
|
798
|
+
const container = choice.matches('[role~="option"]')
|
|
799
|
+
? choice.closest('[role="listbox"]')
|
|
800
|
+
: choice.matches('[role~="radio"]')
|
|
801
|
+
? choice.closest('[role="radiogroup"]')
|
|
802
|
+
: null;
|
|
803
|
+
return container && holdsOptions(container, choice) ? container : null;
|
|
804
|
+
}
|
|
805
|
+
let group = choice.closest(NAMED_GROUP);
|
|
806
|
+
while (group && !holdsOptions(group, choice)) {
|
|
807
|
+
group = group.parentElement
|
|
808
|
+
? group.parentElement.closest(NAMED_GROUP)
|
|
809
|
+
: null;
|
|
810
|
+
}
|
|
811
|
+
return group;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function groupName(choice: Element): string | undefined {
|
|
815
|
+
const group = questionGroup(choice);
|
|
816
|
+
if (!group) {
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
let raw = nonBlank(group.getAttribute('aria-label'));
|
|
820
|
+
if (!raw) {
|
|
821
|
+
const ids = group.getAttribute('aria-labelledby');
|
|
822
|
+
const legend =
|
|
823
|
+
group.tagName === 'FIELDSET'
|
|
824
|
+
? Array.from(group.children).filter(
|
|
825
|
+
child => child.tagName === 'LEGEND'
|
|
826
|
+
)[0]
|
|
827
|
+
: undefined;
|
|
828
|
+
const root = (isFunction(group.getRootNode)
|
|
829
|
+
? group.getRootNode()
|
|
830
|
+
: document) as Document | ShadowRoot;
|
|
831
|
+
const source = ids
|
|
832
|
+
? root.getElementById
|
|
833
|
+
? root.getElementById(ids.trim().split(/\s+/)[0])
|
|
834
|
+
: null
|
|
835
|
+
: legend || null;
|
|
836
|
+
// A label that is, holds or sits in an option or a control, or in an
|
|
837
|
+
// option's label, would name the answer.
|
|
838
|
+
const holder = source ? source.closest('label') : null;
|
|
839
|
+
// Nor one that holds, or sits in, what an option of the group names
|
|
840
|
+
// itself by.
|
|
841
|
+
const labelsOption =
|
|
842
|
+
!!source &&
|
|
843
|
+
Array.from(group.querySelectorAll(CHOICE)).some(option =>
|
|
844
|
+
optionLabels(option, root).some(
|
|
845
|
+
label => source.contains(label) || label.contains(source)
|
|
846
|
+
)
|
|
847
|
+
);
|
|
848
|
+
raw =
|
|
849
|
+
source &&
|
|
850
|
+
!labelsOption &&
|
|
851
|
+
!hasComposedAncestor(source, NOT_A_QUESTION) &&
|
|
852
|
+
!source.querySelector(NOT_A_QUESTION) &&
|
|
853
|
+
!(holder && (holder.control || holder.querySelector(CHOICE)))
|
|
854
|
+
? textOf(source)
|
|
855
|
+
: undefined;
|
|
856
|
+
}
|
|
857
|
+
return raw ? maskLabel(raw.split(':')[0]) : undefined;
|
|
858
|
+
}
|
|
859
|
+
|
|
552
860
|
// A question about payment, read from the names its author gave it, so
|
|
553
861
|
// "checkout[payment_gateway]", "paymentMethod" and "tender_type" all count and
|
|
554
862
|
// "payee" does not. A radio's input name is its question; its group or
|