Popup на jQuery c отключением скрола основного контента

Совсем недавно столкнулся с не совсем обычным таском в таскменеджере. ПроджектМенеджер просто напрямую скопировал текст заказчика — «Хочу, чтобы попап был как в вконтакте». Что значит «как в вконтакте» я понял не сразу. «Потестив» попап в соц.сети я понял, чего же от меня хотят — при открытом попапе основной контент не должен скроллиться, но в то же время, если размеры попапа больше, чем окно пользователя — должен скроллиться попап (основной контент естественно так же не скроллиться и в этом случае).
Сложного в этом таске я ничего не увидел и приступил к его выполнени. Логика была следующая — при откртии попапа даем body overflow = hidden, а при закрытии естественно его возвращаем в auto. Сам попап кладем в div-обвертку и даем ему overflow = auto, чтобы в случаях, когда размер окна меньше, чем размер попапа — пользователь мог его скроллить.
Под OS X все работало идеально. Но я не учел одного нюанса — в остальных системах в браузере есть постоянно видимый скролл, который имеет свою ширину. И если просто делать блоку, у которого есть скролл, overflow hidden — скролл пропадает, ширина блока становится больше на ширину скролла и контент «прыгает». Тогда я решил написать плагин, который решает и эту проблему. Еще раз посмотрев, как работает сей чудный попап в вконтакте, я понял логику и приступил к выполнению.

(Мне кажется, сайты, которые не используют jQuery — уже редкость. Поэтому писал я плагин с использованием jQuery)
Так как я хотел написать такой плагин, для работы которого не нужно было дописывать какие-либо конструкции в Html и в css, плагин должен это все сделать сам. Для начала стоит разобраться, какой Html-каркас будет нужен плагину. Так как у попапа должен быть фон, котороый будет перекрывать основной контент, в body должен появиться div, который и будет являться непосредственно самим фоном. Это первый элемент. Второй — обертка попапа. Она нужна для того, чтобы во время, когда у body стоит overflow hidden, если наш попап больше по размеру, чем размер окна пользователя — пользователь мог проскролить контейнер с попапом. Для этого у оболочки будет прописано css свойство overflow равное auto. Теперь нужно организвать работу плагина так, чтобы он подставлял эти объекты в наш html.
За работу с попапом будет отвечать объект _JPopup. У него же есть метод init(), который будет «дергаться» после загрузки страницы. В этом методе плагин и будет добавлять нужные для его работы html теги в body.

$(function () {
    _JPopup.init();
});
var _JPopup = {
    /**
     * div который является фоном попапа
     * jQuery object
     */
    $background: null,

    /**
     * Блок, в котором будут находиться все попапы
     * jQuery object
     */
    $popupBlock: null,

    $body: null,

    init: function () {
        // Наш затухающий фон. Это div, который имеет 100-процентные ширину и высоту
        this.$background = $('<div style="display: none; position: absolute; z-index: 9997; width: 100%; height: 100%; background-color: #000" id="j_background"></div>');
        // оболочка попапа в которую будут вставляться html конструкции
        this.$popupBlock = $('<div style="display:none;position: fixed; height: 100%; width: 100%; z-index: 9999; overflow: auto;" id="j_popup"></div>');

        this.$body = $('body');
        this.$body
            .prepend(this.$background)
            .prepend(this.$popupBlock);
    }
}

Так же при инициализации попапа стоит подумать и о том, как он будет закрываться. Мне кажется, что большинство пользователей уже привыкло к тому, что при нажатии на контент за пределами попапа сам попап должен закрыться. В нашем случае пользователь не сможет нажать на какой-либо контент за пределами блока $popupBlock, так как у него 100-процентные размеры и самый большой z-index. Получается, что нужно закрывать попап после клика на $popupBlock. Для этого добавляем в метод init() следующий код:

var _JPopup = {
    ...
    init: function () {
        ...
        this.$popupBlock.click(function (e) {
    	    _JPopup.hidePopup();
        });
    }
}

Сам метод _JPopup.hidePopup() мы разберем чуть позже.
Теперь приступим к самому интересному — появлению попапа. В первую очередь нам нужно знать, какой блок будет являться попапом. Есть много вариантов, как реализовать указание плагину на то, какой блок нужно брать. К примеру передавать селектор как параметр, либо же передавать непосредственно сам jQuery объект. Мне же нравится работать с методами jQuery и поэтому хотелось получить возможность вызывать появление попапа подобным способом: $(‘#someId’).showPopup(); Для этого добавим метод jQuery:

$.fn.showPopup = function () {
    _JPopup.showPopup($(this));
    return this;
};

Все, что делает данный метод — вызывает метод объекта _JPopup showPopup() и передает ему объект jQuery элемента, к которому была применена функция. Затем, чтобы не нарушать возможность ведения цепочки методов, возвращаем this.
А вот метод _JPopup.showPopup() и будет показывать нужный нам попап.

var _JPopup = {
    ...
    showPopup: function ($self) {
        if($.browser.msie && $.browser.version < 9)
            this.$background.show();
       else
           this.$background.css('opacity',0).show().fadeTo('slow', 0.7);
       this.$background.css({'top': this.$body.scrollTop(),'left': this.$body.scrollLeft()});

       this.$body.css({'overflow':'hidden','width':$(document).width()-this.getScrollBarWidth()});

       var $html = $self.clone();

       this.$popupBlock.html('').append($html).show();

       $html.css({
           'position': 'absolute',
           'z-index': 9999,
           'top':'50%',
           'left':'50%'
       }).show();

       this.setAlign();

       $html.click(function (e) {
           e.stopPropagation();
       });
    }
}

Разберем этот метод. С самого начала мы показываем полупрозрачный фон. В случае, если пользователь использует IE8 и ниже, фон просто появится, если же юзер использует более цивилизованный браузер — фон появится плавно и будет полупрозрачным за счет css свойства opacity. Плавное появление мы добиваемся с помощью метода fadeTo(‘slow’, 0.7). Если контент страницы будет проскроллен, и так как у нашего фона прописан position: absolute, он не перекроет весь контент. Для того, чтобы такого не произошло, изменим его css значения top и left ровно на столько, на сколько был проскроллен документ.
Затем, мы отключаем скролл у body прописывая ‘overflow’:’hidden’ и в то же время даем ему ширину, равную ширине документа минус ширину скролла. Так как в разных ОС и разных браузерах ширина скролла может быть разная (где-то она вообще равна нулю), мы должны ее (ширину) определить. Для этого я воспользовался кодом, который когда-то где-то нашел. Честно говоря уже даже не помню где. Но он до сих пор верно помогает мне в определении ширины скролла. Учитывая, что ширина скролла не может меняться в одном браузере, для того, чтобы каждый раз не тратить время на ее нахождение, мы создадим переменную, равную null, и только в случае если она равна null будем определять ширину скролла и присваивать это значение этой переменной. В таком случае при повторном обращении мы не будем тратить драгоценное время.

var _JPopup = {
    ...
    scrollbarWidth: null,

    getScrollBarWidth: function () {
        if ( this.scrollbarWidth === null ) {
            if ( $.browser.msie && parseInt($.browser.version, 10) === 8) {
                var $textarea1 = $('<textarea cols="10" rows="2"></textarea>')
                    .css({ position: 'absolute', top: -1000, left: -1000 }).appendTo('body'),
                $textarea2 = $('<textarea cols="10" rows="2" style="overflow: hidden;"></textarea>')
                    .css({ position: 'absolute', top: -1000, left: -1000 }).appendTo('body');
                this.scrollbarWidth = $textarea1.width() - $textarea2.width();
                $textarea1.add($textarea2).remove();
            } else {
                var $div = $('<div />')
                    .css({ width: 100, height: 100, overflow: 'auto', position: 'absolute', top: -1000, left: -1000 })
                    .prependTo('body').append('<div />').find('div')
                    .css({ width: '100%', height: 200 });
                this.scrollbarWidth = 100 - $div.width();
                $div.parent().remove();
            }
        }
        return this.scrollbarWidth;
    }
}

После этого мы получаем Html контента попапа. Для этого воспользуемся ф-цией jQuery clone(). Конечно можно было пойти немного иным способом и вставлять в нашу оболочку непосредственно сам объект, к которому применили метод showPopup, а после закрытия попапа возвращать его на место. Но в таком случае, если в попапе есть, к примеру, инпуты — нужно будет следить за их очисткой. В общем мне показалось что это был бы лишний функционал. Итак. Мы получили html нужного контента для попапа. Теперь вставляем его в оболочку. Причем вставляем мы его заменяя весь Html, который был в оболочке. Это нужно для того, чтобы если вдруг там был другой попап — не вывести заодно и его. Ну и методом show() заменяем display: none оболочки на display: block.
Для того, чтобы контент попапа был «выше» всего остального, ему нужно прописать самый большой z-index. А так же нашей задачей является выравнивание попапа по центру. Для этого дадим ему значения left и top равные 50%. Теперь верхний левый угол попапа находится посередине страницы. А для того, чтобы по центру страницы находился центр попапа нужно дать ему отрицательные margin-left и margin-top равные половине его ширины и высоты соответственно. Вынесем эту логику в отдельный метод setAlign:

var _JPopup = {
    ...
    setAlign: function () {
        var marginLeft = this.$popupBlock.children().width()/ 2,
            marginTop = this.$popupBlock.children().height()/2;
        if($(window).width()/2 < marginLeft)
            marginLeft = $(window).width()/2-10;
        if($(window).height()/2 < marginTop)
            marginTop = $(window).height()/2-10;
        this.$popupBlock.children().css({
            'margin-left': -marginLeft,
            'margin-top': -marginTop,
            'padding-bottom': '10px'
        });
    },
}

В этом методе мы учитываем и те случаи, когда размер попапа больше размера окна. В этом случае, если задавать margin-left и margin-top равные половине ширины и высоты попапа умноженные на -1 — часть попапа просто скроется и не будет видна пользователю. Поэтому мы проверяем, если половина ширины окна браузера меньше чем половина ширины попапа (лобо если проще — ширина окна меньше ширины попапа) — ‘margin-left’ будет равен половине ширины окна. Плюс делаем отбивочку в 10 пикселей чтобы попап не прилипал к краю окна.
Хотелось бы вернуться к событию клика по попапу, после которого попап закрывается. Если оставить все как есть — попап будет закрываться даже тогда, когда пользователь будет кликать по контенту попапа. Для решения этой проблемы я воспользовался эфектом всплытия событий в jQuery. Как извесно, если у нас span лежит в div и юзер кликнул по span — сначала сработает событие у спана, а затем произойдет событие у дива. Для решения нашей проблемы мы просто предотвращаем всплытие события методом stopPropagation().
Осталось только, как я и обещал, описать метод закрытия попапа. Он будет совсем несложным:

var _JPopup = {
    ...
    hidePopup: function () {
        this.$popupBlock.html('').hide();
        this.$background.hide();
        this.$body.css('overflow','auto');
        this.$body.css('width','auto');
    },
}

Метод прячет оболочку попапа с помощью метода hide(). Этим же методом он прячет фон попапа. Затем мы возвращаем возможность скролла у body и даем ему первоначальную ширину. И напишем для него внешнуюю функцию, которую можно вызвать для закрытия попапа «вручну». К примеру по клику на кнопку «Закрыть попап»

function closeJPopup() {
    _JPopup.hidePopup();
}

Теперь, к примеру, у нас есть блок

<div id="popup" style="display: none;">
    <div style="width: 300px;height: 300px; background-color: white;">
        some text 
        <input type="text"/>
    </div>
</div>

Все, что нужно для того, чтобы показать его в попапе — вызвать метод $(‘#popup’).showPopup();

Просмотреть работу попапа можно здесь. Там можно проверить как работают попапы как небольших размеров, так и «длинные» попапы — нужно пролистать страницу ниже.
Если кому будет интересно, скачать файл попапа можно по этой ссылке. Там находится более «допиленная» версия плагина. Основное дополнение — возможность передать каллбэк функцию при появлении попапа. А вторым параметром можно передать параметры, которые нужно передать в каллбэк ф-цию. Эта возможность удобна для тех случаев, когда нужно инитить какой-то плагин на контент в попапе. К примеру — JClever.