Repository: braintree/braintree-web-drop-in
Pull request #647 (2020-08-13 15:20:06)

Add support for shadow DOM in card view
addresses #614

### Summary

Only adds support for the card view.

Depends on an unreleased version of braintree-web to work completely.

* PayPal - should have support in the newest SDK, but not the one we're using currently, so this will have to wait til lv2
* Google Pay - mostly works, just need to figure out how to get the styles to load correctly in the shadow dom instead of in the head. May be as simple as adding the google pay script to the shadow dom, but may need to contact google pay team about it
* Apple Pay - unknown
* Venmo - unknown
* 3D Secure - unknown
* Data Collector - unknown

### Checklist

- [x] Added a changelog entry

### Authors
> List GitHub usernames for everyone who contributed to this pull request.

- @crookedneighbor 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e774811..fd6428b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ unreleased
 - Update @braintree/event-emitter to v0.4.0
 - Update @braintree/wrap-promise to v2.1.0
 - Fix issue where `threeDSecureInfo` was not included in `requestPaymentMethod` payload
+- Add support for the shadow DOM for the card view
 
 1.23.0
 ------
diff --git a/src/dropin-model.js b/src/dropin-model.js
index 3bb7741..2e51614 100644
--- a/src/dropin-model.js
+++ b/src/dropin-model.js
@@ -26,6 +26,7 @@ var DEFAULT_PAYMENT_OPTION_PRIORITY = [
 ];
 
 function DropinModel(options) {
+  this.rootNode = options.container;
   this.componentID = options.componentID;
   this.merchantConfiguration = options.merchantConfiguration;
 
@@ -37,6 +38,11 @@ function DropinModel(options) {
   this._options = options;
   this._setupComplete = false;
 
+  while (this.rootNode.parentNode) {
+    this.rootNode = this.rootNode.parentNode;
+  }
+  this.isInShadowDom = this.rootNode.toString() === '[object ShadowRoot]';
+
   EventEmitter.call(this);
 }
 
diff --git a/src/dropin.js b/src/dropin.js
index a1535f5..bf04662 100644
--- a/src/dropin.js
+++ b/src/dropin.js
@@ -286,8 +286,6 @@ Dropin.prototype._initialize = function (callback) {
   var self = this;
   var container = self._merchantConfiguration.container || self._merchantConfiguration.selector;
 
-  self._injectStylesheet();
-
   if (!container) {
     analytics.sendEvent(self._client, 'configuration-error');
     callback(new DropinError('options.container is required.'));
@@ -348,10 +346,13 @@ Dropin.prototype._initialize = function (callback) {
 
   self._model = new DropinModel({
     client: self._client,
+    container: container,
     componentID: self._componentID,
     merchantConfiguration: self._merchantConfiguration
   });
 
+  self._injectStylesheet();
+
   self._model.initialize().then(function () {
     self._model.on('cancelInitialization', function (err) {
       self._dropinWrapper.innerHTML = '';
@@ -760,17 +761,24 @@ Dropin.prototype._removeStylesheet = function () {
 };
 
 Dropin.prototype._injectStylesheet = function () {
-  var stylesheetUrl, assetsUrl;
+  var assetsUrl;
+  var loadStylesheetOptions = {
+    id: constants.STYLESHEET_ID
+  };
 
   if (document.getElementById(constants.STYLESHEET_ID)) { return; }
 
   assetsUrl = this._client.getConfiguration().gatewayConfiguration.assetsUrl;
-  stylesheetUrl = assetsUrl + '/web/dropin/' + VERSION + '/css/dropin@DOT_MIN.css';
+  loadStylesheetOptions.href = assetsUrl + '/web/dropin/' + VERSION + '/css/dropin@DOT_MIN.css';
 
-  assets.loadStylesheet({
-    href: stylesheetUrl,
-    id: constants.STYLESHEET_ID
-  });
+  if (this._model.isInShadowDom) {
+    // if Drop-in is in the shadow DOM, put the
+    // style sheet in the shadow DOM node instead of
+    // in the head of the document
+    loadStylesheetOptions.container = this._model.rootNode;
+  }
+
+  assets.loadStylesheet(loadStylesheetOptions);
 };
 
 /**
diff --git a/src/views/payment-sheet-views/card-view.js b/src/views/payment-sheet-views/card-view.js
index 18bf96d..32b89f8 100644
--- a/src/views/payment-sheet-views/card-view.js
+++ b/src/views/payment-sheet-views/card-view.js
@@ -162,19 +162,19 @@ CardView.prototype._generateHostedFieldsOptions = function () {
     client: this.client,
     fields: {
       number: {
-        selector: this._generateFieldSelector('number'),
+        container: this._getFieldContainer('number'),
         placeholder: generateCardNumberPlaceholder()
       },
       expirationDate: {
-        selector: this._generateFieldSelector('expiration'),
+        container: this._getFieldContainer('expiration'),
         placeholder: this.strings.expirationDatePlaceholder
       },
       cvv: {
-        selector: this._generateFieldSelector('cvv'),
+        container: this._getFieldContainer('cvv'),
         placeholder: addBullets(3)
       },
       postalCode: {
-        selector: this._generateFieldSelector('postal-code')
+        container: this._getFieldContainer('postal-code')
       }
     },
     styles: {
@@ -499,8 +499,14 @@ CardView.prototype._shouldVault = function () {
   return !this.model.isGuestCheckout && this.saveCardInput.checked;
 };
 
-CardView.prototype._generateFieldSelector = function (field) {
-  return '#braintree--dropin__' + this.model.componentID + ' .braintree-form-' + field;
+CardView.prototype._getFieldContainer = function (field) {
+  // we committed to not changing the data-braintree-id fields
+  // so we need to convert this field to the id used in the HTML
+  if (field === 'expiration') {
+    field = 'expiration-date';
+  }
+
+  return this.getElementById(field + '-field-group').querySelector('.braintree-form__hosted-field');
 };
 
 CardView.prototype._onBlurEvent = function (event) {


Test patch:
diff --git a/test/app/shadow-dom.html b/test/app/shadow-dom.html
new file mode 100644
index 0000000..b1232a4
--- /dev/null
+++ b/test/app/shadow-dom.html
@@ -0,0 +1,159 @@
+<!doctype html>
+<html lang="en">
+<head>
+
+<meta charset="utf-8">
+<meta http-equiv="x-ua-compatible" content="IE=Edge"/>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Drop-in demo in Shadow DOM</title>
+<style>
+
+  html {
+    font-family: sans-serif;
+    font-size: 16pt;
+    background: #f9f9f9;
+  }
+
+  body {
+    width: 95%;
+    max-width: 500px;
+    margin: 0 auto;
+  }
+</style>
+
+</head>
+<body>
+<h1>Braintree Drop-in demo in the Shadow DOM</h1>
+
+<drop-in />
+
+<script src="assign-polyfill.js"></script>
+<script src="web/dropin/dev/js/dropin.js"></script>
+
+<script>
+  customElements.define('drop-in',
+    class extends HTMLElement {
+      constructor() {
+        super();
+
+        var shadow = this.attachShadow({mode: 'open'});
+        var style = document.createElement('style');
+        style.textContent = `
+        .hidden {
+          display: none;
+        }
+
+        #dropin-container, #checkout-form {
+          margin-bottom: 1em;
+        }
+
+        #error {
+          background: #fee;
+          color: tomato;
+          padding: 15px;
+        }
+
+        #error:empty {
+          display: none;
+        }
+
+        input[type="submit"], button {
+          font: inherit;
+          padding: 0.5em 1em;
+          border: 1px solid #d9d9d9;
+          background: #f2f2f2;
+          background-image: linear-gradient(-180deg,#f2f2f2 0,#e6e6e6 100%);
+        }
+
+        button[disabled] {
+          display: none;
+        }
+
+        `;
+        var wrapper = document.createElement('div');
+        wrapper.innerHTML = `
+<pre id="error"></pre>
+
+<form id="checkout-form">
+  <div id="drop-in-container"></div>
+
+  <input id="pay-button" type="submit" value="Pay">
+  <button id="create-button" disabled type="button">Create</button>
+  <button id="teardown-button" type="button">Teardown</button>
+</form>
+
+<pre id="results"></pre>
+        `;
+
+        shadow.appendChild(style);
+        shadow.appendChild(wrapper);
+
+        var dropinContainer = wrapper.querySelector('#drop-in-container');
+        var results = wrapper.querySelector('#results');
+        var error = wrapper.querySelector('#error');
+        var form = wrapper.querySelector('#checkout-form');
+        var createButton = wrapper.querySelector('#create-button');
+        var teardownButton = wrapper.querySelector('#teardown-button');
+        var dropinInstance;
+
+        function createDropin () {
+          results.textContent = '';
+          error.textContent = '';
+
+          braintree.dropin.create({
+            authorization: 'sandbox_f252zhq7_hh4cpc39zq4rgjcg',
+            container: dropinContainer
+          }).then(function (instance) {
+            dropinInstance = instance;
+
+            teardownButton.removeAttribute('disabled');
+            createButton.setAttribute('disabled', true);
+
+          }).catch(function (err) {
+            error.textContent = err.message;
+          });
+        }
+
+        form.addEventListener('submit', function (event) {
+          event.preventDefault();
+
+          dropinInstance.requestPaymentMethod().then(function (payload) {
+            results.textContent = JSON.stringify(payload, null, 2);
+            error.textContent = '';
+          }).catch(function (err) {
+            error.textContent = err.message;
+            results.textContent = '';
+          });
+        }, false);
+
+        createButton.addEventListener('click', createDropin);
+        form.addEventListener('submit', function (event) {
+          event.preventDefault();
+
+          dropinInstance.requestPaymentMethod().then(function (payload) {
+            results.textContent = JSON.stringify(payload, null, 2);
+            error.textContent = '';
+          }).catch(function (err) {
+            error.textContent = err.message;
+            results.textContent = '';
+          });
+        }, false);
+
+        teardownButton.addEventListener('click', function () {
+          teardownButton.setAttribute('disabled', true);
+          dropinInstance.teardown().then(function () {
+            results.textContent = 'Teardown successful';
+            createButton.removeAttribute('disabled');
+          }).catch(function (err) {
+            error.textContent = 'Failed to tear down:' + err;
+          })
+        });
+
+        createDropin();
+      }
+    }
+  );
+</script>
+
+</body>
+</html>
diff --git a/test/helpers/fake.js b/test/helpers/fake.js
index ef28fb2..d08bf94 100644
--- a/test/helpers/fake.js
+++ b/test/helpers/fake.js
@@ -104,6 +104,7 @@ function model(options) {
   var modelInstance;
 
   options = options || modelOptions();
+  options.container = options.container || document.createElement('div');
 
   modelInstance = new DropinModel(options);
 
diff --git a/test/unit/dropin-model.js b/test/unit/dropin-model.js
index 4306b7a..64cc904 100644
--- a/test/unit/dropin-model.js
+++ b/test/unit/dropin-model.js
@@ -29,6 +29,7 @@ describe('DropinModel', () => {
     };
     jest.spyOn(vaultManager, 'create').mockResolvedValue(testContext.vaultManager);
     testContext.modelOptions = {
+      container: document.createElement('div'),
       client: fake.client(testContext.configuration),
       componentID: 'foo123',
       merchantConfiguration: {
@@ -70,6 +71,28 @@ describe('DropinModel', () => {
       expect(model.merchantConfiguration).toBe(testContext.modelOptions.merchantConfiguration);
     });
 
+    test('it sets isInShadowDom to false when the container is not in the shadow DOM', () => {
+      const model = new DropinModel(testContext.modelOptions);
+
+      expect(model.isInShadowDom).toBe(false);
+    });
+
+    test('it sets isInShadowDom to true when the container is in the shadow DOM', () => {
+      const container = document.createElement('div');
+      const insideShadowDOMWrapper = document.createElement('div');
+      const dropinContainer = document.createElement('div');
+      const shadowDom = container.attachShadow({ mode: 'open' });
+
+      insideShadowDOMWrapper.appendChild(dropinContainer);
+      shadowDom.appendChild(insideShadowDOMWrapper);
+
+      testContext.modelOptions.container = dropinContainer;
+
+      const model = new DropinModel(testContext.modelOptions);
+
+      expect(model.isInShadowDom).toBe(true);
+    });
+
     describe('isGuestCheckout', () => {
       test('is true when given a tokenization key', () => {
         let model;
diff --git a/test/unit/dropin.js b/test/unit/dropin.js
index c7bcee0..4fb2129 100644
--- a/test/unit/dropin.js
+++ b/test/unit/dropin.js
@@ -323,6 +323,8 @@ describe('Dropin', () => {
     });
 
     test('injects stylesheet with correct id', done => {
+      jest.spyOn(assets, 'loadStylesheet');
+
       const instance = new Dropin(testContext.dropinOptions);
 
       instance._initialize(() => {
@@ -331,6 +333,46 @@ describe('Dropin', () => {
         expect(stylesheet).toBeDefined();
         expect(stylesheet.href).toMatch(/assets\.braintreegateway\.com/);
 
+        expect(assets.loadStylesheet).toBeCalledTimes(1);
+        expect(assets.loadStylesheet).toBeCalledWith({
+          id: constants.STYLESHEET_ID,
+          href: expect.stringContaining('assets.braintreegateway.com')
+        });
+
+        done();
+      });
+    });
+
+    test('injects stylesheet into shadow DOM instead of the head', done => {
+      jest.spyOn(assets, 'loadStylesheet');
+
+      const container = document.createElement('div');
+      const insideShadowDOMWrapper = document.createElement('div');
+      const dropinContainer = document.createElement('div');
+      const shadowDom = container.attachShadow({ mode: 'open' });
+
+      dropinContainer.id = 'dropin';
+      insideShadowDOMWrapper.appendChild(dropinContainer);
+      shadowDom.appendChild(insideShadowDOMWrapper);
+
+      document.body.appendChild(container);
+      testContext.dropinOptions.merchantConfiguration.container = dropinContainer;
+
+      const instance = new Dropin(testContext.dropinOptions);
+
+      instance._initialize(() => {
+        const stylesheet = shadowDom.querySelector(`#${constants.STYLESHEET_ID}`);
+
+        expect(stylesheet).toBeDefined();
+        expect(stylesheet.href).toMatch(/assets\.braintreegateway\.com/);
+
+        expect(assets.loadStylesheet).toBeCalledTimes(1);
+        expect(assets.loadStylesheet).toBeCalledWith({
+          id: constants.STYLESHEET_ID,
+          href: expect.stringContaining('assets.braintreegateway.com'),
+          container: shadowDom
+        });
+
         done();
       });
     });
diff --git a/test/unit/views/payment-sheet-views/card-view.js b/test/unit/views/payment-sheet-views/card-view.js
index a44b0d6..c2535cb 100644
--- a/test/unit/views/payment-sheet-views/card-view.js
+++ b/test/unit/views/payment-sheet-views/card-view.js
@@ -838,7 +838,7 @@ describe('CardView', () => {
       return model.initialize().then(() => {
         testContext.context = {
           element: testContext.element,
-          _generateFieldSelector: CardView.prototype._generateFieldSelector,
+          _getFieldContainer: CardView.prototype._getFieldContainer,
           _generateHostedFieldsOptions: CardView.prototype._generateHostedFieldsOptions,
           _validateForm: jest.fn(),
           _sendRequestableEvent: CardView.prototype._sendRequestableEvent,