Skip to content

Instantly share code, notes, and snippets.

@x-yuri
Created March 11, 2024 09:26
Show Gist options
  • Save x-yuri/ddd5e9bd017c7d4b78af078da6e19ed4 to your computer and use it in GitHub Desktop.
Save x-yuri/ddd5e9bd017c7d4b78af078da6e19ed4 to your computer and use it in GitHub Desktop.
Popup classes hierarchy design

Popup classes hierarchy design

I'm developing a site where I need to display popups. For now they're a popup with basket contents and a popup to display item's photos. But I might need more down the road. I've come with a class hierarchy, but I wonder if there're any better ways to split the code into classes.

It might make sense to mention, that one of the design goals was to keep things from moving unnecessarily. For instance, when popup is shown, page behind it must not change. You close the popup, you get to where you were. That might be obvious, but it took more effort than it might seem to be needed at first sight.

And let me make it clear, the code was only tested in Chrome.

I've prepared a test page, where you can get a basic idea of what I'm talking about, experiment with the popups. You can use textareas to change popup's extents. The extents are retained across page reloads. Also, you can leave comments in the PR I created. And apparently, you can inspect the code here.

Now then, let me introduce you to the thing a bit. There are two modes the popups can operate in (basically, two implementations):

  1. Popup is simply put on top of the page. If it fits the viewport, it has position: fixed (fixed position relative to the viewport), if not, position: absolute (fixed position relative to the pge). With the latter being its drawback. Ability to scroll the page while popup is shown (with popup, that is) doesn't look good.

  2. Improved implementation. Page is enclosed in a wrapper. When popup is shown, wrapper shrinks to fit the viewport and its content (the page) is positioned (relative to the wrapper) in a way that it seems like nothing happened (page retains its visual appearance). But as a result, viewport scrollbars disappear. On top of that popup is put. In a wrapper, that fits the viewport. If popup doesn't fit the viewport, wrapper's scrollbars appear.

The whole thing is split into a couple of classes:

  1. Manager. Acts as a public interface for parts of the page that doesn't open any popups, but need to know how popup affects the page. Like footer, which is to be stuck to the bottom of the page. Responsible for:
    • holding a reference to popup being shown
    • listening to keyboard events
    • listening to window resize event, to coordinate handling of the event by all popup's components (deterministic order)
    • hiding scrollbars in mode (2)
    • providing information about the page (pageXOffset, clientHeight, delegates it to Viewport class)
    • in the future it might handle "nested" popups (several popups on top of each other)
  2. Viewport. Responsible for hiding page's scrollbars. Is not created in mode (1).
  3. Popup. Basically an abstract class, that is to be inherited to make use of. Popups are shown by instantiating Popup subclass. It has the following components:
    1. Overlay. Semitransparent layer that fits the viewport, and on top of which popup is displayed.
    2. Wrapper. In mode (2) it's the block that fits the viewport, and it's the wrapper's scrollbars that appear if popup doesn't fit the viewport. In mode (1) it has no presence on the page, just appends popup to the body.
    3. Header. Title plus close button at the top of the popup.
    4. Buttons. Block with buttons at the bottom of the popup.

manager

Manager handles 2 keys: 1. Escape. Closes the popup. 2. Enter. Closes the popup, unless popup decides to handle the key.

Manager has enterHasBeenHandled() method. It's used to prevent manager from handling Enter key press. For instance, consider popup with a list of items with corresponding delete buttons. User tabs to delete button and presses Enter. Item is supposed to be deleted in this case, but manager should not close the popup.

popup

Popup is shown by creating instance of Popup subclass (or Popup class itself in simple cases). In constructor Popup class notifies Manager about the former being created. In response to which Manager hides scrollbars if in mode (2), stores a reference to Popup, and passes some info to it:

  • mode (1 or 2)
  • pageXOffset, pageYOffset methods, which are delegated to viewport if in mode (2)
  • Viewport's scrollbarState() method
  • registers afterShow, onUpdateExtents, beforeDestroy, afterDestroy handlers

wrapper

In mode (2) page wrapper is shrunk to fit viewport minus scrollbar width/height. Should it fit viewport unconditionally, page might change visually when showing popup. For instance, consider page having vertical scrollbar, popup gets shown, viewport scrollbars get hidden, and now page has more room to take horizontally. Right-aligned elements would move scrollbar width to the right.

As a result, popup's wrapper should have the same scrollbars the viewport had. Or else, there might be empty space where viewport scrollbars were. To avoid that, Manager passes Viewport's scrollbarState() method to Popup via options, and when Popup is about to be shown, it creates Wrapper, and passes to the latter scrollbar state it gets from Viewport.

In mode (2) when you click outside of popup (on the wrapper), popup closes. The naive implementation would be to close popup each time wrapper receives click event. But then every time you click on popup itself, popup closes. To counter that, Popup listens to click event, and notifies Wrapper about itself having been clicked. Then when event propagates to Wrapper, it can decide whether to close popup or not. On a side note, it doesn't close popup directly. It calls method passed via onClick option from Popup, which is Popup's destroy() method.

window/content change

One other function popups have, they reposition themselves in response to window resize or popup's content change. The latter may happen in at least two cases:

  • popup has textarea, and user have resized it
  • popup has images, as such popup extents might change after them having been loaded

There are 4 classes that concern themselves with window or content changes:

  1. Mananger. It coordinates things regarding window resize event. On receiving the event, it notifies Viewport (if in mode (2)), then Popup (calls Viewport's and Popup's updateExtents() method).

  2. Viewport. Updates page wrapper's extents.

  3. Popup. It "listens" for other two "events". It calls its updateExtents() method when showing popup, after images has been loaded. And after textarea resize. updateExtents() method notifies Wrapper if in mode (2), or updates popup position (left, top, position properties) if in mode (1).

  4. Wrapper. In mode (2) popup updates vertical position on receiving the event. Horizontal position is handled automatically, since wrapper is basically a table.

footer

Footer is to be stuck to the bottom of the page. That is done by setting min-height for everything but footer: allButFooter.minHeight == viewport.clientHeight (height - scrollbar) - footer.height. If there's little to no content on the page, allButFooter's min-height "moves" the footer to the bottom of the page.

In mode (2), when popup is shown, footer can't just take viewport's client height (height - scrollbar), since it might have changed when showing popup (scrollbar might got hidden). So, it asks Manager, which in its turn asks Viewport about client height viewport had before popup was shown.

Another issue you might encounter with footer in mode (2) reveals itself when you scroll down to the bottom of the page, open popup, then enlarge window vertically. Footer is supposed to follow page's bottom edge. And in this case, the abovementioned formula doesn't work. You've got to add pageYOffset for it to work as just described.

Also, after having closed the popup, you have to set allButFooter.minWidth according to the first formula (without pageYOffset).

In mode (1), you don't add pageYOffset to allButFooter.minHeight when popup is shown. In this case the formula takes the following form: allButFooter.minWidth = max(viewport.clientHeight, popup.bottom) - footer.height.

Additionally, consider a page having min-width. When popup is shown, it might cross right boundary, if not taken care of. In this case, you generally want to increase page's min-width: max(page.origMinWidth, popup.right).

Manager class

class Manager {
    constructor() {
        this._mode = 'hideViewportScrollbars';
        if (this.mode() == 'hideViewportScrollbars')
            this._viewport = new Viewport;
    }

    adopt(popup) {
        if (this.mode() == 'hideViewportScrollbars')
            this._viewport.hideScrollbars();
        this._popup = popup;
        this._popup.options({
            mode: this.mode(),
            pageXOffset: this.pageXOffset.bind(this),
            pageYOffset: this.pageYOffset.bind(this),
            scrollbarState: this._viewport ?
                this._viewport.scrollbarState.bind(this._viewport)
            : null,
            afterShow: () => {
                this._keydownEventHandler = this._keydownEventHandler.bind(this);
                bean.on(window, 'keydown', this._keydownEventHandler);
                this._resizeEventHandler = this._resizeEventHandler.bind(this);
                bean.on(window, 'resize', this._resizeEventHandler);
                bean.fire(popupManager, 'afterShow');
            },
            onUpdateExtents: event => {
                if (event != 'windowResize')
                    bean.fire(popupManager, 'updateExtents', event);
            },
            beforeDestroy: () => {
                bean.fire(popupManager, 'beforeDestroy');
                bean.off(window, 'keydown', this._keydownEventHandler);
                bean.off(window, 'resize', this._resizeEventHandler);
            },
            afterDestroy: () => {
                this._popup = null;
                if (this.mode() == 'hideViewportScrollbars')
                    this._viewport.showScrollbars();
                bean.fire(popupManager, 'afterDestroy');
            },
        });
    }


    _keydownEventHandler(e) {
        if ( ! this._popup)
            return;
        switch (e.key) {
        case 'Enter':
            if (this.enterHasBeenHandled()) {
                this.enterHasBeenHandled(false);
                break;
            }
            if (e.target.tagName == 'TEXTAREA')
                break;
            if (this._popup.onEnter() == false)
                break;
            this._popup.destroy();
            break;

        case 'Escape':
            this._popup.destroy();
            break;
        }
    }

    enterHasBeenHandled(enterHasBeenHandled) {
        if (enterHasBeenHandled != null)
            this._enterHasBeenHandled = enterHasBeenHandled;
        return this._enterHasBeenHandled;
    }

    _resizeEventHandler() {
        if (this.mode() == 'hideViewportScrollbars')
            this._viewport.updateExtents();
        this._popup.updateExtents('windowResize');
        bean.fire(popupManager, 'updateExtents', 'windowResize');
    }


    pageXOffset() {
        return this.mode() == 'hideViewportScrollbars'
            ? this._viewport.pageXOffset()
            : window.pageXOffset;
    }

    pageYOffset() {
        return this.mode() == 'hideViewportScrollbars'
            ? this._viewport.pageYOffset()
            : window.pageYOffset;
    }

    viewportClientHeight() {
        return this.mode() == 'hideViewportScrollbars'
            ? this._viewport.clientHeight()
            : document.documentElement.clientHeight;
    }


    popupShown() {
        return this._popup && this._popup.shown();
    }

    popupBoundingClientRect() {
        return this._popup && this._popup.getBoundingClientRect();
    }


    mode() {
        return this._mode;
    }
}

const popupManager = new Manager;
export default popupManager;

Viewport class

class Viewport {
    constructor() {
        this._shown = true;
        this.$siteInnerContainer = $(document.createElement('div'));
        this.$siteInnerContainer.attr('class', 'site-inner-container');
        while (document.body.childNodes.length) {
            this.$siteInnerContainer[0].appendChild(document.body.childNodes[0]);
        }
        this.$siteOuterContainer = $(document.createElement('div'));
        this.$siteOuterContainer.attr('class', 'site-outer-container');
        this.$siteOuterContainer.append(this.$siteInnerContainer);
        document.body.appendChild(this.$siteOuterContainer[0]);
    }

    showScrollbars() {
        this.$siteInnerContainer.css('width', '');
        this.$siteInnerContainer.css('height', '');
        this.$siteInnerContainer.css('margin-left', '');
        this.$siteInnerContainer.css('margin-top', '');
        this.$siteOuterContainer.css('width', '');
        this.$siteOuterContainer.css('height', '');
        $(document.body).css('min-width', '');
        $(document.body).css('overflow-y', '');
        window.scroll(this.pageXOffset(), this.pageYOffset());
        this._shown = true;
    }

    hideScrollbars() {
        this._vertScrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
        this._horzScrollbarHeight = window.innerHeight - document.documentElement.clientHeight;
        this._siteWidth = document.body.offsetWidth;
        this._siteHeight = document.body.offsetHeight;
        this._pageXOffset = window.pageXOffset;
        this._pageYOffset = window.pageYOffset;
        $(document.body).css('min-width', 'auto');
        $(document.body).css('overflow-y', 'auto');
        this.$siteInnerContainer.css('margin-left', - this._pageXOffset + 'px');
        this.$siteInnerContainer.css('margin-top', - this._pageYOffset + 'px');
        this._shown = false;
        this.updateExtents();
    }

    updateExtents() {
        this.$siteOuterContainer.css('width', window.innerWidth - this._vertScrollbarWidth + 'px');
        this.$siteOuterContainer.css('height', window.innerHeight - this._horzScrollbarHeight + 'px');
        if (this.$siteInnerContainer[0].style.width
        && this.$siteOuterContainer[0].offsetWidth > this._siteWidth - this.pageXOffset())
            this.$siteInnerContainer.css('width', '');
        else if ( ! this.$siteInnerContainer[0].style.width
        && this.$siteOuterContainer[0].offsetWidth < this._siteWidth - this.pageXOffset())
            this.$siteInnerContainer.css('width', this._siteWidth + 'px');
        if (this.$siteInnerContainer[0].style.height
        && this.$siteOuterContainer[0].offsetHeight > this._siteHeight - this.pageYOffset())
            this.$siteInnerContainer.css('height', '');
        else if ( ! this.$siteInnerContainer[0].style.height
        && this.$siteOuterContainer[0].offsetHeight < this._siteHeight - this.pageYOffset())
            this.$siteInnerContainer.css('height', this._siteHeight + 'px');
    }


    scrollbarState() {
        return this.shown()
            ? {vert: window.innerHeight > document.documentElement.clientHeight,
                horz: window.innerWidth > document.documentElement.clientWidth}
            : {vert: this._vertScrollbarWidth > 0, horz: this._horzScrollbarHeight > 0};
    }

    pageXOffset() {
        return this.shown() ? window.pageXOffset : this._pageXOffset;
    }

    pageYOffset() {
        return this.shown() ? window.pageYOffset : this._pageYOffset;
    }

    clientHeight() {
        return this.shown()
            ? document.documentElement.clientHeight
            : this.$siteOuterContainer[0].offsetHeight;
    }

    shown() {
        return this._shown;
    }
}

Popup class

class Popup {
    constructor(options = {}) {
        this._options = {};
        popupManager.adopt(this);
        this.options(options);
        this._overlay = new Overlay({
            onClick: this.destroy.bind(this),
        });

        this.$el = $(document.createElement('div'));
        this.$el.attr('class',
            ['b-popup'].concat(
                [this.options('mode') == 'hideViewportScrollbars'
                    ? 'position-static'
                    : 'position-fixed'],
                this.options('class') instanceof Array
                    ? this.options('class')
                    : [this.options('class')])
            .join(' '));
        this.$el.css('visibility', 'hidden');
        this.$el.html(_.template(
            '<div class="header-place"></div>'
            + '<div class="popup-content">'
                + this.options('content')
            + '</div>'
            + '<div class="buttons-place"></div>'
        )(Object.assign({
            options: this.options(),
        }, this.options('templateVars'))));

        new Header($('.header-place', this.$el), {
            title: this.options('title'),
            onCloseClick: this.destroy.bind(this),
        });

        if (this.options('buttons')) {
            this._buttons = new Buttons($('.buttons-place', this.$el),
                this.options('buttons'));
        }

        this._popupWrapper = new PopupWrapper(this.$el, {
            mode: this.options('mode'),
            scrollbars: (this.options('scrollbarState') || (() => {}))(),
        });

        common.waitForImages(this.$el).then(() => {
            (this.options('beforeShow') || (() => {}))();
            this.updateExtents('popupShow');
            this.$el.css('visibility', 'visible');

            this._textareaResizeEventHandler = textarea => {
                if (this.$el.has(textarea))
                    this.updateExtents('popupResize');
            };
            common.onTextareaResize(this._textareaResizeEventHandler);

            bean.on(this.$el[0], 'click', e => {
                this._popupWrapper.popupClicked(true);
            });
            this._popupWrapper.options({
                onClick: this.destroy.bind(this),
            });

            this._shown = true;
            this.options('afterShow').forEach(callback => callback());
        });
    }

    updateExtents(event) {
        if (this.options('mode') == 'hideViewportScrollbars') {
            this._popupWrapper.updateExtents();
        } else {
            const hadPositionAbsolute = this.$el.hasClass('position-absolute');

            const minClientHeight = parseInt(frontEndVars['popup-min-margin'])
                + this.$el[0].offsetHeight
                + parseInt(frontEndVars['popup-min-margin']);

            const minClientWidth = parseInt(frontEndVars['popup-min-margin'])
                + this.$el[0].offsetWidth
                + parseInt(frontEndVars['popup-min-margin']);

            const willHavePositionAbsolute =
                document.documentElement.clientWidth < minClientWidth
                || document.documentElement.clientHeight < minClientHeight;

            if ( ! (hadPositionAbsolute && willHavePositionAbsolute)) {
                let top = Math.round((
                    document.documentElement.clientHeight - this.$el[0].offsetHeight
                ) / 3);
                if (top < parseInt(frontEndVars['popup-min-margin']))
                    top = parseInt(frontEndVars['popup-min-margin']);
                if (willHavePositionAbsolute)
                    top = this.options('pageYOffset')() + top;
                this.$el.css('top', top + 'px');

                let left = Math.round((
                    document.documentElement.clientWidth - this.$el[0].offsetWidth
                ) / 2);
                if (left < parseInt(frontEndVars['popup-min-margin']))
                    left = parseInt(frontEndVars['popup-min-margin']);
                if (willHavePositionAbsolute)
                    left = this.options('pageXOffset')() + left;
                this.$el.css('left', left + 'px');
            }
            const method = willHavePositionAbsolute ? 'addClass' : 'removeClass';
            this.$el[method]('position-absolute');
        }
        if (this.options('onUpdateExtents'))
            this.options('onUpdateExtents')(event);
    }

    onEnter() {
        this._buttons.press('main-button');
        return false;
    }

    destroy() {
        if (this.options('onDestroy')) {
            const r = this.options('onDestroy')();
            if (r == false)
                return false;
        }
        this.options('beforeDestroy') && this.options('beforeDestroy')();
        common.offTextareaResize(this._textareaResizeEventHandler);
        this._popupWrapper.destroy();
        this._overlay.destroy();
        this._shown = false;
        this.options('afterDestroy') && this.options('afterDestroy')();
    }


    options(options) {
        if (options != null && typeof options != 'string') {
            const afterShow = (this.options('afterShow') || [])
                .concat(options['afterShow'] || []);
            Object.assign(this._options, options, {afterShow: afterShow});
        }
        return options && typeof options == 'string'
            ? this._options[options]
            : this._options;
    }

    getBoundingClientRect() {
        return this.$el[0].getBoundingClientRect();
    }

    shown() {
        return this._shown;
    }
}

Overlay class

class Overlay {
    constructor(options = {}) {
        this._options = options;
        this.$el = $(document.createElement('div'));
        this.$el.attr('class', 'b-popup-overlay');
        document.body.appendChild(this.$el[0]);
        bean.on(this.$el[0], 'click', this._options['onClick']);
    }

    destroy() {
        this.$el.remove();
    }
}

Wrapper class

class Wrapper {
    constructor(popup, options = {}) {
        this.$popup = $(popup);
        this._options = options;
        if (this.options('mode') == 'hideViewportScrollbars') {
            this.$el = $(document.createElement('div'));
            this.$el.attr('class', 'popup-wrapper');
            if (this.options('scrollbars')['vert'])
                this.$el.css('overflow-y', 'scroll');
            if (this.options('scrollbars')['horz'])
                this.$el.css('overflow-x', 'scroll');

            const $popupWrapper2 = $(document.createElement('table'));
            $popupWrapper2.attr('class', 'popup-wrapper-2');

            const row = $popupWrapper2[0].insertRow();

            const $popupWrapper3 = $(row.insertCell());
            $popupWrapper3.attr('class', 'popup-wrapper-3');

            this.$el.append($popupWrapper2);
            $popupWrapper3.append(this.$popup);
            document.body.appendChild(this.$el[0]);

            bean.on(this.$el[0], 'click', () => {
                if ( ! this.popupClicked())
                    this.options('onClick')();
                this.popupClicked(false);
            });
        } else {
            document.body.appendChild(this.$popup[0]);
        }
    }

    popupClicked(popupClicked) {
        if (popupClicked != null)
            this._popupClicked = popupClicked;
        return this._popupClicked;
    }

    updateExtents() {
        if (this.options('mode') == 'hideViewportScrollbars') {
            let paddingTop = (this.$el[0].clientHeight - this.$popup[0].offsetHeight) / 3
                - parseInt(frontEndVars['popup-min-margin']);
            if (paddingTop < 0)
                paddingTop = 0;
            this.$el.css('padding-top', paddingTop + 'px');
        }
    }

    destroy() {
        if (this.options('mode') == 'hideViewportScrollbars')
            this.$el.remove();
        else
            this.$popup.remove();
    }

    options(options) {
        if (options != null && typeof options != 'string')
            Object.assign(this._options, options);
        return options && typeof options == 'string'
            ? this._options[options]
            : this._options;
    }
}

Header class

class Header {
    constructor(el, options = {}) {
        this._options = options;
        this.$el = $(document.createElement('div'));
        this.$el.attr('class', 'b-popup-header');
        this.$el.html(_.template(
            '<span role="button" class="close-button">&#x00d7;</span>'
            + '<div class="title">'
                + '<%- options["title"] %>'
            + '</div>'
        )({options: this._options}));
        $(el).replaceWith(this.$el);
        const $closeButton = $('.close-button', this.$el);
        new Button($closeButton);

        bean.on($closeButton[0], 'press', this._options['onCloseClick']);
    }
}

Buttons class

class Buttons {
    constructor(el, options = {}) {
        this._options = options;
        this.$el = $(document.createElement('div'));
        this.$el.attr('class', 'b-popup-buttons');
        this.$el.html(_.template(
            '<span role="<%- options["mainButton"]["role"] %>"'
            + ' class="main-button b-button">'
                + '<span class="fleck"></span>'
                + '<%- options["mainButton"]["label"] %>'
            + '</span>'
            + '<% if (options["auxiliaryButton"]) { %>'
                + '<span role="<%- options["auxiliaryButton"]["role"] %>"'
                + ' class="auxiliary-button b-button gray">'
                    + '<%- options["auxiliaryButton"]["label"] %>'
                + '</span>'
            + '<% } %>'
        )({options: this._options}));
        $(el).replaceWith(this.$el);

        this.$mainButton = $('.main-button', this.$el);
        this.$auxiliaryButton = $('.auxiliary-button', this.$el);
        const mainButton = new Button(this.$mainButton);
        const auxiliaryButton = this.$auxiliaryButton.length ?
            new Button(this.$auxiliaryButton)
        : null;

        bean.on(this.$mainButton[0], 'press', this._options['mainButton']['onPress']);
        if (this.$auxiliaryButton.length)
            bean.on(this.$auxiliaryButton[0], 'press',
                this._options['auxiliaryButton']['onPress']);
    }

    press(button) {
        const $button = button == 'main-button' ? this.$mainButton : this.$auxiliaryButton;
        bean.fire($button[0], 'press');
    }
}

footer code

let positionFooter;
if (popupManager.mode() == 'hideViewportScrollbars') {
    positionFooter = (event) => {
        // Why add pageYOffset? Consider user scrolling to the bottom of the page,
        // then opening popup, then enlarging the window vertically.
        // Footer is supposed to stick to bottom.
        const minHeight
            = (popupManager.popupShown() ? popupManager.pageYOffset() : 0)
            + popupManager.viewportClientHeight() - $footer[0].offsetHeight;
        $allButFooter.css('min-height', minHeight > 0 ? minHeight + 'px' : '');
    };
    bean.on(window, 'resize', () => { positionFooter('resize') });
    bean.on(popupManager, 'afterDestroy', () => { positionFooter('afterDestroy') });

} else {
    const minSiteWidth = parseFloat($(document.body).css('min-width'));
    function updateSiteMinWidth(event) {
        document.body.style.minWidth = event == 'updateExtents'
            ? Math.max(
                minSiteWidth,
                window.pageXOffset + popupManager.popupBoundingClientRect()['right']
            ) + 'px'
            : '';
    }
    bean.on(popupManager, 'updateExtents', () => { updateSiteMinWidth('updateExtents') });
    bean.on(popupManager, 'afterDestroy', () => { updateSiteMinWidth('afterDestroy') });

    positionFooter = (event) => {
        const minHeight = event == 'updateExtents'
            ? Math.max(
                document.documentElement.clientHeight,
                window.pageYOffset + popupManager.popupBoundingClientRect()['bottom']
            ) - $footer[0].offsetHeight
            : document.documentElement.clientHeight - $footer[0].offsetHeight;
        $allButFooter.css('min-height', minHeight > 0 ? minHeight + 'px' : '');
    };
    bean.on(popupManager, 'updateExtents', () => { positionFooter('updateExtents') });
    bean.on(popupManager, 'afterDestroy', () => { positionFooter('afterDestroy') });
}
positionFooter('load');
$footer.removeClass('invisible');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment