Еще раз о модальных окнах, или как пофиксить возможные баги (плюс плюшки)

в 22:23, , рубрики: css, javascript, модальные окна, метки: , ,

О модальных окнах написано уже, наверное, тонны литературы, но на написание этого топика меня меня сподвигла вот эта статья на хабре. В ней осталось много недосказанного, в том числе горизонтальные скачки как страницы, так и модального окна.
Конечно, можно было бы просто отписаться в комментарих, мол, сделайте так-то и так-то, и все будет тип-топ. Но мой комментарий разросся до размеров новой статьи, с наглядными примерами и комментариями.
Кому стало интересно — добро пожаловать под хабракат!

Прямо скажу, что данный вид просмотра контента на странице называть «модальным окном» у меня язык не поворачивается. Все-таки это немного разные вещи. Поэтому я буду называть его «облачный контент». Думаю, это наиболее правильное определение для подобного вида отображения контента.

Итак, наши основные задачи:
1) умудриться зафиксировать страницу так, чтобы при горячем изменении ее высоты она не «прыгала» вправо-влево.
2) открыть облачное окно и отцентрировать его так, чтобы и при изменении и его высоты контент на странице оставался на месте.
3) добиться вертикальной прокрутки «облачного окна» при помощи клавиш на клавиатуре «Стрелки вверх и вниз».
4) ранее открытые окна кешировать в DOM, дабы не производить сложных действий и вычислений или не делать лишний запрос к серверу.
5) подключить горячие навигационные клавиши для максимально удобной навигации конечного пользователя.

Пункты 4 и 5 и будут плюшками. Собственно, начнем.
Сразу покажу готовый наглядный пример, чтобы при чтении данной статьи у вас было с чем сравнить и посмотреть, о чем конкретно идет речь.

ДЕМО ПРИМЕР и подключенный к странице JS файл

Для начала мы определим функцию elemID(); Так как конструкция document.getElementById встречается в коде довольно часто, проще укоротить эту запись при помощи подобной функции, что позволяет значительно уменьшить общий вес JS файла. Это особо важно при массивных файлах с большим объемом кода.

function elemID(e) {return document.getElementById(e);};

Заводим объект, с которым будем впоследствии работать:

var Content = {}

Первое, с чего мы начнем — это фиксация страницы. Для этого мы весь контент страницы обернем в блок с id=«layer»

<body id="body">
	<div class="layer" id="layer">
		<!-- здесь прочий контент сайта -->
	</div>
</body>

Готово. Теперь для него пишем метод, центрирующий этот блок:

editSiteLayer:function (content) {
	
	/** центрировать можно любой элемент, но по умолчанию - "layer" **/
	if (!content) {var content = 'layer';}
	
	/** 
	** ширина окна браузера обозревателя 
	** за вычетом 18px - стандартной ширины полосы прокрутки 
	**/
	var inWidth = window.innerWidth-18,
	
	/** ширина центрируемого контента **/
	layerWidth = elemID(content).clientWidth;
	
	/** если оба значения найдены и они не undefined  **/
	if (inWidth && layerWidth) {
		
		/** задание блоку с контентом сайта CSS значения float:left **/
		elemID(content).style.cssFloat = 'left';
		
		/** 
		** чтобы контент сайта не прижимался к левому краю окна браузера - центрирование этого элемента
		** за счет разницы половины ширины окна обозревателя и половины ширины контента 
		**/
		elemID(content).style.marginLeft = (inWidth/2)-(layerWidth/2)+'px';
	}
}

Написали и временно забыли о нем (в конце мы обязательно подключим его).

Вторая наша задача — это создать и открыть «облачное окно».
Открывать облачное окно мы будем так:

<body id="body">
	<div class="cloud" id="cloud">
		<div class="cloud-layer" id="cloud-layer">
			<em class="cloud-contaner" id="cloudpage_уникальный_ID_блока">
				
			</em>
		</div>
	</div>
</body>

Блок «cloud» выполняет роль некой защитной маски и отделяет контент сайта от облачного окна, при этом и делая его «облачным». Его CSS свойства:

.cloud{
	display:block;
	padding:0;
	margin:0;
	position:fixed;
	top:0; left:0; right:0; bottom:0;
	background: rgba(0, 0, 0, 0.795); 
	z-index:3;
	overflow:auto;
}

Блок «cloud-layer» является «родителем» для всех DOM элементов с облачным контентом. Фиксируется он по центру страницы и ему задается свойство position:relative; (именно оно позволяет скроллить окно клавишами клавиатуры).
Его CSS свойства:

.cloud .cloud-layer{
	position:relative;
	float:left;
	width:720px;
	margin:0;
	padding:0;
	z-index:4;
}

Блок «cloud-contaner» — это и есть контейнер для нашего облачного контента. Почему именно в теге EM? Впоследствии мы будем пробегаться по DOM в поиске актуального блока и с целью скрыть все уже существующие. Поиск по тегу для нас подходит как нельзя кстати.

Чтобы понять реальную задачу — представим, что нам хочется просмотреть все личные фотографии пользователя на нашем сайте. Обычно делается так: при первом нажатии на фотографию делается http-запрос к серверу для получения массива всех фотографий, их уникальных ID, описанием и прочей информацией.
Для примера я не буду этого делать, а заранее руками прописал этот массив и приготовил его к работе:

var photoInfo = [];
photoInfo[0] = {'id':5478,'name':'Название фото №1','desc':'Описание фото №1','link':'1347690631.jpg'};
photoInfo[1] = {'id':4198,'name':'Название фото №2','desc':'Описание фото №2','link':'1347691505.jpg'};
photoInfo[2] = {'id':7596,'name':'Название фото №3','desc':'Описание фото №3','link':'1347691550.jpg'};
photoInfo[3] = {'id':98637,'name':'Название фото №4','desc':'Описание фото №4','link':'1347691521.jpg'};

Теперь при открытии окна мы опираемся на уникальный ID фотографии, перебираем массив, выбираем из него нужную информацию по текущему фото, а так же ID предыдущего и следующего фото для навигации. Параллельно пробегаемся по DOM и проверяем, существует ли в DOM актуальный контент. Если существует — открываем его, если нет — генерируем новый блок и вставляем его в облако. Но для начала проверяем, есть ли блок с облаком в DOM или еще нет. Если еще нет и это первое открытие облака — генерация облака, а в нем — генерация родителя облачного контента.

/**
* метод cloudNav() является вспомогательным для метода cloudShow()
* и выполняет функцию определения предыдущей и следующей фотографии
**/
_cloudNav:function (gid) {
	if (photoInfo) {
		var num, prev, next, info, len = photoInfo.length;
		if (len > 0) {
			for(var i=0; i<len; i++) {
				if (photoInfo[i].id == gid) {
					current = i;
					info = photoInfo[i];
					if (len > 1 && (len-1) > i) {next = photoInfo[i+1].id;}
					if (len > 1 && i > 0) {prev = photoInfo[i-1].id;}
					num = i+1;
				}
			}
		}
		return {"len":len,"num":num,"prev":prev,"current":gid,"next":next,"info":info};
	}
},
/**
* метод cloudShow() генерирует облачное окно, определяет контент и показывает пользователю
* gid - уникальный идентификатор фотографии в системе
**/
cloudShow:function (gid) {
	
	/** определение инедтификаторов предыдущей, текущей и следующей фотографии **/
	var arrNav = Content._cloudNav(gid);
	if (arrNav && arrNav.info) {
		
		/** определение идентификатора для горячих навигационных клавиш **/
		if (arrNav.prev !== undefined) {ContentPointPrev = arrNav.prev;} else {ContentPointPrev = null;}
		if (arrNav.next !== undefined) {ContentPointNext = arrNav.next;} else {ContentPointNext = null;}
		
		/** фиксация общей страницы, дабы убить ее прокручивание под облачным контентом и убрать скролл-бар, если он есть **/
		if (elemID('body').style.overflowY != 'hidden') {
			elemID('body').style.overflowX = 'hidden';
			elemID('body').style.overflowY = 'hidden';
		}
		
		/** если блока для воздушного окна еще нет - создание этого блока **/
		if (!elemID('cloud')) {
			
			/** создание основного фиксированного полупрозрачного слоя "cloud" **/
			var box = document.createElement('div');
				box.className = 'cloud';
				box.id = 'cloud';
				elemID('body').appendChild(box);
				
			/**  помещение в слой "cloud" блока-родителя для блоков фотографий **/
			var cloudbox = document.createElement('div');
				cloudbox.className = 'cloud-layer';
				cloudbox.id = 'cloud-layer';
				elemID('cloud').appendChild(cloudbox);
				
			/**  помещение в слой "cloud" навигационного блока для кнопок Вперед/Назад/Закрыть **/
			var navbox = document.createElement('div');
				navbox.id = 'cloud-nav';
				elemID('cloud').appendChild(navbox);
				
			/**  центрирование облачного контента по центру страницы **/
			Content.editSiteLayer('cloud-layer');
			
		/** В противном случае - перебор существующих в нем блоков с тегом EM и скрытие их **/
		} else {
			var ems = elemID('cloud').getElementsByTagName('EM')
			if (ems.length > 0) {
				for(var i=0; i<ems.length; i++) {
					ems[i].style.display = 'none';
				}
			}
		}
		
		/** очищение навигационного блока **/
		elemID('cloud-nav').innerHTML = '';
		
		/** если необходимого блока с контентом еще нет - создание этого блока **/
		if (!elemID('cloudpage_'+gid)) {
			
			var contentbox = document.createElement('em');
				contentbox.className = 'cloud-contaner';
				contentbox.id = 'cloudpage_'+gid;
				elemID('cloud-layer').appendChild(contentbox);
				
			var html = 
			'<div class="cloud-title">'+
			'<div class="cloud-name">'+
			'Фотография '+arrNav.num+' из '+arrNav.len+
			'</div>'+
			'<div class="cloud-close" onclick="return Content.cloudClose();">Закрыть окно</div>'+
			'</div>'+
			'<div class="cloud-body">'+
			'<p><img onclick="'+(arrNav.next !== undefined ? 'return Content.cloudShow('+arrNav.next+')' : 'return Content.cloudClose();')+'" src="images/'+arrNav.info.link+'" alt="" /></p>'+
			'<div class="more-button" onclick="Content.Slide(this, {point:'next', hide:'Нажмите для удлинения страницы', show:'Нажмите для укорачивания страницы'})">'+
			'Нажмите для удлинения страницы'+
			'</div>'+
			'<span style="display:none;">'+
			'<p>'+arrNav.info.name+'</p>'+
			'<p>'+arrNav.info.desc+'</p>'+
			'<p>'+lorem[0]+'</p>'+
			'<p>'+lorem[1]+'</p>'+
			'<p>'+lorem[0]+'</p>'+
			'<p>'+lorem[1]+'</p>'+
			'<p>'+lorem[0]+'</p>'+
			'<p>'+lorem[1]+'</p>'+
			'</span>'+
			'</div>';
			
			elemID('cloudpage_'+gid).innerHTML = html;
			
		/** в противном случае просто отображение этого блока **/
		} else {
			
			elemID('cloudpage_'+gid).style.display = 'block';
		}
		
		/** определение навигационных клавиш **/
		var navi = '<div class="cloudnavclose" onclick="return Content.cloudClose();"></div>';
		if (arrNav.prev !== undefined) {navi+= '<div onclick="return Content.cloudShow('+arrNav.prev+')" class="cloudnavprev"></div>';}
		if (arrNav.next !== undefined) {navi+= '<div onclick="return Content.cloudShow('+arrNav.next+')" class="cloudnavnext"></div>';}
		
		elemID('cloud-nav').innerHTML = navi;
		
		/** инентификатор того, что окно октыто **/
		ContentShow = true;
		
		/** отображение пользователю на странице **/
		elemID('cloud').style.display = 'block';
	}
},

Выше я изпользовал некоторые значения массива «lorem» — в нем я заранее заготовил очень длинный текст для наглядного удлинения страницы, дабы показать, что окно зафиксировано.

При открытии окна заюзан метод Content.editSiteLayer('cloud-layer'); — уже ранее описанным методом мы центрируем облачное окно по центру страницы, не позаоляя ему скакать вправо-влево при появлении полосы прокрутки.

При закрытии окна используется метод Content.cloudClose(); Вот он:

/** метод cloudClose() скрывает облачное окно **/
cloudClose:function () {
	
	/** скрытие общего облака со страницы **/
	elemID('cloud').style.display = 'none';
	
	/** фиксировать основной контент страницы больше не нужно **/
	elemID('body').style.overflowX = 'auto';
	elemID('body').style.overflowY = 'auto';
	
	/** обнуление идентификатора открытого облачного окна **/
	ContentShow = null;
}

И последняя наша задача — это подключение горячих клавиш. Для этого мы создадим метод Content.init(), который будет обрабатывать горячие клавиши и при загрузке страницы сразу центрировать ее в окне обозревателя (то, о чем я писал в самом начале статьи):

/**
* метод init() стартует обработку облачного контента и обрабатывает горячие клавиши
**/
init:function () {
	
	/** центрирование основного контента страницы сайта **/
	Content.editSiteLayer('layer');
	
	/** отслеживание изменения размера окна браузера **/
	window.onresize = function() {
		
		/** центрирование основного контента страницы сайта **/
		Content.editSiteLayer('layer');
		
		/** центрирование облачного контента, если он открыт **/
		if (ContentShow) {
			Content.editSiteLayer('cloud-layer');
		}
	};
	
	/** отслеживание нажатия клавиш на клавиатуре **/
	window.onkeydown = function(event) {
		
		/** если облачный контент открыт **/
		if (ContentShow) {
			
			/** кроссбраузерное событие **/
			event = event || window.event;
			
			switch(event.keyCode) {
				case 27: // клавиша "Escape"
					Content.cloudClose();
				break;
				case 37: // клавиша "Стрелка влево" (если для нее задан ID контента в массиве)
					if (ContentPointPrev !== null){Content.cloudShow(ContentPointPrev);}
				break;
				case 39: // клавиша "Стрелка вправо" (если для нее задан ID контента в массиве)
					if (ContentPointNext !== null){Content.cloudShow(ContentPointNext);}
				break;
			}
		}
	};
}

Данный метод необходимо прописать в самом конце страницы перед закрытием тега BODY:

Некоторые поинтересуются, почему именно так, и почему нельзя повесить его на тег body в виде BODY onload=«Content.init();» либо заюзать конструкцию window.onload() Но в этом случае наш объект не начнет работать, пока не загрузится вся страница до конца. Если же у вас на странице много рекламы, то это тем более неприятно. Да и резкий скачек страницы влево на 9px при ее центрировании немного будет надоедать.

В общем, с основными задачами мы с вами справились. Еще раз ДЕМО ПРИМЕР.

Спасибо за то, что дочитали статью до конца.
Скачать целиком все CSS, JavaScript файлы и html из демо примера можно в этом архиве.
С удовольствием отвечу на все ваши вопросы, а так же выслушаю любую критику.

Автор: DimaLondon

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js