@mparticle/web-sdk 3.17.0 → 3.19.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 +11 -6
- package/dist/mparticle.common.js +110 -47
- package/dist/mparticle.esm.js +110 -47
- package/dist/mparticle.js +561 -158
- package/dist/types/src/buttonTracking/buttonClickEvent.d.ts +5 -0
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +16 -0
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +35 -0
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +3 -0
- package/dist/types/src/buttonTracking/controls.d.ts +6 -0
- package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
- package/dist/types/src/constants.d.ts +1 -1
- package/dist/types/src/mp-instance.d.ts +2 -2
- package/dist/types/src/roktManager.d.ts +1 -1
- package/dist/types/src/store.d.ts +1 -1
- package/package.json +4 -3
- package/src/buttonTracking/buttonClickEvent.ts +48 -0
- package/src/buttonTracking/buttonDescription.ts +259 -0
- package/src/buttonTracking/buttonTracker.ts +370 -0
- package/src/{checkoutIntentClassifier.ts → buttonTracking/checkoutIntent.ts} +39 -80
- package/src/buttonTracking/controls.ts +111 -0
- package/src/buttonTracking/labelMasking.ts +41 -0
- package/src/constants.ts +1 -1
- package/src/mp-instance.ts +22 -27
- package/src/roktManager.ts +2 -2
- package/src/store.ts +3 -3
- package/dist/types/src/checkoutIntentClassifier.d.ts +0 -4
- package/dist/types/src/checkoutIntentTracker.d.ts +0 -23
- package/src/checkoutIntentTracker.ts +0 -244
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { BaseEvent } from '../sdkRuntimeModels';
|
|
2
|
+
import { IButtonClick } from './buttonDescription';
|
|
3
|
+
export declare const AUTO_BUTTON_ATTRIBUTE = "is_auto_button_click";
|
|
4
|
+
export declare const BUTTON_CLICK_EVENT_NAME = "Auto Button Click";
|
|
5
|
+
export declare const buildButtonClickEvent: (click: IButtonClick, pathname: string) => BaseEvent;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
|
|
2
|
+
export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'alt' | 'title' | 'value' | 'none';
|
|
3
|
+
export interface IButtonClick {
|
|
4
|
+
label?: string;
|
|
5
|
+
labelSource: ButtonLabelSource;
|
|
6
|
+
ariaLabel?: string;
|
|
7
|
+
icon?: string;
|
|
8
|
+
elementId?: string;
|
|
9
|
+
elementName?: string;
|
|
10
|
+
testId?: string;
|
|
11
|
+
controls?: string;
|
|
12
|
+
form?: string;
|
|
13
|
+
element: ButtonElementKind;
|
|
14
|
+
type: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function describeButtonClick(control: Element): IButtonClick | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { IMParticleWebSDKInstance } from '../mp-instance';
|
|
2
|
+
export declare const WIN_BTN_KEY = "__mpBtn__";
|
|
3
|
+
export interface IButtonTrackerOptions {
|
|
4
|
+
hasRoktKit: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare const getActiveButtonTracker: () => ButtonTracker | undefined;
|
|
7
|
+
export declare const resetButtonTracking: () => void;
|
|
8
|
+
export declare class ButtonTracker {
|
|
9
|
+
private readonly mpInstance;
|
|
10
|
+
private active;
|
|
11
|
+
private hasRoktKit;
|
|
12
|
+
private generation;
|
|
13
|
+
private lastCommitClick;
|
|
14
|
+
private lastSent;
|
|
15
|
+
private pagePath;
|
|
16
|
+
private pageEvents;
|
|
17
|
+
private pending;
|
|
18
|
+
constructor(mpInstance: IMParticleWebSDKInstance);
|
|
19
|
+
get isActive(): boolean;
|
|
20
|
+
init(options: IButtonTrackerOptions): void;
|
|
21
|
+
teardown(): void;
|
|
22
|
+
clearPending(): void;
|
|
23
|
+
private reset;
|
|
24
|
+
private enqueue;
|
|
25
|
+
private readonly drain;
|
|
26
|
+
private readonly onHide;
|
|
27
|
+
private readonly onPageShow;
|
|
28
|
+
private readonly onEvent;
|
|
29
|
+
private observe;
|
|
30
|
+
private logClick;
|
|
31
|
+
private emit;
|
|
32
|
+
private eventsEnabled;
|
|
33
|
+
private isKitSuppressed;
|
|
34
|
+
private log;
|
|
35
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type CheckoutIntentKind = 'commit' | 'wallet' | 'checkout_step' | 'frame';
|
|
2
|
+
export declare function classifyIntentControl(control: Element, pathname: string): Exclude<CheckoutIntentKind, 'frame'> | undefined;
|
|
3
|
+
export declare function classifyIntentFrame(target: EventTarget | null, win: Window): 'frame' | undefined;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const CONTROL_SELECTOR = "button, input[type=\"submit\"], input[type=\"button\"], input[type=\"image\"], [role=\"button\"]";
|
|
2
|
+
export declare function hasComposedAncestor(element: Element, selector: string): boolean;
|
|
3
|
+
export declare function isRoktElement(element: Element): boolean;
|
|
4
|
+
export declare function isWalletElement(element: Element): boolean;
|
|
5
|
+
export declare function findControl(path: Array<EventTarget | null>): Element | undefined;
|
|
6
|
+
export declare function isUnsafeTextSource(element: Element): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function maskLabel(raw: string | null | undefined): string | undefined;
|
|
@@ -162,7 +162,7 @@ declare const Constants: {
|
|
|
162
162
|
readonly CaptureIntegrationSpecificIdsV2: "captureIntegrationSpecificIds.V2";
|
|
163
163
|
readonly AstBackgroundEvents: "astBackgroundEvents";
|
|
164
164
|
readonly AutoLogPageView: "autoLogPageView";
|
|
165
|
-
readonly
|
|
165
|
+
readonly AutoButtonTracking: "autoButtonTracking";
|
|
166
166
|
};
|
|
167
167
|
readonly DefaultInstance: "default_instance";
|
|
168
168
|
readonly CCPAPurpose: "data_sale_opt_out";
|
|
@@ -22,7 +22,7 @@ import { ICookieConsentManager } from './cookieConsentManager';
|
|
|
22
22
|
import { ErrorReportingDispatcher } from './reporting/errorReportingDispatcher';
|
|
23
23
|
import { LoggingDispatcher } from './reporting/loggingDispatcher';
|
|
24
24
|
import { PageViewTracker } from './pageViewTracker';
|
|
25
|
-
import {
|
|
25
|
+
import { ButtonTracker } from './buttonTracking/buttonTracker';
|
|
26
26
|
export interface IErrorLogMessage {
|
|
27
27
|
message?: string;
|
|
28
28
|
name?: string;
|
|
@@ -48,7 +48,7 @@ export interface IMParticleWebSDKInstance extends MParticleWebSDK {
|
|
|
48
48
|
_IntegrationCapture: IntegrationCapture;
|
|
49
49
|
_NativeSdkHelpers: INativeSdkHelpers;
|
|
50
50
|
_PageViewTracker?: PageViewTracker;
|
|
51
|
-
|
|
51
|
+
_ButtonTracker?: ButtonTracker;
|
|
52
52
|
_Persistence: IPersistence;
|
|
53
53
|
_CookieConsentManager: ICookieConsentManager;
|
|
54
54
|
_ErrorReportingDispatcher: ErrorReportingDispatcher;
|
|
@@ -8,7 +8,7 @@ import { IStore, LocalSessionAttributes } from "./store";
|
|
|
8
8
|
import { IErrorReportingService, ILoggingService } from "./reporting/types";
|
|
9
9
|
import { IRoktLauncherOptions } from "./roktLauncherOptions";
|
|
10
10
|
import IntegrationCapture from "./integrationCapture";
|
|
11
|
-
import type { CheckoutIntentKind } from "./
|
|
11
|
+
import type { CheckoutIntentKind } from "./buttonTracking/checkoutIntent";
|
|
12
12
|
export type RoktAttributeValueArray = Array<string | number | boolean>;
|
|
13
13
|
export type RoktAttributeValueType = string | number | boolean | undefined | null;
|
|
14
14
|
export type RoktAttributeValue = RoktAttributeValueType | RoktAttributeValueArray;
|
|
@@ -79,7 +79,7 @@ export interface IFeatureFlags {
|
|
|
79
79
|
'captureIntegrationSpecificIds.V2'?: string;
|
|
80
80
|
astBackgroundEvents?: boolean;
|
|
81
81
|
autoLogPageView?: boolean;
|
|
82
|
-
|
|
82
|
+
autoButtonTracking?: boolean;
|
|
83
83
|
}
|
|
84
84
|
export interface IStore {
|
|
85
85
|
isEnabled: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mparticle/web-sdk",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "mParticle core SDK for web applications",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"keywords": [
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
},
|
|
33
33
|
"repository": "https://github.com/mParticle/mparticle-web-sdk",
|
|
34
34
|
"scripts": {
|
|
35
|
-
"build": "cross-env ENVIRONMENT=prod BUILDALL=true rollup --config rollup.config.js && npm run build:types",
|
|
35
|
+
"build": "cross-env ENVIRONMENT=prod BUILDALL=true rollup --config rollup.config.js && npm run check:ascii:dist && npm run build:types",
|
|
36
36
|
"build:types": "rm -rf dist/types && (tsc -p tsconfig.types.json || true) && test -f dist/types/src/public-types.d.ts && test -f dist/types/src/internal-types.d.ts",
|
|
37
37
|
"build:cbt": "rollup --config test/cross-browser-testing/rollup.CBT.config.js",
|
|
38
38
|
"build:dev": "cross-env ENVIRONMENT=dev BUILD=iife rollup --config rollup.config.js",
|
|
39
|
-
"build:iife": "cross-env ENVIRONMENT=prod BUILD=iife rollup --config rollup.config.js",
|
|
39
|
+
"build:iife": "cross-env ENVIRONMENT=prod BUILD=iife rollup --config rollup.config.js && npm run check:ascii:dist -- dist/mparticle.js",
|
|
40
40
|
"build:npm": "cross-env ENVIRONMENT=prod BUILD=cjs rollup --config rollup.config.js",
|
|
41
41
|
"build:esm": "cross-env ENVIRONMENT=prod BUILD=esm rollup --config rollup.config.js",
|
|
42
42
|
"build:stub": "cross-env ENVIRONMENT=prod BUILD=stub rollup --config rollup.config.js",
|
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
"gzip": "gzip -c dist/mparticle.min.js > dist/mparticle.min.js.gz",
|
|
94
94
|
"bundle": "npm run uglify && npm run gzip",
|
|
95
95
|
"report:bundled": "ls -l dist/mparticle.min.js.gz | awk '{print $5}'",
|
|
96
|
+
"check:ascii:dist": "node scripts/check-ascii-dist.js",
|
|
96
97
|
"report:bundled:human": "ls -lh dist/mparticle.min.js.gz | awk '{print $5}'"
|
|
97
98
|
},
|
|
98
99
|
"pre-commit": [
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { BaseEvent } from '../sdkRuntimeModels';
|
|
2
|
+
import { EventType, MessageType } from '../types';
|
|
3
|
+
import { IButtonClick } from './buttonDescription';
|
|
4
|
+
|
|
5
|
+
// Marks an automatically logged button click, like `is_auto_page_view`.
|
|
6
|
+
export const AUTO_BUTTON_ATTRIBUTE = 'is_auto_button_click';
|
|
7
|
+
|
|
8
|
+
// Specific enough not to merge with a customer's own 'Button Click' event.
|
|
9
|
+
export const BUTTON_CLICK_EVENT_NAME = 'Auto Button Click';
|
|
10
|
+
|
|
11
|
+
// One custom event per click, logged like logLink() with the auto marker and the
|
|
12
|
+
// page fields that auto page views carry. No intent: classifying clicks is left
|
|
13
|
+
// to whoever reads the data.
|
|
14
|
+
export const buildButtonClickEvent = (
|
|
15
|
+
click: IButtonClick,
|
|
16
|
+
pathname: string
|
|
17
|
+
): BaseEvent => {
|
|
18
|
+
const data: { [key: string]: string | boolean } = {
|
|
19
|
+
label_source: click.labelSource,
|
|
20
|
+
element: click.element,
|
|
21
|
+
type: click.type,
|
|
22
|
+
path: pathname,
|
|
23
|
+
hostname: window.location.hostname,
|
|
24
|
+
[AUTO_BUTTON_ATTRIBUTE]: true,
|
|
25
|
+
};
|
|
26
|
+
// Optional fields are left out when the button has no safe value for them.
|
|
27
|
+
const optional: { [key: string]: string | undefined } = {
|
|
28
|
+
label: click.label,
|
|
29
|
+
aria_label: click.ariaLabel,
|
|
30
|
+
icon: click.icon,
|
|
31
|
+
element_id: click.elementId,
|
|
32
|
+
element_name: click.elementName,
|
|
33
|
+
test_id: click.testId,
|
|
34
|
+
controls: click.controls,
|
|
35
|
+
form: click.form,
|
|
36
|
+
};
|
|
37
|
+
for (const key of Object.keys(optional)) {
|
|
38
|
+
if (optional[key]) {
|
|
39
|
+
data[key] = optional[key];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
messageType: MessageType.PageEvent,
|
|
44
|
+
name: BUTTON_CLICK_EVENT_NAME,
|
|
45
|
+
data,
|
|
46
|
+
eventType: EventType.Other,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { isFunction } from '../utils';
|
|
2
|
+
import {
|
|
3
|
+
hasComposedAncestor,
|
|
4
|
+
isUnsafeTextSource,
|
|
5
|
+
isWalletElement,
|
|
6
|
+
} from './controls';
|
|
7
|
+
import { maskLabel } from './labelMasking';
|
|
8
|
+
|
|
9
|
+
// Describes a clicked button for auto button tracking: its masked name, icon,
|
|
10
|
+
// hand-written identifiers and kind. Never reads form field values.
|
|
11
|
+
export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
|
|
12
|
+
|
|
13
|
+
export type ButtonLabelSource =
|
|
14
|
+
| 'labelledby'
|
|
15
|
+
| 'aria'
|
|
16
|
+
| 'text'
|
|
17
|
+
| 'alt'
|
|
18
|
+
| 'title'
|
|
19
|
+
| 'value'
|
|
20
|
+
| 'none';
|
|
21
|
+
|
|
22
|
+
export interface IButtonClick {
|
|
23
|
+
label?: string;
|
|
24
|
+
labelSource: ButtonLabelSource;
|
|
25
|
+
ariaLabel?: string;
|
|
26
|
+
icon?: string;
|
|
27
|
+
elementId?: string;
|
|
28
|
+
elementName?: string;
|
|
29
|
+
testId?: string;
|
|
30
|
+
controls?: string;
|
|
31
|
+
form?: string;
|
|
32
|
+
element: ButtonElementKind;
|
|
33
|
+
type: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function elementKind(control: Element): ButtonElementKind {
|
|
37
|
+
if (isWalletElement(control)) {
|
|
38
|
+
return 'wallet';
|
|
39
|
+
}
|
|
40
|
+
const tag = control.tagName.toLowerCase();
|
|
41
|
+
return tag === 'button' || tag === 'input' ? tag : 'role';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function textOf(element: Element | null): string {
|
|
45
|
+
return element && !isUnsafeTextSource(element) && element.textContent
|
|
46
|
+
? element.textContent.trim()
|
|
47
|
+
: '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nonBlank(value: string | null): string | undefined {
|
|
51
|
+
return value && value.trim() ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The button's name as a screen reader would read it, simplified: the first of
|
|
55
|
+
// aria-labelledby, aria-label, own text (or an inner image's alt), title, and for
|
|
56
|
+
// inputs their value. The text sources are skipped for controls that hold form
|
|
57
|
+
// fields or more than four children, so a typed value is never read.
|
|
58
|
+
function accessibleName(
|
|
59
|
+
control: Element
|
|
60
|
+
): { raw?: string; source: ButtonLabelSource } {
|
|
61
|
+
const labelledBy = control.getAttribute('aria-labelledby');
|
|
62
|
+
if (labelledBy) {
|
|
63
|
+
const root = (isFunction(control.getRootNode)
|
|
64
|
+
? control.getRootNode()
|
|
65
|
+
: document) as Document | ShadowRoot;
|
|
66
|
+
const text = labelledBy
|
|
67
|
+
.split(/\s+/)
|
|
68
|
+
.map(id =>
|
|
69
|
+
textOf(root.getElementById ? root.getElementById(id) : null)
|
|
70
|
+
)
|
|
71
|
+
.filter(part => !!part)
|
|
72
|
+
.join(' ');
|
|
73
|
+
if (text) {
|
|
74
|
+
return { raw: text, source: 'labelledby' };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const aria = nonBlank(control.getAttribute('aria-label'));
|
|
78
|
+
if (aria) {
|
|
79
|
+
return { raw: aria, source: 'aria' };
|
|
80
|
+
}
|
|
81
|
+
const readable =
|
|
82
|
+
control.children.length <= 4 && !isUnsafeTextSource(control);
|
|
83
|
+
if (control instanceof HTMLInputElement) {
|
|
84
|
+
// The browser's normalised type: an unknown value reads as 'text'.
|
|
85
|
+
const type = control.type;
|
|
86
|
+
if (type === 'image') {
|
|
87
|
+
const alt = nonBlank(control.getAttribute('alt'));
|
|
88
|
+
if (alt) {
|
|
89
|
+
return { raw: alt, source: 'alt' };
|
|
90
|
+
}
|
|
91
|
+
} else if (type === 'submit' || type === 'button' || type === 'reset') {
|
|
92
|
+
const value = nonBlank(control.value);
|
|
93
|
+
if (value) {
|
|
94
|
+
return { raw: value, source: 'value' };
|
|
95
|
+
}
|
|
96
|
+
if (type !== 'button') {
|
|
97
|
+
// What the browser shows on an input without a value.
|
|
98
|
+
return { raw: type, source: 'value' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} else if (readable) {
|
|
102
|
+
const text = textOf(control);
|
|
103
|
+
if (text) {
|
|
104
|
+
return { raw: text, source: 'text' };
|
|
105
|
+
}
|
|
106
|
+
const image = control.querySelector('img[alt]');
|
|
107
|
+
const alt = image ? nonBlank(image.getAttribute('alt')) : undefined;
|
|
108
|
+
if (alt) {
|
|
109
|
+
return { raw: alt, source: 'alt' };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const title = nonBlank(control.getAttribute('title'));
|
|
113
|
+
if (title) {
|
|
114
|
+
return { raw: title, source: 'title' };
|
|
115
|
+
}
|
|
116
|
+
return { source: 'none' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ICON_CLASS = /^(?:fa|fas|far|fal|icon|bi|glyphicon|ti|mdi|ri|la|lucide|ion|feather|ph)-([a-z][a-z-]{1,31})$/;
|
|
120
|
+
// Size, style and animation classes that share the icon prefixes.
|
|
121
|
+
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]+)$/;
|
|
122
|
+
|
|
123
|
+
function iconToken(candidate: string | null): string | undefined {
|
|
124
|
+
if (!candidate) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const token = candidate.toLowerCase().replace(/^icon-/, '');
|
|
128
|
+
return /^[a-z][a-z-]{1,31}$/.test(token) ? token : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// An icon name from an SVG sprite reference or an icon-font class, for buttons
|
|
132
|
+
// whose only content is an icon. Letters and hyphens only.
|
|
133
|
+
function iconHint(control: Element): string | undefined {
|
|
134
|
+
const allowed = (element: Element) =>
|
|
135
|
+
!hasComposedAncestor(element, '[data-mp-no-track]');
|
|
136
|
+
const use = Array.from(control.querySelectorAll('use')).filter(allowed)[0];
|
|
137
|
+
if (use) {
|
|
138
|
+
const href =
|
|
139
|
+
use.getAttribute('href') || use.getAttribute('xlink:href') || '';
|
|
140
|
+
const hash = href.indexOf('#');
|
|
141
|
+
const sprite = iconToken(hash === -1 ? null : href.slice(hash + 1));
|
|
142
|
+
if (sprite) {
|
|
143
|
+
return sprite;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const candidates = [control]
|
|
147
|
+
.concat(Array.from(control.querySelectorAll('i, span, svg')))
|
|
148
|
+
.filter(allowed)
|
|
149
|
+
.slice(0, 5);
|
|
150
|
+
for (const element of candidates) {
|
|
151
|
+
const classes = (element.getAttribute('class') || '').split(/\s+/);
|
|
152
|
+
for (const name of classes) {
|
|
153
|
+
const match = ICON_CLASS.exec(name.toLowerCase());
|
|
154
|
+
if (match && !ICON_MODIFIER.test(match[1])) {
|
|
155
|
+
return match[1];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Words of letters, each optionally ending in up to three digits, joined by
|
|
163
|
+
// hyphens or underscores: `add-to-cart`, `btnCheckout`, `step_2`. Generated ids
|
|
164
|
+
// (`ember1234`, `a8f3e2c1`, `radix-:r1:`) and long numbers don't match.
|
|
165
|
+
const HAND_WRITTEN_ID = /^[A-Za-z]+[0-9]{0,3}(?:[-_](?:[A-Za-z]+[0-9]{0,3}|[0-9]{1,3}))*$/;
|
|
166
|
+
|
|
167
|
+
// A developer-written identifier, or nothing.
|
|
168
|
+
function stableIdentifier(value: string | null): string | undefined {
|
|
169
|
+
if (!value) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
const id = value.trim();
|
|
173
|
+
// At most three digits in all, so numbers split into groups don't pass.
|
|
174
|
+
const digits = id.replace(/[^0-9]/g, '').length;
|
|
175
|
+
return id.length <= 64 && digits <= 3 && HAND_WRITTEN_ID.test(id)
|
|
176
|
+
? id
|
|
177
|
+
: undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const TEST_ID_ATTRIBUTES = [
|
|
181
|
+
'data-testid',
|
|
182
|
+
'data-test-id',
|
|
183
|
+
'data-test',
|
|
184
|
+
'data-qa',
|
|
185
|
+
'data-cy',
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
// The first of these attributes that holds a developer-written identifier.
|
|
189
|
+
function firstIdentifier(
|
|
190
|
+
control: Element,
|
|
191
|
+
attributes: string[]
|
|
192
|
+
): string | undefined {
|
|
193
|
+
for (const attribute of attributes) {
|
|
194
|
+
const id = stableIdentifier(control.getAttribute(attribute));
|
|
195
|
+
if (id) {
|
|
196
|
+
return id;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// What the button opens or acts on: a menu, drawer or dialog. aria-controls can
|
|
203
|
+
// list several ids, so only the first is used.
|
|
204
|
+
function controlsIdentifier(control: Element): string | undefined {
|
|
205
|
+
const ariaControls = control.getAttribute('aria-controls');
|
|
206
|
+
return (
|
|
207
|
+
stableIdentifier(
|
|
208
|
+
ariaControls ? ariaControls.trim().split(/\s+/)[0] : null
|
|
209
|
+
) || firstIdentifier(control, ['popovertarget', 'commandfor'])
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The id of the form a button or input actually submits, which the browser
|
|
214
|
+
// resolves from its form attribute or its owning form. getAttribute, because a
|
|
215
|
+
// field named "id" shadows form.id.
|
|
216
|
+
function formIdentifier(control: Element): string | undefined {
|
|
217
|
+
const form =
|
|
218
|
+
control instanceof HTMLButtonElement ||
|
|
219
|
+
control instanceof HTMLInputElement
|
|
220
|
+
? control.form
|
|
221
|
+
: null;
|
|
222
|
+
return form ? stableIdentifier(form.getAttribute('id')) : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The browser's normalised type: a button defaults to 'submit' and an unknown
|
|
226
|
+
// input type reads as 'text'. Wallet and role buttons are just 'button'.
|
|
227
|
+
function controlType(control: Element, element: ButtonElementKind): string {
|
|
228
|
+
return element === 'button' || element === 'input'
|
|
229
|
+
? (control as HTMLButtonElement | HTMLInputElement).type
|
|
230
|
+
: 'button';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Describes a clicked control for auto button tracking. Links are left to page
|
|
234
|
+
// views, so a link click is not described.
|
|
235
|
+
export function describeButtonClick(
|
|
236
|
+
control: Element
|
|
237
|
+
): IButtonClick | undefined {
|
|
238
|
+
if (control.matches('a[href]:not([role="button"])')) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const element = elementKind(control);
|
|
242
|
+
const name = accessibleName(control);
|
|
243
|
+
const label = maskLabel(name.raw);
|
|
244
|
+
return {
|
|
245
|
+
label,
|
|
246
|
+
labelSource: label ? name.source : 'none',
|
|
247
|
+
// Kept on its own as well: it often says more than the visible text,
|
|
248
|
+
// such as which item a remove button removes.
|
|
249
|
+
ariaLabel: maskLabel(control.getAttribute('aria-label')),
|
|
250
|
+
icon: iconHint(control),
|
|
251
|
+
elementId: stableIdentifier(control.getAttribute('id')),
|
|
252
|
+
elementName: stableIdentifier(control.getAttribute('name')),
|
|
253
|
+
testId: firstIdentifier(control, TEST_ID_ATTRIBUTES),
|
|
254
|
+
controls: controlsIdentifier(control),
|
|
255
|
+
form: formIdentifier(control),
|
|
256
|
+
element,
|
|
257
|
+
type: controlType(control, element),
|
|
258
|
+
};
|
|
259
|
+
}
|