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):
-
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. -
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:
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 toViewport
class) - in the future it might handle "nested" popups (several popups on top of each other)
Viewport
. Responsible for hiding page's scrollbars. Is not created in mode (1).Popup
. Basically an abstract class, that is to be inherited to make use of. Popups are shown by instantiatingPopup
subclass. It has the following components:Overlay
. Semitransparent layer that fits the viewport, and on top of which popup is displayed.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.Header
. Title plus close button at the top of the popup.Buttons
. Block with buttons at the bottom of the popup.
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 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
'sscrollbarState()
method- registers
afterShow
,onUpdateExtents
,beforeDestroy
,afterDestroy
handlers
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.
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:
-
Mananger
. It coordinates things regarding windowresize
event. On receiving the event, it notifiesViewport
(if in mode (2)), thenPopup
(callsViewport
's andPopup
'supdateExtents()
method). -
Viewport
. Updates page wrapper's extents. -
Popup
. It "listens" for other two "events". It calls itsupdateExtents()
method when showing popup, after images has been loaded. And after textarea resize.updateExtents()
method notifiesWrapper
if in mode (2), or updates popup position (left
,top
,position
properties) if in mode (1). -
Wrapper
. In mode (2) popup updates vertical position on receiving the event. Horizontal position is handled automatically, since wrapper is basically a table.
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">×</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');