@mparticle/web-sdk 3.28.0 → 3.29.1
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 +65 -34
- package/dist/mparticle.common.js +1 -1
- package/dist/mparticle.esm.js +1 -1
- package/dist/mparticle.js +324 -55
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +2 -0
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +1 -0
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +2 -0
- package/dist/types/src/roktManager.d.ts +3 -0
- package/package.json +1 -1
- package/src/buttonTracking/buttonDescription.ts +397 -61
- package/src/buttonTracking/buttonTracker.ts +49 -7
- package/src/buttonTracking/checkoutIntent.ts +36 -0
- package/src/mp-instance.ts +8 -4
- package/src/roktManager.ts +13 -0
package/package.json
CHANGED
|
@@ -56,6 +56,11 @@ export interface IButtonClick {
|
|
|
56
56
|
checked?: boolean;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// A link run by script reads as a link, as an anchor does, so whoever reads
|
|
60
|
+
// the click treats it the same.
|
|
61
|
+
const LINK_CONTROL =
|
|
62
|
+
'a[href]:not([role="button"]), [role~="link"]:not([role~="button"])';
|
|
63
|
+
|
|
59
64
|
function elementKind(control: Element): ButtonElementKind {
|
|
60
65
|
if (isWalletElement(control)) {
|
|
61
66
|
return 'wallet';
|
|
@@ -64,13 +69,7 @@ function elementKind(control: Element): ButtonElementKind {
|
|
|
64
69
|
if (tag === 'button' || tag === 'input') {
|
|
65
70
|
return tag;
|
|
66
71
|
}
|
|
67
|
-
|
|
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';
|
|
72
|
+
return control.matches(LINK_CONTROL) ? 'link' : 'role';
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
const NOT_RENDERED =
|
|
@@ -203,12 +202,19 @@ function textOf(element: Element | null): string | undefined {
|
|
|
203
202
|
: '';
|
|
204
203
|
}
|
|
205
204
|
|
|
206
|
-
// The control itself is already known to be shown.
|
|
205
|
+
// The control itself is already known to be shown. An inline icon names
|
|
206
|
+
// itself with aria-label rather than alt.
|
|
207
207
|
function shownImageAlt(control: Element): string | undefined {
|
|
208
|
-
const images = control.querySelectorAll(
|
|
208
|
+
const images = control.querySelectorAll(
|
|
209
|
+
'img[alt], svg[aria-label], [role="img"][aria-label]'
|
|
210
|
+
);
|
|
209
211
|
for (let i = 0; i < images.length && i < MAX_READS; i++) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
+
const alt = nonBlank(
|
|
213
|
+
images[i].getAttribute('alt') ||
|
|
214
|
+
images[i].getAttribute('aria-label')
|
|
215
|
+
);
|
|
216
|
+
if (alt && isShown(images[i], control) && isVisible(images[i])) {
|
|
217
|
+
return alt;
|
|
212
218
|
}
|
|
213
219
|
}
|
|
214
220
|
return undefined;
|
|
@@ -381,14 +387,40 @@ function stableIdentifier(value: string | null): string | undefined {
|
|
|
381
387
|
: undefined;
|
|
382
388
|
}
|
|
383
389
|
|
|
390
|
+
// An id React's useId generates, in any of the forms React has used: `:r1:`,
|
|
391
|
+
// `«r1»` and `_r_1_`. On the client it is r and a short base-32 counter; on
|
|
392
|
+
// the server, R and a base-32 tree id, where a component's later ids add an H
|
|
393
|
+
// and a counter (`:R1H1:`). A word that only starts with r, such as
|
|
394
|
+
// `:region:`, is not one.
|
|
395
|
+
const REACT_ID = /:(?:r[0-9a-v]{1,4}|R[0-9a-v]+(?:H[0-9a-v]+)?):|\u00ab(?:r[0-9a-v]{1,4}|R[0-9a-v]+(?:H[0-9a-v]+)?)\u00bb|_(?:r_[0-9a-v]{1,4}|R_[0-9a-v]+(?:H[0-9a-v]+)?)_/g;
|
|
396
|
+
|
|
397
|
+
// What a UI library puts before the React id it names an unnamed group with
|
|
398
|
+
// (`radix-:r1:`, `react-aria-:R1:`). Left on its own once that id is removed,
|
|
399
|
+
// it names the library, not the question.
|
|
400
|
+
const LIBRARY_ID_PREFIX = /^(?:radix|react-aria|headlessui(?:-[a-z]+)*|mui|chakra|reach|ark|mantine|base-ui)$/i;
|
|
401
|
+
|
|
384
402
|
// 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`)
|
|
386
|
-
//
|
|
403
|
+
// id of the product it belongs to (`color-swatches-48213907`), or build it
|
|
404
|
+
// with a React id (`variant_group__R_1mn59b_`); without either it still names
|
|
405
|
+
// the question.
|
|
387
406
|
function questionName(value: string | null): string | undefined {
|
|
388
|
-
|
|
407
|
+
if (!value) {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
const trimmed = value.trim();
|
|
411
|
+
const withoutReactId = trimmed.replace(REACT_ID, '-');
|
|
412
|
+
const name = stableIdentifier(
|
|
413
|
+
withoutReactId
|
|
414
|
+
.replace(/([-_])[-_]+/g, '$1')
|
|
415
|
+
.replace(/^[-_]+|[-_]+$/g, '')
|
|
416
|
+
.replace(/[-_][0-9]{4,}$/, '')
|
|
417
|
+
);
|
|
418
|
+
return name && withoutReactId !== trimmed && LIBRARY_ID_PREFIX.test(name)
|
|
419
|
+
? undefined
|
|
420
|
+
: name;
|
|
389
421
|
}
|
|
390
422
|
|
|
391
|
-
const TEST_ID_ATTRIBUTES = [
|
|
423
|
+
export const TEST_ID_ATTRIBUTES = [
|
|
392
424
|
'data-testid',
|
|
393
425
|
'data-test-id',
|
|
394
426
|
'data-test',
|
|
@@ -422,14 +454,19 @@ function controlsIdentifier(control: Element): string | undefined {
|
|
|
422
454
|
}
|
|
423
455
|
|
|
424
456
|
// The id of the form a button or input actually submits, which the browser
|
|
425
|
-
// resolves from its form attribute or its owning form.
|
|
426
|
-
//
|
|
457
|
+
// resolves from its form attribute or its owning form. A role control, such as
|
|
458
|
+
// a styled radio box, belongs to the form around it, read without following a
|
|
459
|
+
// form attribute or leaving its shadow root, since neither makes it part of
|
|
460
|
+
// the form; a link, anchor or role, to none. getAttribute, because a field
|
|
461
|
+
// named "id" shadows form.id.
|
|
427
462
|
function formIdentifier(control: Element): string | undefined {
|
|
428
463
|
const form =
|
|
429
464
|
control instanceof HTMLButtonElement ||
|
|
430
465
|
control instanceof HTMLInputElement
|
|
431
466
|
? control.form
|
|
432
|
-
:
|
|
467
|
+
: control.matches(LINK_CONTROL)
|
|
468
|
+
? null
|
|
469
|
+
: control.closest('form');
|
|
433
470
|
return form ? stableIdentifier(form.getAttribute('id')) : undefined;
|
|
434
471
|
}
|
|
435
472
|
|
|
@@ -691,7 +728,9 @@ export function describeButtonClick(
|
|
|
691
728
|
// aria-label, aria-labelledby or legend, masked like a label and cut at its
|
|
692
729
|
// first colon, since a legend can repeat the pick ("Color: Navy"). Never the
|
|
693
730
|
// option's own text, which is the shopper's answer.
|
|
694
|
-
const NAMED_GROUP =
|
|
731
|
+
const NAMED_GROUP =
|
|
732
|
+
'[role="radiogroup"], [role="listbox"], [role="group"], fieldset, ' +
|
|
733
|
+
'[role="region"][aria-label], [role="region"][aria-labelledby]';
|
|
695
734
|
// A control such as the button that opens a list box shows the option picked.
|
|
696
735
|
const NOT_A_QUESTION =
|
|
697
736
|
CHOICE +
|
|
@@ -740,6 +779,193 @@ function sameQuestion(option: Element, choice: Element): boolean {
|
|
|
740
779
|
);
|
|
741
780
|
}
|
|
742
781
|
|
|
782
|
+
function isTransparent(element: Element, until: Element): boolean {
|
|
783
|
+
return someComposedAncestor(
|
|
784
|
+
element,
|
|
785
|
+
node => window.getComputedStyle(node).opacity === '0',
|
|
786
|
+
until
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Content that is never painted where it sits: SVG that is only referenced or
|
|
791
|
+
// read, and a canvas's fallback, which some browsers still give boxes.
|
|
792
|
+
const UNDRAWN =
|
|
793
|
+
'svg title, svg desc, svg metadata, svg defs, svg symbol, svg clipPath, ' +
|
|
794
|
+
'svg mask, svg pattern, svg marker, svg linearGradient, ' +
|
|
795
|
+
'svg radialGradient, svg filter, canvas';
|
|
796
|
+
|
|
797
|
+
// Whether text sits in any closed details element outside its summary.
|
|
798
|
+
function isInClosedDetails(text: Node, parent: Element): boolean {
|
|
799
|
+
let details = parent.closest('details:not([open])');
|
|
800
|
+
while (details) {
|
|
801
|
+
const summary = Array.from(details.children).filter(
|
|
802
|
+
child => child.tagName === 'SUMMARY'
|
|
803
|
+
)[0];
|
|
804
|
+
if (!summary || !summary.contains(text)) {
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
details = details.parentElement
|
|
808
|
+
? details.parentElement.closest('details:not([open])')
|
|
809
|
+
: null;
|
|
810
|
+
}
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Whether the browser shows an element through every ancestor it renders
|
|
815
|
+
// under, shadow trees included, which a walk of light ancestors can't see
|
|
816
|
+
// into when a shadow root is closed. Where it can't say (checkVisibility
|
|
817
|
+
// arrived in Chrome 105, Firefox 125 and Safari 17.4), the element counts as
|
|
818
|
+
// not shown, so option rows name no group in older browsers.
|
|
819
|
+
function isComposedVisible(element: Element): boolean {
|
|
820
|
+
const check = (element as Element & {
|
|
821
|
+
checkVisibility?: (options: { [key: string]: boolean }) => boolean;
|
|
822
|
+
}).checkVisibility;
|
|
823
|
+
return (
|
|
824
|
+
isFunction(check) &&
|
|
825
|
+
// Older Chrome versions read the first two names, newer browsers the rest.
|
|
826
|
+
check.call(element, {
|
|
827
|
+
checkOpacity: true,
|
|
828
|
+
checkVisibilityCSS: true,
|
|
829
|
+
opacityProperty: true,
|
|
830
|
+
visibilityProperty: true,
|
|
831
|
+
})
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Whether the browser lays a text node out in a box of its own. Text it never
|
|
836
|
+
// draws, such as an SVG definition, canvas fallback, closed details or an
|
|
837
|
+
// unslotted child of a shadow host, has none. Where boxes can't be read, the
|
|
838
|
+
// text counts as not drawn.
|
|
839
|
+
function isDrawn(text: Node): boolean {
|
|
840
|
+
const range = isFunction(document.createRange)
|
|
841
|
+
? document.createRange()
|
|
842
|
+
: null;
|
|
843
|
+
if (!range || !isFunction(range.getClientRects)) {
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
range.selectNodeContents(text);
|
|
847
|
+
return Array.from(range.getClientRects()).some(
|
|
848
|
+
box => box.width > 0 && box.height > 0
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Whether a row shows text of its own: a drawn, shown, non-transparent text
|
|
853
|
+
// node, outside any form field, editable area or opted-out part, beside
|
|
854
|
+
// nothing but the row's own input. Not a row with a shadow root, slot or
|
|
855
|
+
// custom element, whose rendered text a walk of its light children can't see.
|
|
856
|
+
function showsText(row: Element, own: Element): boolean {
|
|
857
|
+
if (
|
|
858
|
+
isUnsafeTextSource(row, own) ||
|
|
859
|
+
hasComposedAncestor(own, '[data-mp-no-track]') ||
|
|
860
|
+
row.shadowRoot ||
|
|
861
|
+
row.tagName.indexOf('-') !== -1 ||
|
|
862
|
+
row.querySelector('slot')
|
|
863
|
+
) {
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
// A row too large to check in full counts as showing nothing.
|
|
867
|
+
const descendants = row.querySelectorAll('*');
|
|
868
|
+
if (descendants.length > MAX_READS) {
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
for (let i = 0; i < descendants.length; i++) {
|
|
872
|
+
if (
|
|
873
|
+
descendants[i].shadowRoot ||
|
|
874
|
+
descendants[i].tagName.indexOf('-') !== -1
|
|
875
|
+
) {
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
|
|
880
|
+
for (let i = 0; i < MAX_READS && walker.nextNode(); i++) {
|
|
881
|
+
const parent = walker.currentNode.parentElement;
|
|
882
|
+
if (
|
|
883
|
+
parent &&
|
|
884
|
+
nonBlank(walker.currentNode.nodeValue) &&
|
|
885
|
+
// Undrawn content, and the body of a closed details element,
|
|
886
|
+
// which browsers still give layout boxes.
|
|
887
|
+
!parent.closest(UNDRAWN) &&
|
|
888
|
+
!isInClosedDetails(walker.currentNode, parent) &&
|
|
889
|
+
isShown(parent, row) &&
|
|
890
|
+
isVisible(parent) &&
|
|
891
|
+
!isTransparent(parent, row) &&
|
|
892
|
+
isComposedVisible(parent) &&
|
|
893
|
+
isDrawn(walker.currentNode)
|
|
894
|
+
) {
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// A native input inside a shown control of its own, such as a menu option row
|
|
902
|
+
// with a styled radio, which shows text of its own and doesn't also hold the
|
|
903
|
+
// option picked.
|
|
904
|
+
function showsRow(option: Element, choice: Element, group: Element): boolean {
|
|
905
|
+
const row =
|
|
906
|
+
option instanceof HTMLInputElement && option.parentElement
|
|
907
|
+
? option.parentElement.closest(CONTROL_SELECTOR)
|
|
908
|
+
: null;
|
|
909
|
+
return (
|
|
910
|
+
!!row &&
|
|
911
|
+
group.contains(row) &&
|
|
912
|
+
!row.contains(choice) &&
|
|
913
|
+
isShown(row, group) &&
|
|
914
|
+
isVisible(row) &&
|
|
915
|
+
!isTransparent(row, group) &&
|
|
916
|
+
showsText(row, option)
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Native boxes with no name, which the browser doesn't group, answer one
|
|
921
|
+
// question only as menu option rows do: each in a shown row of its own, in the
|
|
922
|
+
// same group. A card around a "make default" checkbox and another box has no
|
|
923
|
+
// such rows. Rows alone can't tell a question from a card named for a person
|
|
924
|
+
// around separate settings, so only a group that shows its question and holds
|
|
925
|
+
// the rows directly counts.
|
|
926
|
+
function unnamedOptionRows(
|
|
927
|
+
option: Element,
|
|
928
|
+
choice: Element,
|
|
929
|
+
group: Element
|
|
930
|
+
): boolean {
|
|
931
|
+
return (
|
|
932
|
+
option instanceof HTMLInputElement &&
|
|
933
|
+
choice instanceof HTMLInputElement &&
|
|
934
|
+
option.type === choice.type &&
|
|
935
|
+
!option.getAttribute('name') &&
|
|
936
|
+
!choice.getAttribute('name') &&
|
|
937
|
+
option.form === choice.form &&
|
|
938
|
+
option.closest(NAMED_GROUP) === group &&
|
|
939
|
+
choice.closest(NAMED_GROUP) === group &&
|
|
940
|
+
showsRow(option, choice, group) &&
|
|
941
|
+
showsRow(choice, option, group) &&
|
|
942
|
+
showsQuestion(group)
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// A native input the page hides, styling its label as the option instead, is
|
|
947
|
+
// shown by that label: one of its own, in the group, that the browser draws
|
|
948
|
+
// and that shows text of its own, as an option row must. Neither an opted-out
|
|
949
|
+
// input nor an opted-out label counts.
|
|
950
|
+
function showsLabel(option: Element, choice: Element, group: Element): boolean {
|
|
951
|
+
return (
|
|
952
|
+
option instanceof HTMLInputElement &&
|
|
953
|
+
!!option.labels &&
|
|
954
|
+
!hasComposedAncestor(option, '[data-mp-no-track]') &&
|
|
955
|
+
Array.from(option.labels).some(
|
|
956
|
+
label =>
|
|
957
|
+
group.contains(label) &&
|
|
958
|
+
!label.contains(choice) &&
|
|
959
|
+
!hasComposedAncestor(label, '[data-mp-no-track]') &&
|
|
960
|
+
isShown(label, group) &&
|
|
961
|
+
isVisible(label) &&
|
|
962
|
+
!isTransparent(label, group) &&
|
|
963
|
+
isComposedVisible(label) &&
|
|
964
|
+
showsText(label, option)
|
|
965
|
+
)
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
|
|
743
969
|
// Whether a group offers another answer to the question picked: a shown option
|
|
744
970
|
// to the same question that isn't part of the one picked. A card around one
|
|
745
971
|
// address radio and a "make default" checkbox, or around a role radio and the
|
|
@@ -751,16 +977,16 @@ function holdsOptions(group: Element, choice: Element): boolean {
|
|
|
751
977
|
if (
|
|
752
978
|
!option.contains(choice) &&
|
|
753
979
|
!choice.contains(option) &&
|
|
754
|
-
sameQuestion(option, choice)
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
980
|
+
(sameQuestion(option, choice) ||
|
|
981
|
+
unnamedOptionRows(option, choice, group)) &&
|
|
982
|
+
((isShown(option, group) &&
|
|
983
|
+
isVisible(option) &&
|
|
984
|
+
// A fully transparent option, as a native input behind a
|
|
985
|
+
// styled one often is, shows the shopper no second answer,
|
|
986
|
+
// unless a shown row around it does.
|
|
987
|
+
(!isTransparent(option, group) ||
|
|
988
|
+
showsRow(option, choice, group))) ||
|
|
989
|
+
showsLabel(option, choice, group))
|
|
764
990
|
) {
|
|
765
991
|
return true;
|
|
766
992
|
}
|
|
@@ -788,51 +1014,109 @@ function optionLabels(option: Element, root: Document | ShadowRoot): Element[] {
|
|
|
788
1014
|
return labels;
|
|
789
1015
|
}
|
|
790
1016
|
|
|
791
|
-
// The group asking the question a pick answers. A role radio's
|
|
792
|
-
// radiogroup and an option's its list box; a role toggle, such as
|
|
793
|
-
// stands alone. A native box's is the nearest group offering other
|
|
794
|
-
// since a group around one option, such as a saved address card, can
|
|
795
|
-
// named by that option.
|
|
796
|
-
|
|
1017
|
+
// The name of the group asking the question a pick answers. A role radio's
|
|
1018
|
+
// group is its radiogroup and an option's its list box; a role toggle, such as
|
|
1019
|
+
// a switch, stands alone. A native box's is the nearest group offering other
|
|
1020
|
+
// answers, since a group around one option, such as a saved address card, can
|
|
1021
|
+
// be named by that option. A region with no usable name is passed over, so a
|
|
1022
|
+
// group around it can still name the pick. Each group's name is read once.
|
|
1023
|
+
function groupName(choice: Element): string | undefined {
|
|
797
1024
|
if (!(choice instanceof HTMLInputElement)) {
|
|
798
1025
|
const container = choice.matches('[role~="option"]')
|
|
799
1026
|
? choice.closest('[role="listbox"]')
|
|
800
1027
|
: choice.matches('[role~="radio"]')
|
|
801
1028
|
? choice.closest('[role="radiogroup"]')
|
|
802
1029
|
: null;
|
|
803
|
-
return container && holdsOptions(container, choice)
|
|
1030
|
+
return container && holdsOptions(container, choice)
|
|
1031
|
+
? nameOf(container)
|
|
1032
|
+
: undefined;
|
|
804
1033
|
}
|
|
805
1034
|
let group = choice.closest(NAMED_GROUP);
|
|
806
|
-
while (group
|
|
1035
|
+
while (group) {
|
|
1036
|
+
if (holdsOptions(group, choice)) {
|
|
1037
|
+
const name = nameOf(group);
|
|
1038
|
+
if (name || !group.matches('[role="region"]')) {
|
|
1039
|
+
return name;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
807
1042
|
group = group.parentElement
|
|
808
1043
|
? group.parentElement.closest(NAMED_GROUP)
|
|
809
1044
|
: null;
|
|
810
1045
|
}
|
|
811
|
-
return
|
|
1046
|
+
return undefined;
|
|
812
1047
|
}
|
|
813
1048
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1049
|
+
// Whether what names a group is shown as its question: a fieldset's own
|
|
1050
|
+
// legend, or the header that opens a section. nameOf reads an aria-label first,
|
|
1051
|
+
// so a group carrying one is named by that instead.
|
|
1052
|
+
function showsQuestion(group: Element): boolean {
|
|
1053
|
+
if (group.tagName === 'FIELDSET') {
|
|
1054
|
+
return namedByLegend(group);
|
|
818
1055
|
}
|
|
1056
|
+
return (
|
|
1057
|
+
!nonBlank(group.getAttribute('aria-label')) &&
|
|
1058
|
+
opensRegion(group, labelledBy(group))
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// A fieldset's own legend, a direct child, as the browser reads it.
|
|
1063
|
+
function legendOf(group: Element): Element | undefined {
|
|
1064
|
+
return group.tagName === 'FIELDSET'
|
|
1065
|
+
? Array.from(group.children).filter(
|
|
1066
|
+
child => child.tagName === 'LEGEND'
|
|
1067
|
+
)[0]
|
|
1068
|
+
: undefined;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Whether a fieldset is named by its own legend, which nameOf reads only when
|
|
1072
|
+
// no aria-label overrides it and any aria-labelledby points at that legend.
|
|
1073
|
+
function namedByLegend(group: Element): boolean {
|
|
1074
|
+
const legend = legendOf(group);
|
|
1075
|
+
return (
|
|
1076
|
+
!!legend &&
|
|
1077
|
+
!nonBlank(group.getAttribute('aria-label')) &&
|
|
1078
|
+
(!group.getAttribute('aria-labelledby') || labelledBy(group) === legend)
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// The first element a group's aria-labelledby points at.
|
|
1083
|
+
function labelledBy(group: Element): Element | null {
|
|
1084
|
+
const ids = group.getAttribute('aria-labelledby');
|
|
1085
|
+
const root = (isFunction(group.getRootNode)
|
|
1086
|
+
? group.getRootNode()
|
|
1087
|
+
: document) as Document | ShadowRoot;
|
|
1088
|
+
return ids && root.getElementById
|
|
1089
|
+
? root.getElementById(ids.trim().split(/\s+/)[0])
|
|
1090
|
+
: null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Whether a region is opened by this accordion header: a tab outside any tab
|
|
1094
|
+
// list, holding no control or option of its own.
|
|
1095
|
+
function opensRegion(group: Element, header: Element | null): boolean {
|
|
1096
|
+
return (
|
|
1097
|
+
!!header &&
|
|
1098
|
+
group.matches('[role="region"]') &&
|
|
1099
|
+
(header.getAttribute('role') || '').trim() === 'tab' &&
|
|
1100
|
+
!hasComposedAncestor(header, '[role~="tablist"]') &&
|
|
1101
|
+
!header.querySelector(NOT_A_QUESTION)
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function nameOf(group: Element): string | undefined {
|
|
819
1106
|
let raw = nonBlank(group.getAttribute('aria-label'));
|
|
820
1107
|
if (!raw) {
|
|
821
1108
|
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;
|
|
1109
|
+
const legend = legendOf(group);
|
|
828
1110
|
const root = (isFunction(group.getRootNode)
|
|
829
1111
|
? group.getRootNode()
|
|
830
1112
|
: document) as Document | ShadowRoot;
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1113
|
+
const target = ids ? labelledBy(group) : legend || null;
|
|
1114
|
+
// A region opened by an accordion header is named by the header's
|
|
1115
|
+
// first heading, never the rest of what it shows.
|
|
1116
|
+
const header = ids && opensRegion(group, target) ? target : null;
|
|
1117
|
+
const source = header
|
|
1118
|
+
? header.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]')
|
|
1119
|
+
: target;
|
|
836
1120
|
// A label that is, holds or sits in an option or a control, or in an
|
|
837
1121
|
// option's label, would name the answer.
|
|
838
1122
|
const holder = source ? source.closest('label') : null;
|
|
@@ -848,7 +1132,10 @@ function groupName(choice: Element): string | undefined {
|
|
|
848
1132
|
raw =
|
|
849
1133
|
source &&
|
|
850
1134
|
!labelsOption &&
|
|
851
|
-
!
|
|
1135
|
+
!someComposedAncestor(
|
|
1136
|
+
source,
|
|
1137
|
+
node => node !== header && node.matches(NOT_A_QUESTION)
|
|
1138
|
+
) &&
|
|
852
1139
|
!source.querySelector(NOT_A_QUESTION) &&
|
|
853
1140
|
!(holder && (holder.control || holder.querySelector(CHOICE)))
|
|
854
1141
|
? textOf(source)
|
|
@@ -877,9 +1164,9 @@ function isPaymentQuestion(radio: Element): boolean {
|
|
|
877
1164
|
return question.some(words => PAYMENT_QUESTION.test(nameWords(words)));
|
|
878
1165
|
}
|
|
879
1166
|
|
|
880
|
-
// What names a payment option: the radio's accessible name
|
|
881
|
-
// image of each label,
|
|
882
|
-
function
|
|
1167
|
+
// What names a payment option to shoppers: the radio's accessible name and the
|
|
1168
|
+
// text and image of each label, masked like a label.
|
|
1169
|
+
function shownOptionNames(radio: Element): Array<string | undefined> {
|
|
883
1170
|
const names: Array<string | undefined> = [
|
|
884
1171
|
maskLabel(accessibleName(radio).raw),
|
|
885
1172
|
];
|
|
@@ -893,15 +1180,26 @@ function optionNames(radio: Element): string[] {
|
|
|
893
1180
|
names.push(maskLabel(shownImageAlt(label)));
|
|
894
1181
|
}
|
|
895
1182
|
});
|
|
896
|
-
names
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1183
|
+
return names;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function distinctNames(names: Array<string | undefined>): string[] {
|
|
900
1187
|
return names.filter(
|
|
901
1188
|
(name, index) => !!name && names.indexOf(name) === index
|
|
902
1189
|
);
|
|
903
1190
|
}
|
|
904
1191
|
|
|
1192
|
+
// What names a payment option: the names shoppers read, then its hand-written
|
|
1193
|
+
// ids.
|
|
1194
|
+
function optionNames(radio: Element): string[] {
|
|
1195
|
+
return distinctNames(
|
|
1196
|
+
shownOptionNames(radio).concat(
|
|
1197
|
+
stableIdentifier(radio.getAttribute('id')),
|
|
1198
|
+
firstIdentifier(radio, TEST_ID_ATTRIBUTES)
|
|
1199
|
+
)
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
905
1203
|
// The payment radio a click picks and the names of its option, for the Rokt
|
|
906
1204
|
// kit alone to read a payment method from. A radio's option is the shopper's
|
|
907
1205
|
// answer, so these names never go into the logged click.
|
|
@@ -926,6 +1224,44 @@ export function describePaymentChoice(
|
|
|
926
1224
|
return names.length ? { radio: choice, names } : undefined;
|
|
927
1225
|
}
|
|
928
1226
|
|
|
1227
|
+
const CHECKED_RADIO =
|
|
1228
|
+
'input[type="radio"]:checked, [role~="radio"][aria-checked="true"], [role~="menuitemradio"][aria-checked="true"]';
|
|
1229
|
+
|
|
1230
|
+
// The payment radio the page holds checked, such as a card option checked by
|
|
1231
|
+
// default that the shopper never clicked. A page holding more than one option
|
|
1232
|
+
// names no single method, so it describes nothing. One option drawn twice
|
|
1233
|
+
// counts once: a role radio wrapping its native input, a hidden input a
|
|
1234
|
+
// component library keeps beside its role radio, or one group rendered for two
|
|
1235
|
+
// layouts, whose copies share the names shoppers read but not their ids. An
|
|
1236
|
+
// option shoppers see no name for is told apart by its ids.
|
|
1237
|
+
export function describeCheckedPaymentChoice(): IPaymentChoice | undefined {
|
|
1238
|
+
const radios = Array.from(document.querySelectorAll(CHECKED_RADIO)).filter(
|
|
1239
|
+
radio =>
|
|
1240
|
+
isPicked(radio) &&
|
|
1241
|
+
radio.getAttribute('aria-hidden') !== 'true' &&
|
|
1242
|
+
!hasComposedAncestor(radio, '[data-mp-no-track]') &&
|
|
1243
|
+
isPaymentQuestion(radio)
|
|
1244
|
+
);
|
|
1245
|
+
const choices = radios
|
|
1246
|
+
.filter(
|
|
1247
|
+
radio =>
|
|
1248
|
+
!radios.some(other => other !== radio && radio.contains(other))
|
|
1249
|
+
)
|
|
1250
|
+
.map(radio => {
|
|
1251
|
+
const shown = distinctNames(shownOptionNames(radio));
|
|
1252
|
+
const names = optionNames(radio);
|
|
1253
|
+
return {
|
|
1254
|
+
radio,
|
|
1255
|
+
names,
|
|
1256
|
+
option: (shown.length ? shown : names).join('\n'),
|
|
1257
|
+
};
|
|
1258
|
+
});
|
|
1259
|
+
const options = new Set(choices.map(choice => choice.option));
|
|
1260
|
+
return options.size === 1 && choices[0].names.length
|
|
1261
|
+
? { radio: choices[0].radio, names: choices[0].names }
|
|
1262
|
+
: undefined;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
929
1265
|
// Read once the page has handled the click, which may have cancelled it.
|
|
930
1266
|
export function isPicked(radio: Element): boolean {
|
|
931
1267
|
return radio instanceof HTMLInputElement
|