@mparticle/web-sdk 3.29.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/dist/mparticle.js CHANGED
@@ -204,7 +204,7 @@ var mParticle = (function () {
204
204
  Base64: Base64$1
205
205
  };
206
206
 
207
- var version = "3.29.0";
207
+ var version = "3.29.1";
208
208
 
209
209
  var Constants = {
210
210
  sdkVersion: version,
@@ -11209,6 +11209,9 @@ var mParticle = (function () {
11209
11209
  this.pendingCheckoutIntents = [];
11210
11210
  // Off until SDK init finds the Rokt connection receives auto button clicks.
11211
11211
  this.autoButtonClicksForwarded = false;
11212
+ // Off until SDK init finds the Rokt connection receives auto link clicks, or
11213
+ // link tracking is off. Only link clicks lose their intent without it.
11214
+ this.autoLinkClicksForwarded = false;
11212
11215
  }
11213
11216
  /**
11214
11217
  * Sets a callback to be invoked when RoktManager becomes ready
@@ -11338,6 +11341,14 @@ var mParticle = (function () {
11338
11341
  RoktManager.prototype.setAutoButtonClicksForwarded = function (forwarded) {
11339
11342
  this.autoButtonClicksForwarded = forwarded;
11340
11343
  };
11344
+ RoktManager.prototype.setAutoLinkClicksForwarded = function (forwarded) {
11345
+ this.autoLinkClicksForwarded = forwarded;
11346
+ };
11347
+ // The Rokt kit reads this too: while false, its page views keep
11348
+ // dispatchDelayMs, since a checkout link could never release the wait.
11349
+ RoktManager.prototype.areAutoLinkClicksForwarded = function () {
11350
+ return this.autoLinkClicksForwarded;
11351
+ };
11341
11352
  // noTargeting (which noDeviceId implies), doNotShareOrSell or Global Privacy
11342
11353
  // Control. Unlike noFunctional, these also stop auto button and link clicks
11343
11354
  // from being logged.
@@ -12472,6 +12483,9 @@ var mParticle = (function () {
12472
12483
  }
12473
12484
 
12474
12485
  var DOWNLOAD_EXTENSIONS = /\.(pdf|zip|csv|txt|ics|docx?|xlsx?|pptx?|dmg|exe|apk|mp3|mp4)$/i;
12486
+ // A link run by script reads as a link, as an anchor does, so whoever reads
12487
+ // the click treats it the same.
12488
+ var LINK_CONTROL = 'a[href]:not([role="button"]), [role~="link"]:not([role~="button"])';
12475
12489
  function elementKind(control) {
12476
12490
  if (isWalletElement(control)) {
12477
12491
  return 'wallet';
@@ -12480,9 +12494,7 @@ var mParticle = (function () {
12480
12494
  if (tag === 'button' || tag === 'input') {
12481
12495
  return tag;
12482
12496
  }
12483
- // A link run by script reads as a link, as an anchor does, so whoever reads
12484
- // the click treats it the same.
12485
- return control.matches('a[href]:not([role="button"]), [role~="link"]:not([role~="button"])') ? 'link' : 'role';
12497
+ return control.matches(LINK_CONTROL) ? 'link' : 'role';
12486
12498
  }
12487
12499
  var NOT_RENDERED = 'script, style, template, [hidden], [aria-hidden="true"], [inert]';
12488
12500
  // The most nodes read for one label source, and images looked at for one
@@ -12773,11 +12785,28 @@ var mParticle = (function () {
12773
12785
  var digits = id.replace(/[^0-9]/g, '').length;
12774
12786
  return id.length <= 64 && digits <= 3 && HAND_WRITTEN_ID.test(id) ? id : undefined;
12775
12787
  }
12788
+ // An id React's useId generates, in any of the forms React has used: `:r1:`,
12789
+ // `\u00ABr1\u00BB` and `_r_1_`. On the client it is r and a short base-32 counter; on
12790
+ // the server, R and a base-32 tree id, where a component's later ids add an H
12791
+ // and a counter (`:R1H1:`). A word that only starts with r, such as
12792
+ // `:region:`, is not one.
12793
+ var 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;
12794
+ // What a UI library puts before the React id it names an unnamed group with
12795
+ // (`radix-:r1:`, `react-aria-:R1:`). Left on its own once that id is removed,
12796
+ // it names the library, not the question.
12797
+ var LIBRARY_ID_PREFIX = /^(?:radix|react-aria|headlessui(?:-[a-z]+)*|mui|chakra|reach|ark|mantine|base-ui)$/i;
12776
12798
  // A picker's input name, which names its question. Sites often end it with the
12777
- // id of the product it belongs to (`color-swatches-48213907`); without that
12778
- // number it still names the question.
12799
+ // id of the product it belongs to (`color-swatches-48213907`), or build it
12800
+ // with a React id (`variant_group__R_1mn59b_`); without either it still names
12801
+ // the question.
12779
12802
  function questionName(value) {
12780
- return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
12803
+ if (!value) {
12804
+ return undefined;
12805
+ }
12806
+ var trimmed = value.trim();
12807
+ var withoutReactId = trimmed.replace(REACT_ID, '-');
12808
+ var name = stableIdentifier(withoutReactId.replace(/([-_])[-_]+/g, '$1').replace(/^[-_]+|[-_]+$/g, '').replace(/[-_][0-9]{4,}$/, ''));
12809
+ return name && withoutReactId !== trimmed && LIBRARY_ID_PREFIX.test(name) ? undefined : name;
12781
12810
  }
12782
12811
  var TEST_ID_ATTRIBUTES = ['data-testid', 'data-test-id', 'data-test', 'data-qa', 'data-cy'];
12783
12812
  // The first of these attributes that holds a developer-written identifier.
@@ -12798,10 +12827,13 @@ var mParticle = (function () {
12798
12827
  return stableIdentifier(ariaControls ? ariaControls.trim().split(/\s+/)[0] : null) || firstIdentifier(control, ['popovertarget', 'commandfor']);
12799
12828
  }
12800
12829
  // The id of the form a button or input actually submits, which the browser
12801
- // resolves from its form attribute or its owning form. getAttribute, because a
12802
- // field named "id" shadows form.id.
12830
+ // resolves from its form attribute or its owning form. A role control, such as
12831
+ // a styled radio box, belongs to the form around it, read without following a
12832
+ // form attribute or leaving its shadow root, since neither makes it part of
12833
+ // the form; a link, anchor or role, to none. getAttribute, because a field
12834
+ // named "id" shadows form.id.
12803
12835
  function formIdentifier(control) {
12804
- var form = control instanceof HTMLButtonElement || control instanceof HTMLInputElement ? control.form : null;
12836
+ var form = control instanceof HTMLButtonElement || control instanceof HTMLInputElement ? control.form : control.matches(LINK_CONTROL) ? null : control.closest('form');
12805
12837
  return form ? stableIdentifier(form.getAttribute('id')) : undefined;
12806
12838
  }
12807
12839
  // The browser's normalised type: a button defaults to 'submit' and an unknown
@@ -13118,6 +13150,24 @@ var mParticle = (function () {
13118
13150
  var row = option instanceof HTMLInputElement && option.parentElement ? option.parentElement.closest(CONTROL_SELECTOR) : null;
13119
13151
  return !!row && group.contains(row) && !row.contains(choice) && isShown(row, group) && isVisible(row) && !isTransparent(row, group) && showsText(row, option);
13120
13152
  }
13153
+ // Native boxes with no name, which the browser doesn't group, answer one
13154
+ // question only as menu option rows do: each in a shown row of its own, in the
13155
+ // same group. A card around a "make default" checkbox and another box has no
13156
+ // such rows. Rows alone can't tell a question from a card named for a person
13157
+ // around separate settings, so only a group that shows its question and holds
13158
+ // the rows directly counts.
13159
+ function unnamedOptionRows(option, choice, group) {
13160
+ return option instanceof HTMLInputElement && choice instanceof HTMLInputElement && option.type === choice.type && !option.getAttribute('name') && !choice.getAttribute('name') && option.form === choice.form && option.closest(NAMED_GROUP) === group && choice.closest(NAMED_GROUP) === group && showsRow(option, choice, group) && showsRow(choice, option, group) && showsQuestion(group);
13161
+ }
13162
+ // A native input the page hides, styling its label as the option instead, is
13163
+ // shown by that label: one of its own, in the group, that the browser draws
13164
+ // and that shows text of its own, as an option row must. Neither an opted-out
13165
+ // input nor an opted-out label counts.
13166
+ function showsLabel(option, choice, group) {
13167
+ return option instanceof HTMLInputElement && !!option.labels && !hasComposedAncestor(option, '[data-mp-no-track]') && Array.from(option.labels).some(function (label) {
13168
+ return group.contains(label) && !label.contains(choice) && !hasComposedAncestor(label, '[data-mp-no-track]') && isShown(label, group) && isVisible(label) && !isTransparent(label, group) && isComposedVisible(label) && showsText(label, option);
13169
+ });
13170
+ }
13121
13171
  // Whether a group offers another answer to the question picked: a shown option
13122
13172
  // to the same question that isn't part of the one picked. A card around one
13123
13173
  // address radio and a "make default" checkbox, or around a role radio and the
@@ -13126,11 +13176,11 @@ var mParticle = (function () {
13126
13176
  var options = group.querySelectorAll(CHOICE);
13127
13177
  for (var i = 0; i < options.length; i++) {
13128
13178
  var option = options[i];
13129
- if (!option.contains(choice) && !choice.contains(option) && sameQuestion(option, choice) && isShown(option, group) && isVisible(option) && (
13130
- // A fully transparent option, as a native input behind a styled
13131
- // one often is, shows the shopper no second answer, unless a
13132
- // shown row around it does.
13133
- !isTransparent(option, group) || showsRow(option, choice, group))) {
13179
+ if (!option.contains(choice) && !choice.contains(option) && (sameQuestion(option, choice) || unnamedOptionRows(option, choice, group)) && (isShown(option, group) && isVisible(option) && (
13180
+ // A fully transparent option, as a native input behind a
13181
+ // styled one often is, shows the shopper no second answer,
13182
+ // unless a shown row around it does.
13183
+ !isTransparent(option, group) || showsRow(option, choice, group)) || showsLabel(option, choice, group))) {
13134
13184
  return true;
13135
13185
  }
13136
13186
  }
@@ -13171,18 +13221,48 @@ var mParticle = (function () {
13171
13221
  }
13172
13222
  return undefined;
13173
13223
  }
13224
+ // Whether what names a group is shown as its question: a fieldset's own
13225
+ // legend, or the header that opens a section. nameOf reads an aria-label first,
13226
+ // so a group carrying one is named by that instead.
13227
+ function showsQuestion(group) {
13228
+ if (group.tagName === 'FIELDSET') {
13229
+ return namedByLegend(group);
13230
+ }
13231
+ return !nonBlank(group.getAttribute('aria-label')) && opensRegion(group, labelledBy(group));
13232
+ }
13233
+ // A fieldset's own legend, a direct child, as the browser reads it.
13234
+ function legendOf(group) {
13235
+ return group.tagName === 'FIELDSET' ? Array.from(group.children).filter(function (child) {
13236
+ return child.tagName === 'LEGEND';
13237
+ })[0] : undefined;
13238
+ }
13239
+ // Whether a fieldset is named by its own legend, which nameOf reads only when
13240
+ // no aria-label overrides it and any aria-labelledby points at that legend.
13241
+ function namedByLegend(group) {
13242
+ var legend = legendOf(group);
13243
+ return !!legend && !nonBlank(group.getAttribute('aria-label')) && (!group.getAttribute('aria-labelledby') || labelledBy(group) === legend);
13244
+ }
13245
+ // The first element a group's aria-labelledby points at.
13246
+ function labelledBy(group) {
13247
+ var ids = group.getAttribute('aria-labelledby');
13248
+ var root = isFunction(group.getRootNode) ? group.getRootNode() : document;
13249
+ return ids && root.getElementById ? root.getElementById(ids.trim().split(/\s+/)[0]) : null;
13250
+ }
13251
+ // Whether a region is opened by this accordion header: a tab outside any tab
13252
+ // list, holding no control or option of its own.
13253
+ function opensRegion(group, header) {
13254
+ return !!header && group.matches('[role="region"]') && (header.getAttribute('role') || '').trim() === 'tab' && !hasComposedAncestor(header, '[role~="tablist"]') && !header.querySelector(NOT_A_QUESTION);
13255
+ }
13174
13256
  function nameOf(group) {
13175
13257
  var raw = nonBlank(group.getAttribute('aria-label'));
13176
13258
  if (!raw) {
13177
13259
  var ids = group.getAttribute('aria-labelledby');
13178
- var legend = group.tagName === 'FIELDSET' ? Array.from(group.children).filter(function (child) {
13179
- return child.tagName === 'LEGEND';
13180
- })[0] : undefined;
13260
+ var legend = legendOf(group);
13181
13261
  var root_2 = isFunction(group.getRootNode) ? group.getRootNode() : document;
13182
- var target = ids ? root_2.getElementById ? root_2.getElementById(ids.trim().split(/\s+/)[0]) : null : legend || null;
13183
- // A region opened by an accordion header outside a tab list is named
13184
- // by the header's first heading, never the rest of what it shows.
13185
- var header_1 = target && group.matches('[role="region"]') && (target.getAttribute('role') || '').trim() === 'tab' && !hasComposedAncestor(target, '[role~="tablist"]') && !target.querySelector(NOT_A_QUESTION) ? target : null;
13262
+ var target = ids ? labelledBy(group) : legend || null;
13263
+ // A region opened by an accordion header is named by the header's
13264
+ // first heading, never the rest of what it shows.
13265
+ var header_1 = ids && opensRegion(group, target) ? target : null;
13186
13266
  var source_1 = header_1 ? header_1.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]') : target;
13187
13267
  // A label that is, holds or sits in an option or a control, or in an
13188
13268
  // option's label, would name the answer.
@@ -13748,8 +13828,9 @@ var mParticle = (function () {
13748
13828
  // Bumped on reset so classification queued before it is dropped.
13749
13829
  this.generation = 0;
13750
13830
  this.lastCommitClick = -Infinity;
13751
- // The latest trusted click: whether the page excluded it, and the intent it
13752
- // already carries to the kit, with its source, if any.
13831
+ // The latest trusted click: whether the page excluded it or it was withheld
13832
+ // from the kit, and the intent it already carries to the kit, with its
13833
+ // source, if any.
13753
13834
  this.lastClick = null;
13754
13835
  this.lastSent = {};
13755
13836
  this.pagePath = null;
@@ -13937,11 +14018,19 @@ var mParticle = (function () {
13937
14018
  var excluded = isExcludedPath(path);
13938
14019
  var control = excluded ? undefined : findControl(path, true);
13939
14020
  var logged = control || (excluded ? undefined : findControl(path, true, PATH_LIMIT) || findRoleControl(path, PATH_LIMIT));
14021
+ // A click that link tracking logs as Auto Link Click gives the kit no
14022
+ // intent when the Rokt connection does not receive those events.
14023
+ var withheld = false;
13940
14024
  if (logged) {
13941
14025
  // With links alone, only an anchor can be logged.
13942
- click = logsEvents && (this.logButtons || logged.matches('a[href]')) ? describeButtonClick(logged, path) : undefined;
14026
+ var logsThis = logsEvents && (this.logButtons || logged.matches('a[href]'));
14027
+ var linksWithheld = signalsKit && this.logLinks && !this.linkClicksReachKit();
14028
+ var described = logsThis || linksWithheld ? describeButtonClick(logged, path) : undefined;
14029
+ var linkClick = this.logLinks && !!described && isLinkClick(described);
14030
+ withheld = linksWithheld && linkClick;
14031
+ click = logsThis ? described : undefined;
13943
14032
  if (click) {
13944
- asLink = this.logLinks && isLinkClick(click);
14033
+ asLink = linkClick;
13945
14034
  // Without link tracking, a link that leaves the page stays a
13946
14035
  // button click, except a plain link to another page of the
13947
14036
  // site, which button tracking never logged.
@@ -13954,7 +14043,7 @@ var mParticle = (function () {
13954
14043
  box = logged;
13955
14044
  }
13956
14045
  }
13957
- if (control) {
14046
+ if (control && !withheld) {
13958
14047
  // Checkout intent is only worked out when the kit can use it.
13959
14048
  var intent = signalsKit ? classifyIntent(control, pathname) : undefined;
13960
14049
  kind = intent ? intent.kind : undefined;
@@ -13971,9 +14060,11 @@ var mParticle = (function () {
13971
14060
  checkedNames = checked ? checked.names : undefined;
13972
14061
  }
13973
14062
  }
14063
+ // A withheld click cannot start an Apple Pay session for the kit
14064
+ // either.
13974
14065
  this.lastClick = {
13975
14066
  t: t,
13976
- excluded: excluded,
14067
+ excluded: excluded || withheld,
13977
14068
  kind: kind,
13978
14069
  source: source
13979
14070
  };
@@ -14116,6 +14207,10 @@ var mParticle = (function () {
14116
14207
  var roktManager = this.mpInstance._RoktManager;
14117
14208
  return !this.hasRoktKit || !roktManager || roktManager.isCheckoutIntentSuppressed();
14118
14209
  };
14210
+ ButtonTracker.prototype.linkClicksReachKit = function () {
14211
+ var roktManager = this.mpInstance._RoktManager;
14212
+ return !!roktManager && roktManager.areAutoLinkClicksForwarded();
14213
+ };
14119
14214
  ButtonTracker.prototype.log = function (message) {
14120
14215
  this.mpInstance.Logger.verbose('mParticle button tracking: ' + message);
14121
14216
  };
@@ -15074,6 +15169,7 @@ var mParticle = (function () {
15074
15169
  // Button tracking never runs as a native passthrough.
15075
15170
  resetButtonTracking();
15076
15171
  mpInstance._RoktManager.setAutoButtonClicksForwarded(false);
15172
+ mpInstance._RoktManager.setAutoLinkClicksForwarded(false);
15077
15173
  } else {
15078
15174
  // Main SDK initialization flow
15079
15175
  // Load any settings/identities/attributes from cookie or localStorage
@@ -15124,14 +15220,15 @@ var mParticle = (function () {
15124
15220
  // init or from a returning shopper's landing page view.
15125
15221
  var logButtons = !!getFeatureFlag(AutoButtonTracking);
15126
15222
  var logLinks = !!getFeatureFlag(AutoLinkTracking);
15127
- // Links feed intent too, so with link tracking on the kit must also
15128
- // receive Auto Link Click events.
15223
+ // With link tracking on, a click logged as Auto Link Click feeds intent
15224
+ // only when the kit also receives those events; button clicks still do.
15129
15225
  var buttonClickBlocker = roktConfig_1 ? getButtonClickBlocker(roktConfig_1, kitBlocker) : undefined;
15130
15226
  var linkClickBlocker = roktConfig_1 && logLinks ? getButtonClickBlocker(roktConfig_1, kitBlocker, true) : undefined;
15131
15227
  if (logButtons && (buttonClickBlocker || linkClickBlocker)) {
15132
- mpInstance.Logger.warning(buttonClickBlocker ? "Checkout intent is off: the ".concat(buttonClickBlocker, " keeps some or all Auto Button Click events from the Rokt kit.") : "Checkout intent is off: the ".concat(linkClickBlocker, " keeps some or all Auto Link Click events from the Rokt kit."));
15228
+ mpInstance.Logger.warning(buttonClickBlocker ? "Checkout intent is off: the ".concat(buttonClickBlocker, " keeps some or all Auto Button Click events from the Rokt kit.") : "Checkout intent skips link clicks: the ".concat(linkClickBlocker, " keeps some or all Auto Link Click events from the Rokt kit."));
15133
15229
  }
15134
- mpInstance._RoktManager.setAutoButtonClicksForwarded(!!roktConfig_1 && !buttonClickBlocker && !linkClickBlocker);
15230
+ mpInstance._RoktManager.setAutoButtonClicksForwarded(!!roktConfig_1 && !buttonClickBlocker);
15231
+ mpInstance._RoktManager.setAutoLinkClicksForwarded(!!roktConfig_1 && !linkClickBlocker);
15135
15232
  mpInstance._Forwarders.processForwarders(config, mpInstance._APIClient.prepareForwardingStats);
15136
15233
  mpInstance._Forwarders.processPixelConfigs(config);
15137
15234
  // Checks if session is created, resumed, or needs to be ended
@@ -39,5 +39,6 @@ export declare class ButtonTracker {
39
39
  private eventsEnabled;
40
40
  private hasTargetingOptOut;
41
41
  private isKitSuppressed;
42
+ private linkClicksReachKit;
42
43
  private log;
43
44
  }
@@ -111,6 +111,7 @@ export default class RoktManager {
111
111
  private stopRouteChangeWatch;
112
112
  private pendingCheckoutIntents;
113
113
  private autoButtonClicksForwarded;
114
+ private autoLinkClicksForwarded;
114
115
  constructor(instanceName?: string);
115
116
  /**
116
117
  * Sets a callback to be invoked when RoktManager becomes ready
@@ -137,6 +138,8 @@ export default class RoktManager {
137
138
  private watchRouteChanges;
138
139
  private notifyRouteChange;
139
140
  setAutoButtonClicksForwarded(forwarded: boolean): void;
141
+ setAutoLinkClicksForwarded(forwarded: boolean): void;
142
+ areAutoLinkClicksForwarded(): boolean;
140
143
  hasTargetingOptOut(): boolean;
141
144
  isCheckoutIntentSuppressed(): boolean;
142
145
  isCheckoutIntentLive(): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mparticle/web-sdk",
3
- "version": "3.29.0",
3
+ "version": "3.29.1",
4
4
  "description": "mParticle core SDK for web applications",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -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
- // 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';
72
+ return control.matches(LINK_CONTROL) ? 'link' : 'role';
74
73
  }
75
74
 
76
75
  const NOT_RENDERED =
@@ -388,11 +387,37 @@ function stableIdentifier(value: string | null): string | undefined {
388
387
  : undefined;
389
388
  }
390
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
+
391
402
  // A picker's input name, which names its question. Sites often end it with the
392
- // id of the product it belongs to (`color-swatches-48213907`); without that
393
- // number it still names the question.
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.
394
406
  function questionName(value: string | null): string | undefined {
395
- return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
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;
396
421
  }
397
422
 
398
423
  export const TEST_ID_ATTRIBUTES = [
@@ -429,14 +454,19 @@ function controlsIdentifier(control: Element): string | undefined {
429
454
  }
430
455
 
431
456
  // The id of the form a button or input actually submits, which the browser
432
- // resolves from its form attribute or its owning form. getAttribute, because a
433
- // field named "id" shadows form.id.
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.
434
462
  function formIdentifier(control: Element): string | undefined {
435
463
  const form =
436
464
  control instanceof HTMLButtonElement ||
437
465
  control instanceof HTMLInputElement
438
466
  ? control.form
439
- : null;
467
+ : control.matches(LINK_CONTROL)
468
+ ? null
469
+ : control.closest('form');
440
470
  return form ? stableIdentifier(form.getAttribute('id')) : undefined;
441
471
  }
442
472
 
@@ -887,6 +917,55 @@ function showsRow(option: Element, choice: Element, group: Element): boolean {
887
917
  );
888
918
  }
889
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
+
890
969
  // Whether a group offers another answer to the question picked: a shown option
891
970
  // to the same question that isn't part of the one picked. A card around one
892
971
  // address radio and a "make default" checkbox, or around a role radio and the
@@ -898,13 +977,16 @@ function holdsOptions(group: Element, choice: Element): boolean {
898
977
  if (
899
978
  !option.contains(choice) &&
900
979
  !choice.contains(option) &&
901
- sameQuestion(option, choice) &&
902
- isShown(option, group) &&
903
- isVisible(option) &&
904
- // A fully transparent option, as a native input behind a styled
905
- // one often is, shows the shopper no second answer, unless a
906
- // shown row around it does.
907
- (!isTransparent(option, group) || showsRow(option, choice, group))
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))
908
990
  ) {
909
991
  return true;
910
992
  }
@@ -964,34 +1046,74 @@ function groupName(choice: Element): string | undefined {
964
1046
  return undefined;
965
1047
  }
966
1048
 
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);
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
+
967
1105
  function nameOf(group: Element): string | undefined {
968
1106
  let raw = nonBlank(group.getAttribute('aria-label'));
969
1107
  if (!raw) {
970
1108
  const ids = group.getAttribute('aria-labelledby');
971
- const legend =
972
- group.tagName === 'FIELDSET'
973
- ? Array.from(group.children).filter(
974
- child => child.tagName === 'LEGEND'
975
- )[0]
976
- : undefined;
1109
+ const legend = legendOf(group);
977
1110
  const root = (isFunction(group.getRootNode)
978
1111
  ? group.getRootNode()
979
1112
  : document) as Document | ShadowRoot;
980
- const target = ids
981
- ? root.getElementById
982
- ? root.getElementById(ids.trim().split(/\s+/)[0])
983
- : null
984
- : legend || null;
985
- // A region opened by an accordion header outside a tab list is named
986
- // by the header's first heading, never the rest of what it shows.
987
- const header =
988
- target &&
989
- group.matches('[role="region"]') &&
990
- (target.getAttribute('role') || '').trim() === 'tab' &&
991
- !hasComposedAncestor(target, '[role~="tablist"]') &&
992
- !target.querySelector(NOT_A_QUESTION)
993
- ? target
994
- : null;
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;
995
1117
  const source = header
996
1118
  ? header.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]')
997
1119
  : target;