diff --git a/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/app.js b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/app.js
index e50c30ed..c50caf3f 100755
--- a/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/app.js
+++ b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/app.js
@@ -1,51 +1,8 @@
-window.dx = window.dx || {};
-window.dx.author = { watch: { functions: [] } };
+// Ignore for code coverage
+/* istanbul ignore file */
-window.dx.author.watch.registerFunction = (func) => {
- window.dx.author.watch.functions.push(func);
-};
-
-const TAG_SCRIPT = 'SCRIPT';
-const WATCH_CONFIG = {
- childList: true,
- subtree: true,
-};
-
-/**
- * Watch for Author mutations and run functions when they meet node type criteria.
- *
- * @param {Array} apps The functions or classes to instantiate when a mutation occurs
- * @param {DOMElment} parent The top level parent to start the observation
- */
-const watch = (document) => {
- const parentToWatch = document.querySelector('body');
-
- const callback = (mutationsList) => {
- mutationsList.forEach((mutation) => {
- // Attempt to cut down on noise from all mutations.
- // An AEM component mutation will have only one added node. No more. No less.
- if (window.dx.author.watch.functions.length > 0 && mutation.addedNodes.length === 1) {
- const addedNode = mutation.addedNodes[0];
- if (addedNode.nodeType === 1 && addedNode.tagName !== TAG_SCRIPT) {
- // Loop through each function and instantiate
- // it with the added node.
- window.dx.author.watch.functions.forEach((app) => {
- app(addedNode);
- });
- }
- }
- });
- };
-
- // Create an observer instance linked to the callback function
- const observer = new MutationObserver(callback);
- observer.observe(parentToWatch, WATCH_CONFIG);
-};
-
-const authorWatch = (document) => {
- if (typeof CQ !== 'undefined' && document) {
- watch(document);
- }
-};
+import { initAuthorVh } from './utils/authorVh';
+import authorWatch from './utils/authorWatch';
authorWatch(document);
+initAuthorVh(document);
diff --git a/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/__mocks__/vh.html b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/__mocks__/vh.html
new file mode 100644
index 00000000..9b12de8a
--- /dev/null
+++ b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/__mocks__/vh.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/__tests__/authorVh.test.js b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/__tests__/authorVh.test.js
new file mode 100644
index 00000000..5e68d8b5
--- /dev/null
+++ b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/__tests__/authorVh.test.js
@@ -0,0 +1,163 @@
+import vhHtml from '../__mocks__/vh.html';
+import {
+ initAuthorVh,
+ findAuthorVhElements,
+ getEditorHeight,
+ setVh,
+ getInitialWidth,
+ getVhAsPx,
+ getBreakpointVh,
+} from '../authorVh';
+
+// Define our innerWidth
+Object.defineProperties(window, {
+ innerWidth: { value: 300, configurable: true, writable: true },
+ innerHeight: { value: 720 },
+});
+
+document.body.innerHTML = vhHtml;
+
+let elements;
+let editorHeight;
+
+beforeAll(() => {
+ elements = findAuthorVhElements(document);
+});
+
+describe('count elements with VH', () => {
+ test('four total elements', () => {
+ expect(window.innerWidth).toEqual(300);
+ expect(elements).toHaveLength(8);
+ });
+});
+
+describe('get editor height', () => {
+ test('height is height minus AEM toolbar', () => {
+ editorHeight = getEditorHeight();
+ expect(editorHeight).toEqual(610);
+ });
+});
+
+describe('setting VH', () => {
+ test('VH is correctly set on first element', () => {
+ const mobileElement = setVh(elements[0], 'mobile', editorHeight);
+ expect(mobileElement.style.minHeight).toEqual('122px');
+ });
+ test('bad data is not set on element', () => {
+ const badElement = document.querySelector('.bad-element');
+ expect(badElement.style.minHeight).toBeFalsy();
+ });
+});
+
+describe('get initial width', () => {
+ test('width should be mobile', () => {
+ const initialWidth = getInitialWidth();
+ expect(initialWidth).toEqual('mobile');
+ });
+
+ test('width should be tablet', () => {
+ Object.defineProperties(window, {
+ innerWidth: { value: 768, configurable: true, writable: true },
+ });
+ const initialWidth = getInitialWidth();
+ expect(initialWidth).toEqual('tablet');
+ });
+ test('width should be desktop', () => {
+ Object.defineProperties(window, {
+ innerWidth: { value: 1366, configurable: true, writable: true },
+ });
+ const initialWidth = getInitialWidth();
+ expect(initialWidth).toEqual('desktop');
+ });
+});
+
+describe('get vh to px', () => {
+ test('vh should not convert to px', () => {
+ const pxValue = getVhAsPx(900, 'foo');
+ expect(pxValue).toBeNull();
+ });
+ test('vh should convert to px', () => {
+ const pxValue = getVhAsPx(900, 66);
+ expect(pxValue).toEqual(594);
+ });
+});
+
+describe('get breakpoint vh as px', () => {
+ test('breakpoint vh should be mobile', () => {
+ const el = elements[2];
+ const pxValueMobile = getBreakpointVh('mobile', window.innerHeight, el.dataset);
+ expect(pxValueMobile.flexVh).toEqual(144);
+ });
+
+ test('breakpoint vh should be tablet', () => {
+ const el = elements[2];
+ const pxValueMobile = getBreakpointVh('tablet', window.innerHeight, el.dataset);
+ expect(pxValueMobile.flexVh).toEqual(288);
+ });
+
+ test('breakpoint vh should be desktop', () => {
+ const el = elements[2];
+ const pxValueMobile = getBreakpointVh('desktop', window.innerHeight, el.dataset);
+ expect(pxValueMobile.flexVh).toEqual(432);
+ });
+});
+
+describe('breakpoint vh with items defined', () => {
+ test('mobile only', () => {
+ const el = elements[4];
+ const { flexVh, itemVhs } = getBreakpointVh('mobile', window.innerHeight, el.dataset);
+ expect(flexVh).toEqual(144);
+ expect(itemVhs).toEqual([720]);
+ });
+
+ test('tablet override', () => {
+ const el = elements[5];
+ const { flexVh, itemVhs } = getBreakpointVh('mobile', window.innerHeight, el.dataset);
+ expect(flexVh).toEqual(144);
+ expect(itemVhs).toEqual([144, null, 288]);
+
+ const { flexVh: flexVh1, itemVhs: itemVhs1 } = getBreakpointVh(
+ 'tablet',
+ window.innerHeight,
+ el.dataset
+ );
+ expect(flexVh1).toEqual(288);
+ expect(itemVhs1).toEqual([null, 108, 648]);
+ });
+
+ test('desktop override + item inheritance', () => {
+ const el = elements[6];
+
+ const item1 = el.querySelector('#i1');
+ const item2 = el.querySelector('#i2');
+ const item3 = el.querySelector('#i3');
+ const item4 = el.querySelector('#i4');
+
+ setVh(el, 'mobile', window.innerHeight, el.dataset);
+ expect(item1.style.minHeight).toBe('');
+ expect(item2.style.minHeight).toBe('');
+ expect(item3.style.minHeight).toBe('712.8px');
+ expect(item4.style.minHeight).toBe('216px');
+
+ setVh(el, 'tablet', window.innerHeight, el.dataset);
+ expect(item1.style.minHeight).toBe('');
+ expect(item2.style.minHeight).toBe('108px');
+ expect(item3.style.minHeight).toBe('712.8px');
+ expect(item4.style.minHeight).toBe('288px');
+
+ setVh(el, 'desktop', window.innerHeight, el.dataset);
+ expect(item1.style.minHeight).toBe('72px');
+ expect(item2.style.minHeight).toBe('108px');
+ expect(item3.style.minHeight).toBe('712.8px');
+ expect(item4.style.minHeight).toBe('288px');
+ });
+});
+
+describe('initAuthorVh', () => {
+ test('four total elements', () => {
+ const addListener = jest.fn();
+ global.matchMedia = () => ({ addListener });
+ initAuthorVh(document);
+ expect(addListener).toHaveBeenCalled();
+ });
+});
diff --git a/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/__tests__/authorWatch.test.js b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/__tests__/authorWatch.test.js
similarity index 97%
rename from apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/__tests__/authorWatch.test.js
rename to apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/__tests__/authorWatch.test.js
index 8eb5deff..1645f5a2 100644
--- a/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/__tests__/authorWatch.test.js
+++ b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/__tests__/authorWatch.test.js
@@ -35,7 +35,8 @@ window.MutationObserver = SimulateMutationObserver;
window.CQ = {};
// use require as import is hoisted
-require('../app');
+const authorWatch = require('../authorWatch.js').default;
+authorWatch(document);
describe('authorWatch', () => {
beforeEach(() => {
diff --git a/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/authorVh.js b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/authorVh.js
new file mode 100644
index 00000000..6c461c4f
--- /dev/null
+++ b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/authorVh.js
@@ -0,0 +1,225 @@
+/**
+ * Author VH
+ * A utility to set VH values as pixels in authoring mode (editor.html)
+ * This is needed because editor.html's iframe cannot handle VH.
+ *
+ * This utility supports:
+ * 1) Initial load of page
+ * 2) Changing the mobile emulator width
+ * 3) Saving dialog and adding new components (through AuthorWatch)
+ */
+
+// Setup Size Names
+const DESKTOP = 'desktop';
+const TABLET = 'tablet';
+const MOBILE = 'mobile';
+
+// Setup Sizing
+const MOBILE_MAX = 599;
+const TABLET_MIN = 600;
+const TABLET_MAX = 1199;
+const DESKTOP_MIN = 1200;
+
+// Setup Media Queries
+const MOBILE_QUERY = `(max-width: ${MOBILE_MAX}px)`;
+const TABLET_QUERY = `(min-width: ${TABLET_MIN}px) and (max-width: ${TABLET_MAX}px)`;
+const DESKTOP_QUERY = `(min-width: ${DESKTOP_MIN}px)`;
+
+const AUTHOR_VH_SELECTOR = '.has-AuthorVh';
+const FLEX_CLASS = 'flex';
+const FLEX_CONTAINER_SELECTOR = '.dexter-FlexContainer';
+const AEM_TOOLBAR_HEIGHT = 110;
+
+/**
+ * Get the equivalent px value for a vh value
+ * @param {number} viewHeight - The current window height in px
+ * @param {number} vhValue - the vh height to convert to px
+ * @returns {number} vhValue in px
+ */
+const getVhAsPx = (viewHeight, vhValue) => {
+ if (!(vhValue > 0)) return null;
+ const vhDecimal = vhValue / 100;
+ const pixelHeight = viewHeight * vhDecimal;
+ return pixelHeight;
+};
+
+const mergeArrays = (arr, mergeArr) => {
+ if (!arr || arr.length === 0) return mergeArr ? [...mergeArr] : [];
+ if (!mergeArr || mergeArr.length === 0) return arr ? [...arr] : [];
+
+ let merged = [...arr].map((val, i) => {
+ if (!(val || val === 0) && mergeArr[i]) {
+ return mergeArr[i];
+ }
+ return val;
+ });
+
+ if (merged.length < mergeArr.length) {
+ // add addtional mergeArr entries to the end of merged
+ merged = [...merged, ...mergeArr.slice(merged.length)];
+ }
+
+ return merged;
+};
+
+/**
+ * Get the equivalent px value for a vh value
+ * Note that this is a curried function requiring viewHeight first
+ * @param {number} viewHeight - The current window height in px
+ * @param {string]} vhs - A comma delimited string of vhs to convert to px
+ * @returns {array[number]} array of vhs in px
+ */
+const getVhPxArray = (mediaName, viewHeight, dataset) => {
+ // If any array slots are empty, need to inherit the values from smaller screen size if defined
+ const mobile = dataset.authorMobileItemsVh && dataset.authorMobileItemsVh.split(',');
+ const tablet = dataset.authorTabletItemsVh && dataset.authorTabletItemsVh.split(',');
+ const desktop = dataset.authorDesktopItemsVh && dataset.authorDesktopItemsVh.split(',');
+
+ let vhValues = [];
+ switch (mediaName) {
+ case DESKTOP:
+ vhValues = mergeArrays(desktop, mergeArrays(tablet, mobile));
+ break;
+ case TABLET:
+ vhValues = mergeArrays(tablet, mobile);
+ break;
+ default:
+ vhValues = mobile;
+ }
+
+ return vhValues && vhValues.map((val) => getVhAsPx(viewHeight, val));
+};
+
+const breakpointSwitch = (mediaName, viewHeight, dataset) => {
+ let vhValue;
+ switch (mediaName) {
+ case DESKTOP:
+ vhValue =
+ getVhAsPx(viewHeight, dataset.authorDesktopVh) ||
+ getVhAsPx(viewHeight, dataset.authorTabletVh) ||
+ getVhAsPx(viewHeight, dataset.authorMobileVh);
+ break;
+ case TABLET:
+ vhValue =
+ getVhAsPx(viewHeight, dataset.authorTabletVh) ||
+ getVhAsPx(viewHeight, dataset.authorMobileVh);
+ break;
+ default:
+ vhValue = getVhAsPx(viewHeight, dataset.authorMobileVh);
+ }
+ return vhValue;
+};
+
+const getBreakpointVh = (mediaName, viewHeight, dataset) => ({
+ flexVh: breakpointSwitch(mediaName, viewHeight, dataset),
+ itemVhs: getVhPxArray(mediaName, viewHeight, dataset),
+});
+
+const getInitialWidth = () => {
+ const { innerWidth } = window;
+ if (innerWidth >= DESKTOP_MIN) {
+ return DESKTOP;
+ }
+ if (innerWidth >= TABLET_MIN && innerWidth <= TABLET_MAX) {
+ return TABLET;
+ }
+ return MOBILE;
+};
+
+/**
+ * Get the parent window height because AEM's content editor
+ * iframe height can change often.
+ */
+const getEditorHeight = () => window.parent.innerHeight - AEM_TOOLBAR_HEIGHT;
+
+const findAuthorVhElements = (el) => {
+ if (el) {
+ const flexContainer =
+ el === document || el.classList.contains(FLEX_CLASS)
+ ? el
+ : el.closest(FLEX_CONTAINER_SELECTOR);
+ return flexContainer ? flexContainer.querySelectorAll(AUTHOR_VH_SELECTOR) : [];
+ }
+ return [];
+};
+
+/**
+ * Set the element VH
+ * @param {HTMLElement} element The element you want to change.
+ * @param {String} mediaName the media breakpoint name.
+ * @param {Number} viewHeight the view height.
+ */
+const setVh = (element, mediaName, viewHeight) => {
+ const { flexVh, itemVhs } = getBreakpointVh(mediaName, viewHeight, element.dataset);
+
+ if (flexVh) {
+ element.style.minHeight = `${flexVh}px`;
+ }
+
+ if (itemVhs && itemVhs.length) {
+ const children = [...element.children];
+ children.forEach((el, i) => {
+ if (el.nodeName === 'CQ') return;
+
+ if (el.classList.contains('newpar')) {
+ el.style.minHeight = '0';
+ } else if (itemVhs[i]) {
+ el.style.minHeight = `${itemVhs[i]}px`;
+ } else {
+ el.style.minHeight = '';
+ }
+ });
+ }
+ return element;
+};
+
+const loopThroughVhElements = (elements, mediaName) => {
+ const viewHeight = getEditorHeight();
+ elements.forEach((element) => {
+ setVh(element, mediaName, viewHeight);
+ });
+};
+
+const setupMediaListeners = () => {
+ const mediaMatches = [
+ { mediaName: MOBILE, mediaQuery: window.matchMedia(MOBILE_QUERY) },
+ { mediaName: TABLET, mediaQuery: window.matchMedia(TABLET_QUERY) },
+ { mediaName: DESKTOP, mediaQuery: window.matchMedia(DESKTOP_QUERY) },
+ ];
+
+ mediaMatches.forEach((match) => {
+ match.mediaQuery.addListener((event) => {
+ if (event.matches) {
+ // Re-find any elments that may have been added
+ // since we initially setup the listener.
+ const elements = findAuthorVhElements(document);
+ loopThroughVhElements(elements, match.mediaName);
+ }
+ });
+ });
+};
+
+const initAuthorVh = (el) => {
+ const elements = findAuthorVhElements(el);
+ if (elements.length > 0) {
+ // Run on first load of page / element
+ loopThroughVhElements(elements, getInitialWidth());
+
+ // AuthorWatch may pass in a single component as the parent so only setup
+ // media query listeners if the passed in element is a document.
+ if (el === document) {
+ setupMediaListeners();
+ }
+ }
+};
+
+export {
+ initAuthorVh,
+ findAuthorVhElements,
+ setVh,
+ getEditorHeight,
+ getInitialWidth,
+ getBreakpointVh,
+ getVhAsPx,
+ getVhPxArray,
+};
diff --git a/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/authorWatch.js b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/authorWatch.js
new file mode 100644
index 00000000..1ef58491
--- /dev/null
+++ b/apps/admin/app/jcr_root/apps/dx/admin/clientlibs/author/src/js/utils/authorWatch.js
@@ -0,0 +1,51 @@
+window.dx = window.dx || {};
+window.dx.author = { watch: { functions: [] } };
+
+window.dx.author.watch.registerFunction = (func) => {
+ window.dx.author.watch.functions.push(func);
+};
+
+const TAG_SCRIPT = 'SCRIPT';
+const WATCH_CONFIG = {
+ childList: true,
+ subtree: true,
+};
+
+/**
+ * Watch for Author mutations and run functions when they meet node type criteria.
+ *
+ * @param {Array} apps The functions or classes to instantiate when a mutation occurs
+ * @param {DOMElment} parent The top level parent to start the observation
+ */
+const watch = (document) => {
+ const parentToWatch = document.querySelector('body');
+
+ const callback = (mutationsList) => {
+ mutationsList.forEach((mutation) => {
+ // Attempt to cut down on noise from all mutations.
+ // An AEM component mutation will have only one added node. No more. No less.
+ if (window.dx.author.watch.functions.length > 0 && mutation.addedNodes.length === 1) {
+ const addedNode = mutation.addedNodes[0];
+ if (addedNode.nodeType === 1 && addedNode.tagName !== TAG_SCRIPT) {
+ // Loop through each function and instantiate
+ // it with the added node.
+ window.dx.author.watch.functions.forEach((app) => {
+ app(addedNode);
+ });
+ }
+ }
+ });
+ };
+
+ // Create an observer instance linked to the callback function
+ const observer = new MutationObserver(callback);
+ observer.observe(parentToWatch, WATCH_CONFIG);
+};
+
+const authorWatch = (document) => {
+ if (typeof CQ !== 'undefined' && document) {
+ watch(document);
+ }
+};
+
+export default authorWatch;