Serge van den Oever's weblog

How the Sitecore SXA team writes SXA components

July 13, 2020 • ☕️☕️☕️ 13 min read

When you look at the out-of-the-box Sitecore SXA components you can see that the Sitecore SXA team writes most SXA components using jQuery and a small utility API called XA (eXperience Accelerator). In this post, I will dissect how Sitecore writes components and I will show you how to write a simple clock component in TypeScript using the XA API.

Introduction

If you look at the HTML code of an SXA page you will see something like the following scripts loaded at the end of the page body:

<script src="/componentlibrary/-/media/Base-Themes/Core-Libraries/scripts/optimized-min.js?t=20200616T160435Z"></script>
<script src="/componentlibrary/-/media/Base-Themes/XA-API/Scripts/optimized-min.js?t=20200616T160435Z"></script>
<script src="/componentlibrary/-/media/Base-Themes/Main-Theme/scripts/optimized-min.js?t=20200616T160436Z"></script>
<script src="/componentlibrary/-/media/Base-Themes/Components-Theme/Scripts/optimized-min.js?t=20200616T160436Z"></script>
<script src="/componentlibrary/-/media/Themes/DigitalMarketingPlatform/ComponentLibrary/ComponentLibrary/scripts/pre-optimized-min.js?t=20200527T181257Z"></script>

Sitecore loads a set of themes as part of an SXA site. These themes can be of type:

  • Base Themes - The out-of-the-box building blocks provided by Sitecore
  • Themes - An either out-of-the-box theme containing styling (Basic2 or Wireframe) or your custom theme that can be assigned to an SXA site
  • Extension Themes - additional extensions that can be assigned on top of a selected theme through selecting a module in your SXA site. See the blog post Use Extension Themes in the Sitecore Experience Accelerator by Mark van Aalst for more information on extension themes

Dissecting

Themes live in the Sitecore Media Library folder. If you investigate this folder you will see that Sitecore provides the separate JavaScript files in the Scripts folder of a theme. These files are automatically minified and bundled to a file optimized-min.js by Sitecore SXA using the Asset optimizer. It is also possible to do the optimization and bundling yourself. In that case a file pre-optimized-min.js must be placed in the Scripts folder of your theme. When this file is available it will be served, instead of combining the JavaScript files in the folder. This means also that it does not work to add an additional JavaScript file to the Scripts folder and expect it to be loaded by the browser because it will not be included in the resulting bundle. It is possible to add additional bundles in the Scripts folder and reference these bundles explicitly from the HTML code by including a script reference in a page design, a partial design or the metadata partial design; we have done this successfully with ReactJS bundles.

Note that if the theme assigned to an SXA site does not have a CSS bundle, the wireframe CSS bundle will be returned.

When you look at the out-of-the-box Sitecore SXA components you can see that the Sitecore SXA team writes most SXA components using jQuery (part of the xaquery.js file) and that they use a small utility API called XA (eXperience Accelerator).

If we look at the above-loaded JavaScript files we will see the following:

Base Theme: Core-Libraries

Located at: /sitecore/media library/Base Themes/Core Libraries

Consists of the following JavaScript libraries:

  • ie-origin-fix
  • xaquery
  • moment
  • lo-dash
  • modernizr
  • galleria-157
  • fullcalendar
  • gcal
  • jqueryuitouch-punchmin
  • hammer
  • backbone-min
  • typeahead
  • jquerymCustomScrollbar
  • flash-polyfill
  • mediaelement-and-player
  • dailymotion
  • facebook
  • soundcloud
  • twitch
  • vimeo

Base Theme: XA-API

Located at: /sitecore/media library/Base Themes/XA API

Consists of the following JavaScript libraries:

  • xa

Base Theme: Main-Theme

Located at: /sitecore/media library/Base Themes/Main Theme

Consists of the following JavaScript libraries:

  • observer
  • partial-design-highlight

Base Theme: Components-Theme

Located at: /sitecore/media library/Base Themes/Components Theme

Consists of the following JavaScript libraries:

  • accessibility
  • component-accordions
  • component-archive
  • component-breadcrumb
  • component-carousel
  • component-container
  • component-disqus
  • component-facebook
  • component-flash
  • component-flip
  • component-fullcalendar
  • component-galleria
  • component-language-selector
  • component-navigation
  • component-overlay
  • component-snippet
  • component-social
  • component-tabs
  • component-toggle
  • component-video
  • component-video-playlist
  • details-polyfill
  • fixheight
  • search-fixheight

Theme: ComponentLibrary

Locates at: /sitecore/media library/Themes/DigitalMarketingPlatform/ComponentLibrary/ComponentLibrary

Consists of our own pre-optimized code. See SXA Umbrella for an example on how to do this.

Especially the XA API base theme and the Component Theme base theme are of interest to see how an SXA component can be written.

SXA API

The SXA API is a small utility library for writing a jQuery based SXA component.

The code of xa.js is small and provides functionality for:

  • initialization, with the ability to register pre-initialization and post-initialization handlers
  • cookie functions
  • a query string utility function
  • a function to register a DOM-changed callback function

Actually nothing you could not have written yourself.

The code of xa.js is as follows:

var XA = XA || (function ($, document) {
    var api = {}, onPreInitHandlers, onPostInitHandlers, modules = {};
    onPreInitHandlers = new Array();
    onPostInitHandlers = new Array();
    api.register = function (name, api, init) {
        modules[name] = {
            name: name,
            api: api,
            init: init || api.init || (function () { })
        };
    };
    api.hasPageModes = function () { return !!(window.Sitecore && window.Sitecore.PageModes); };
    api.registerOnPreInitHandler = function (handler) { onPreInitHandlers.push(handler); };
    api.registerOnPostInitHandler = function (handler) { onPostInitHandlers.push(handler); };
    var initScheduled = false;
    api.init = function () {
        if (!initScheduled) {
            initScheduled = true;
            XA.ready(function () {
                try {
                    for (var name in modules)
                        if (modules.hasOwnProperty(name)) {
                            $xa.each(onPreInitHandlers, function (i, h) { h.process(name, modules[name]); });
                            modules[name].init();
                            $xa.each(onPostInitHandlers, function (i, h) { h.process(name, modules[name]); });
                        }
                }
                finally {
                    initScheduled = false;
                }
            });
        }
    };
    api.ready = function (fn) {
        $(document).ready(fn);
    };
    api.component = {};
    api.connector = {};
    api.cookies = {
        createCookie: function (name, value, days) {
            if (days) {
                var date = new Date();
                date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
                var expires = "; expires=" + date.toUTCString();
            }
            else {
                expires = "";
            }
            document.cookie = name + "=" + value + expires + "; path=/";
        },
        readCookie: function (name) {
            var nameEQ = name + "=";
            var ca = document.cookie.split(';');
            for (var i = 0; i < ca.length; i++) {
                var c = ca[i];
                while (c.charAt(0) == ' ') {
                    c = c.substring(1, c.length);
                }
                if (c.indexOf(nameEQ) == 0) {
                    return c.substring(nameEQ.length, c.length);
                }
            }
            return null;
        },
        removeCookieWarning: function () {
            var cookieWarning = $xa(".privacy-warning");
            cookieWarning.remove();
        }
    };
    api.queryString = {
        getQueryParam: function (variable) {
            if (variable != null) {
                variable = variable.toLocaleLowerCase();
            }
            var query = window.location.search.substring(1);
            var vars = query.split("&");
            for (var i = 0; i < vars.length; i++) {
                var pair = vars[i].split("=");
                if (decodeURIComponent(pair[0].toLocaleLowerCase()) === variable) {
                    return decodeURIComponent(pair[1]);
                }
            }
            return null;
        }
    };
    var MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
    var eventListenerSupported = window.addEventListener;
    api.dom = {
        observeDOM: function (obj, callback) {
            if (MutationObserver) {
                var obs = new MutationObserver(function (mutations) {
                    if (mutations[0].addedNodes.length || mutations[0].removedNodes.length)
                        callback();
                });
                var options = {
                    childList: true,
                    subtree: true
                };
                obs.observe(obj, options);
            }
            else if (eventListenerSupported) {
                obj.addEventListener('DOMNodeInserted', callback, false);
                obj.addEventListener('DOMNodeRemoved', callback, false);
            }
        }
    };
    return api;
})($, document);
XA.init();

The Flip SXA component

One of the simpler out-of-the-box SXA components is the Flip SXA component. It is nice that the SXA team has kept the documentation and comments in the code, which helps in understanding what is going on.

The code for component-flip.js is as follows:

/**
 * Component Flip
 * @module Flip
 * @param  {jQuery} $ Instance of jQuery
 * @return {Object} List of flip methods
 */
XA.component.flip = (function($) {
    /**
     * This object stores all public api methods
     * @type {Object.<Methods>}
     * @memberOf module:Flip
     * @
     * */
    var api = {
            /**
             * equalSideHeight set css "min-height" property with value that is
             * equal to size of bigger flip side 
             * @param {jQuery<DOMElement>} $el  Root DOM element of Flip component wrapped by jQuery 
             * @memberof module:Flip
             * @method equalSideHeight
             */
            equalSideHeight: function($el) {
                var side0 = $el.find(".Side0"),
                    side1 = $el.find(".Side1"),
                    slide0Height = this.calcSlideSizeInToggle(side0),
                    slide1Height = this.calcSlideSizeInToggle(side1),
                    maxHeight = Math.max(slide0Height, slide1Height);
                $el.find(".flipsides").css({ "min-height": maxHeight + "px" });
                side0.add(side1).css({ bottom: 0 });
            },
            /**
             * calcSlideSizeInToggle calculates size of slide content
             * @param {jQuery<DOMElement>} $slide Slide DOM Element of flip component
             * @memberof module:Flip
             * @method calcSlideSizeInToggle
             * @return {number} size
             */
            calcSlideSizeInToggle: function($slide) {
                var child = $slide.find(">div"),
                    size = 0;
                child.each(function(pos, el) {
                    size += $(el).outerHeight(true);
                });
                size += parseInt($slide.css("padding-top"));
                size += parseInt($slide.css("padding-bottom"));
                return size;
            },
            /**
             * equalSideHeightInToggle method that called from component toggle 
             * to make all slides inside same height
             * @param {jQuery<DOMElement>} $el  Root DOM element of Flip component wrapped by jQuery 
             * @memberof module:Flip
             * @method equalSideHeightInToggle
             */
            equalSideHeightInToggle: function($el) {
                var side0 = $el.find(".Side0"),
                    side1 = $el.find(".Side1"),
                    slide0Height = this.calcSlideSizeInToggle(side0),
                    slide1Height = this.calcSlideSizeInToggle(side1),
                    maxHeight = Math.max(slide0Height, slide1Height);
                $el.find(".flipsides").css({ "min-height": maxHeight + "px" });
                side0.add(side1).css({ bottom: 0 });
            }
        };

    function detectMobile() {
        return "ontouchstart" in window;
    }
    /**
     * calcHeightOnResize method calls
     * ["equalSideHeight"]{@link module:Flip.equalSideHeight} method
     * for all initialized Flip components
     * @memberOf module:Flip
     * @method
     * @alias module:Flip.initInstance
     * @private
     */
    function calcHeightOnResize() {
        var flip = $(".flip.initialized");
        flip.each(function() {
            api.equalSideHeight($(this));
        });
    }
    /**
     * initInstance method binds toggling "active" class for component
     * an Flip element
     * @memberOf module:Flip
     * @method
     * @param {jQuery} component Root DOM element of flip component wrapped by jQuery
     * @alias module:Flip.initInstance
     */
    api.initInstance = function(component) {
        // Set tabindex=0 for first header
        component.find('[class*="Side0"]').attr("tabindex", "0");
        //
        if (component.hasClass("flip-hover") && !detectMobile()) {
            component.hover(
                function() {
                    component.addClass("active");
                },
                function() {
                    component.removeClass("active");
                }
            );
        } else {
            component.on("click", function() {
                component.toggleClass("active");
            });
        }
    };
    /**
     * init method calls in a loop for each
     * flip component on a page and runs Flip's
     * ["initInstance"]{@link module:Flip.api.initInstance},
     * ["equalSideHeight"]{@link module:Flip.equalSideHeight} methods.
     * Added watcher to "resize" event on window that call
     * ["calcHeightOnResize"]{@link module:Flip.calcHeightOnResize}
     *
     * @memberOf module:Flip
     * @alias module:Flip.init
     */
    api.init = function() {
        var flip = $(".flip:not(.initialized)");
        $(window).on("resize", function() {
            calcHeightOnResize();
        });
        flip.each(function() {
            var $flipModule = $(this).find(".flipsides");
            $flipModule.find(".Side0").attr("tabindex", "0");
            api.initInstance($(this));
            $(this).addClass("initialized");
            api.equalSideHeight($(this));
        });
    };
    return api;
})(jQuery, document);

XA.register("flip", XA.component.flip);

A nice trick used in the registration of components on the page is how in the api.init() function a check is done for the class initialized on each of the elements with the class flip, so the component is not initialized twice. This is important when working in the experience editor where new flip components can be added to the page already containing flip components.

Writing SXA components using TypeScript

Within our company we write almost all of our front-end code using TypeScript. This is also possible for SXA components using the XA API. Below I provide a first take for writing a type definition file xa.d.ts for the XA API library. See https://github.com/macaw-interactive/sxa-umbrella/blob/master/local_modules/sxa-types/xa.d.ts for the latest version:

interface HashTable<T> {
    [key: string]: T;
}

export interface XAComponent {
    initInstance?: (component: JQuery, module: JQuery) => void
    init?: () => void;
}

export interface XAStatic {
    //
    register: (name: string, api?: any, init?: () => void) => void;
    hasPageModes: () => boolean;
    registerOnPreInitHandler: (handler: () => void) => void;
    registerOnPostInitHandler: (handler: () => void) => void;
    init: () => void;
    ready: (fn: () => void) => void;

    component: HashTable<XAComponent>;
    connector: any;
    cookies: {
        createCookie: (name: string, value: string, days: number) => void,
        readCookie: (name: string) => string | null,
        removeCookieWarning: () => void
    },
    queryString: {
        getQueryParam: (variable: string) => string
    },
    dom: {
        observeDOM: (obj: Node, callback: () => void) => void
    }
}

Writing an SXA component in TypeScript

As an example of a SXA component written in TypeScript I provide a simple clock component called xaclock below. See https://github.com/macaw-interactive/sxa-umbrella/blob/master/Media%20Library/Themes/DigitalMarketingPlatform/ComponentLibrary/ComponentLibrary/src/components/xaclock/xaclock.ts for the latest version:

import { XAStatic, XAComponent } from 'sxa-types/xa';
declare var XA: XAStatic;

import './xaclock.scss';

/**
 * Component XaClock
 * @module XaClock
 * @param  {jQuery} $ Instance of jQuery
 * @return {Object} List of XaClock methods
 */
XA.component.xaClock = (function ($) {
    /**
    * This object stores all public api methods
    * @type {Object.<Methods>}
    * @memberOf module:XaClock
    * @
    * */
    var api: XAComponent = {};

    function clockUpdate(component: JQuery) { 
        const stateContainer = component[0]; // unwrap
        const showTwelve: boolean = jQuery.data(stateContainer, "showTwelve");
        const hourShiftData: string | undefined = component.attr("data-hourshift");
        const hourShift = isNaN(parseInt(hourShiftData, 10)) ? 0 : parseInt(hourShiftData, 10);
        var date = new Date();
        component.css({ 'color': '#fff', 'text-shadow': '0 0 6px #ff0' });
        function addZero(x: number | string) {
            if (x < 10) {
                return x = '0' + x;
            } else {
                return x;
            }
        } 
    
        function twelveHour(x: number) {
            if (x > 12) {
                return x = x - 12; 
            } else if (x == 0) {
                return x = 12;
            } else {
                return x; 
            }
        }
    
        var hours = (date.getHours() + hourShift) % 24;
        if (showTwelve) hours = twelveHour(hours);
        var h = addZero(hours);
        var m = addZero(date.getMinutes());
        var s = addZero(date.getSeconds());
    
        component.text(h + ':' + m + ':' + s)
    }

    /**
    * initInstance method of a XaClock element
    * @memberOf module:XaClock
    * @method
    * @param {jQuery} component Root DOM element of XaClock component wrapped by jQuery
    * @param {jQuery} xaClockModule XaClock inner DOM element of XaClock component wrapped by jQuery
    * @alias module:XaClock.initInstance
    */
    api.initInstance = function (component, xaClockModule) { 
        const stateContainer = component[0]; // unwrap
        jQuery.data(stateContainer, "showTwelve", false);
        $(component).click(() => {
            let showTwelve: boolean = jQuery.data(stateContainer, "showTwelve");
            showTwelve = !showTwelve;
            jQuery.data(stateContainer, "showTwelve", showTwelve);
            console.log("showTwelve:", showTwelve);
        });
        clockUpdate(component);
        setInterval(() => clockUpdate(component), 1000);
    };

    /**
     * init method calls in a loop for each
     * XaClock component on a page and runs XaClock's
     * ["initInstance"]{@link module:XaClock.api.initInstance} methods.
     * @memberOf module:XaClock
     * @alias module:XaClock.init
     */
    api.init = function () {
        var $xaClocks = $(".xaClock:not(.initialized)");
        $xaClocks.each(function () {
            var $xaClockModule = $(this);
            // @ts-ignore
            api.initInstance($(this), $xaClockModule);
            $(this).addClass("initialized");
        });
    };

    return api;
})(jQuery);

XA.register("xaclock", XA.component.xaClock);

And a SCSS file xaclock.scss containing the styling:

.xaClock {
    margin: auto;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    width: 200px;
    height: 60px;
    color: #35e00a;
    border: 2px solid #999;
    border-radius: 4px;
    text-align: center;
    font: 50px/60px Helvetica;
    background: linear-gradient(90deg, #000, #5555);
}

Transpiling and bundling the code

Using TypeScript and SASS is nice, but brings the burden of doing transpilation, minification and bundling. That is why I wrote tooling to support a good front-end developer workflow for Sitecore SXA: SXA Umbrella - the project structure and tools to optimize the front-end team development workflow in any Sitecore SXA project (plug plug). Have a look at the extensive documentation in the Github repository, try it out and let me know if it works for you.

Note that the XA API TypeScript type definitions and the xaclock example component are included in the starter template of SXA Umbrella.

Discuss on TwitterEdit on GitHub

Serge van den Oever's weblog

Serge van den Oever

Personal blog by Serge van den Oever - als je maar lol hebt...