renovations

This commit is contained in:
Ilya Kantor 2015-02-21 00:59:02 +03:00
parent 24171550ae
commit a62682e188
49 changed files with 620 additions and 894 deletions

View file

@ -1,94 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="http://code.jquery.com/jquery.min.js"></script>
<style>
.voter {
font-family: Consolas, "Lucida Console", monospace;
font-size: 18px;
}
.up, .down {
cursor: pointer;
color: blue;
font-weight: bold;
}
</style>
</head>
<body>
<div id="voter" class="voter">
<span class="down"></span>
<span class="vote">0</span>
<span class="up">+</span>
</div>
<script>
function Voter(options) {
var self = this;
var elem = options.elem;
var voteElem = elem.find('.vote');
// вынесем текущее значение в отдельную переменную
var vote = options.value || 0;
// установить значение без генерации события
setVote(vote, true);
elem.on('click', '.down', onDownClick)
.on('click', '.up', onUpClick)
.on('mousedown selectstart', false);
// ----------- методы -------------
function onDownClick() {
// сам обработчик не меняет голос, он вызывает для этого функцию
voteDecrease();
}
function onUpClick() {
voteIncrease();
}
function voteDecrease() {
self.setVote( vote - 1 );
}
function voteIncrease() {
self.setVote( vote + 1 );
}
// сделали функцию приватной, чтобы её можно было вызывать до объявления
// в коде инициализации
function setVote(newVote, quiet) {
vote = newVote;
voteElem.html( vote );
if (!quiet) {
$(self).triggerHandler({
type: 'change',
value: vote
});
}
}
this.setVote = setVote;
}
var voter = new Voter({
elem: $('#voter'),
value: 5
});
$(voter).on('change', function(e) {
alert(e.value);
});
</script>
</body>
</html>

View file

@ -2,7 +2,6 @@
<html>
<head>
<meta charset="utf-8">
<script src="http://code.jquery.com/jquery.min.js"></script>
<style>
.voter {
font-family: Consolas, "Lucida Console", monospace;
@ -14,6 +13,8 @@
font-weight: bold;
}
</style>
<script src="https://cdn.polyfill.io/v1/polyfill.js?features=CustomEvent,Element.prototype.closest"></script>
<script src="voter.js"></script>
</head>
<body>
@ -24,67 +25,14 @@
</div>
<script>
function Voter(options) {
var self = this;
var elem = options.elem;
var voteElem = elem.find('.vote');
// вынесем текущее значение в отдельную переменную
var vote = options.value || 0;
// установить значение без генерации события
setVote(vote, true);
elem.on('click', '.down', onDownClick)
.on('click', '.up', onUpClick)
.on('mousedown selectstart', false);
// ----------- методы -------------
function onDownClick() {
// сам обработчик не меняет голос, он вызывает для этого функцию
voteDecrease();
}
function onUpClick() {
voteIncrease();
}
function voteDecrease() {
self.setVote( vote - 1 );
}
function voteIncrease() {
self.setVote( vote + 1 );
}
// сделали функцию приватной, чтобы её можно было вызывать до объявления
// в коде инициализации
function setVote(newVote, quiet) {
vote = newVote;
voteElem.html( vote );
if (!quiet) {
$(self).triggerHandler({
type: 'change',
value: vote
});
}
}
this.setVote = setVote;
}
var voter = new Voter({
elem: $('#voter'),
value: 5
elem: document.getElementById('voter')
});
$(voter).on('change', function(e) {
alert(e.value);
voter.setVote(5);
document.getElementById('voter').addEventListener('change', function(e) {
alert(e.detail);
});
</script>

View file

@ -0,0 +1,36 @@
function Voter(options) {
var elem = options.elem;
var voteElem = elem.querySelector('.vote');
elem.onclick = function(event) {
// сам обработчик не меняет голос, он вызывает функцию
if (event.target.closest('.down')) {
voteDecrease();
} else if (event.target.closest('.up')) {
voteIncrease();
}
}
elem.onmousedown = function() {
return false;
};
// ----------- методы -------------
function voteDecrease() {
setVote(+voteElem.innerHTML - 1);
}
function voteIncrease() {
setVote(+voteElem.innerHTML + 1);
}
function setVote(vote) {
voteElem.innerHTML = +vote;
var widgetEvent = new CustomEvent("change", { bubbles: true, detail: +vote });
elem.dispatchEvent(widgetEvent);
};
this.setVote = setVote;
}

View file

@ -2,24 +2,25 @@
[importance 5]
Добавьте событие в голосовалку, созданную в задаче [](/task/voter), используя jQuery-механизм генерации событий на объекте.
Добавьте событие в голосовалку, созданную в задаче [](/task/voter), используя механизм генерации событий на объекте.
Пусть каждое изменение голоса сопровождается событием `change`:
Пусть каждое изменение голоса сопровождается событием `change` со свойством `detail`, содержащим обновлённое значение:
```js
var voter = new Voter({
elem: $('#voter'),
value: 5
elem: document.getElementById('voter')
});
$(voter).on('change', function(e) {
alert(e.value);
voter.setVote(5);
document.getElementById('voter').addEventListener('change', function(e) {
alert(e.detail); // новое значение голоса
});
```
Все изменения голоса должны производиться централизованно, через метод `setVote`, который и генерирует событие.
Результат использования кода выше (планируемый):
[iframe border=1 height=60 src="index.html"].
[iframe border=1 height=60 src="solution"]
Исходный документ возьмите из решения задачи [](/task/voter).

View file

@ -1,8 +0,0 @@
Обратите внимание:
<ul>
<li>`onLiClick` не генерирует событие `select`. Это обработчик, его роль -- разобраться, что происходит, и передать работу нужным методам.</li>
<li>В событие передаётся массив значений `value`. Код виджета производит всю работу по подготовке этого значения. Было бы совершенно недопустимо, хотя это проще, передавать массив выбранных `LI`. Вообще, элементы -- это внутреннее дело компонента, они могут измениться в любой момент, доступ к ним снаружи крайне нежелателен.</li>
<li>Код получения значений вынесен в отдельную функцию `getValues` -- для чистоты (каждая функция делает свою работу), и потому что скорее всего она ещё где-то понадобится.</li>
</ul>

View file

@ -2,22 +2,18 @@
<html>
<head>
<meta charset="utf-8">
<script src="http://code.jquery.com/jquery.min.js"></script>
<style>
.selected {
background: #0f0;
}
li {
cursor: pointer;
}
</style>
<link rel="stylesheet" href="style.css">
<script src="https://cdn.polyfill.io/v1/polyfill.js?features=CustomEvent,Element.prototype.closest"></script>
<script src="listSelect.js"></script>
</head>
<body>
Клик на элементе выделяет только его.<br>
Shift+Клик добавляет/убирает элемент из выделенных.<br>
Ctrl(Cmd)+Клик добавляет/убирает элемент из выделенных.<br>
Shift+Клик добавляет промежуток от последнего кликнутого к выделению.<br>
<ul>
<ul id="heroes">
<li>Кристофер Робин</li>
<li>Винни-Пух</li>
<li>Ослик Иа</li>
<li>Мудрая Сова</li>
@ -25,55 +21,16 @@ Shift+Клик добавляет/убирает элемент из выдел
</ul>
<script>
function ListSelect(options) {
var elem = options.elem;
var self = this;
elem.on('click', 'li', onLiClick);
elem.on('selectstart mousedown', false);
function onLiClick(e) {
if (!e.shiftKey) {
deselectAllItems();
}
toggleSelectItem( $(this) );
}
function deselectAllItems() {
elem.children().removeClass('selected');
}
function getValue() {
var value = [];
elem.children('.selected').each(function() {
value.push( $(this).html() );
});
return value;
}
function toggleSelectItem(li) {
li.toggleClass('selected');
$(self).triggerHandler({
type: 'change',
value: getValue()
})
}
}
var select = new ListSelect({
elem: $('ul')
var listSelect = new ListSelect({
elem: document.querySelector('#heroes')
});
$(select).on('change', function(e) {
alert(e.value);
document.querySelector('#heroes').addEventListener('select', function(event) {
alert(event.value);
});
</script>
</body>
</html>

View file

@ -0,0 +1,81 @@
function ListSelect(options) {
var elem = options.elem;
var lastClickedLi = null;
elem.onmousedown = function() {
return false;
};
elem.onclick = function(e) {
var li = e.target.closest('li');
if (!li) return;
if(e.metaKey || e.ctrlKey) { // для Mac проверяем Cmd, т.к. Ctrl + click там контекстное меню
toggleSelect(li);
} else if (e.shiftKey) {
selectFromLast(li);
} else {
selectSingle(li);
}
dispatchEvent();
lastClickedLi = li;
}
function deselectAll() {
[].forEach.call(elem.children, function(child) {
child.classList.remove('selected')
});
}
function toggleSelect(li) {
li.classList.toggle('selected');
}
function selectSingle(li) {
deselectAll();
li.classList.add('selected');
}
function selectFromLast(target) {
var startElem = lastClickedLi || elem.children[0];
target.classList.add('selected');
if (startElem == target) {
// клик на том же элементе, что и раньше
// это особый случай
return;
}
var isLastClickedBefore = startElem.compareDocumentPosition(target) & 4;
if (isLastClickedBefore) {
for(var elem = startElem; elem != target; elem = elem.nextElementSibling) {
elem.classList.add('selected');
}
} else {
for(var elem = startElem; elem != target; elem = elem.previousElementSibling) {
elem.classList.add('selected');
}
}
}
function dispatchEvent() {
var widgetEvent = new CustomEvent("select", {
bubbles: true,
detail: getSelected()
});
elem.dispatchEvent(widgetEvent);
}
function getSelected() {
return [].map.call(elem.querySelectorAll('.selected'), function(li) {
return li.innerHTML;
});
};
this.getSelected = getSelected;
}

View file

@ -0,0 +1,7 @@
.selected {
background: #0f0;
}
li {
cursor: pointer;
}

View file

@ -4,9 +4,9 @@
Добавьте в решение задачи [](/task/selectable-list-component) событие `select`.
Оно должно срабатывать при каждом изменении выбора и содержать список выбранных строк.
Оно должно срабатывать при каждом изменении выбора и в свойстве `detail` содержать список выбранных строк.
Во внешнем коде добавьте обработчик к списку, который при изменениях выводит список значений.
[iframe border="1" src="solution"]
[iframe border="1" src="solution" height=180]
В качестве исходного кода возьмите решение задачи [](/task/selectable-list-component).

View file

@ -4,30 +4,32 @@
display: inline-block;
}
.customselect-title {
height: 17px;
border: 1px solid #ADD8E6;
background-position: right;
background-image: url(https://js.cx/clipart/select-button.gif);
background-repeat: no-repeat;
.customselect .title {
height: 20px;
border: 2px groove #ADD8E6;
background: white;
width: 200px;
box-sizing: border-box;
padding: 2px;
line-height: 14px;
cursor: pointer;
text-align: left;
}
.customselect li {
padding: 2px;
cursor: pointer;
}
.customselect-options li {
padding: 2px;
cursor: pointer;
}
.customselect-options li:nth-child(even) {
.customselect li:nth-child(even) {
background-color: #f0f8ff;
}
.customselect-options li:hover {
.customselect li:hover {
background-color: #7fffd4;
}
.customselect-options {
.customselect ul {
list-style: none;
margin: 0;
padding: 0;
@ -42,6 +44,7 @@
border-right: 1px solid #add8e6;
box-sizing: border-box;
}
.customselect-open .customselect-options {
.customselect.open ul {
display: block;
}

View file

@ -1,44 +1,39 @@
function CustomSelect(options) {
var self = this;
var elem = options.elem;
elem.on('click', '.customselect-title', onTitleClick);
elem.on('click', 'li', onOptionClick);
elem.onclick = function(event) {
if (event.target.className == 'title') {
toggle();
} else if (event.target.tagName == 'LI') {
setValue(event.target.innerHTML, event.target.dataset.value);
close();
}
}
var isOpen = false;
// ------ обработчики ------
function onTitleClick(event) {
toggle();
}
// закрыть селект, если клик вне его
function onDocumentClick(event) {
var isInside = $(event.target).closest(elem).length;
if (!isInside) close();
}
function onOptionClick(event) {
close();
var name = $(event.target).html();
var value = $(event.target).data('value');
setValue(name, value);
if (!elem.contains(event.target)) close();
}
// ------------------------
function setValue(name, value) {
elem.find('.customselect-title').html(name);
function setValue(title, value) {
elem.querySelector('.title').innerHTML = title;
$(self).triggerHandler({
type: 'select',
name: name,
value: value
var widgetEvent = new CustomEvent('select', {
bubbles: true,
detail: {
title: title,
value: value
}
});
elem.dispatchEvent(widgetEvent);
}
function toggle() {
@ -47,14 +42,14 @@ function CustomSelect(options) {
}
function open() {
elem.addClass('customselect-open');
$(document).on('click', onDocumentClick);
elem.classList.add('open');
document.addEventListener('click', onDocumentClick);
isOpen = true;
}
function close() {
elem.removeClass('customselect-open');
$(document).off('click', onDocumentClick);
elem.classList.remove('open');
document.removeEventListener('click', onDocumentClick);
isOpen = false;
}

View file

@ -2,7 +2,7 @@
<html>
<head>
<title>Селект</title>
<script src='http://code.jquery.com/jquery.min.js'></script>
<script src="https://cdn.polyfill.io/v1/polyfill.js?features=CustomEvent,Element.prototype.closest"></script>
<link rel="stylesheet" href="customselect.css"/>
<script src="customselect.js"></script>
</head>
@ -11,38 +11,38 @@
<div>Последний результат: <span id="result">...</span></div>
<div id="animal-select" class="customselect">
<div class="customselect-title">Выберите</div>
<ol class="customselect-options">
<button class="title">Выберите</button>
<ul>
<!-- значение хранится в свойстве data-value -->
<li data-value="bird">Птицы</li>
<li data-value="fish">Рыбы</li>
<li data-value="animal">Звери</li>
<li data-value="dino">Динозавры</li>
<li data-value="simplest">Одноклеточные</li>
</ol>
</ul>
</div>
<div id="food-select" class="customselect">
<div class="customselect-title">Выберите</div>
<ol class="customselect-options">
<button class="title">Выберите</button>
<ul>
<li data-value="carnivore">Плотоядные</li>
<li data-value="herbivore">Травоядные</li>
<li data-value="omnivore">Всеядные</li>
</ol>
</ul>
</div>
<script>
var animalSelect = new CustomSelect({
elem: $('#animal-select')
elem: document.getElementById('animal-select')
});
var foodSelect = new CustomSelect({
elem: $('#food-select')
elem: document.getElementById('food-select')
});
$([animalSelect, foodSelect]).on('select', function (event) {
$('#result').html(event.value)
document.addEventListener('select', function (event) {
document.getElementById('result').innerHTML = event.detail.value;
});
</script>

View file

@ -0,0 +1,50 @@
.customselect {
width: 200px;
font-size: 14px;
display: inline-block;
}
.customselect .title {
height: 20px;
border: 2px groove #ADD8E6;
background: white;
width: 200px;
box-sizing: border-box;
padding: 2px;
line-height: 14px;
cursor: pointer;
text-align: left;
}
.customselect li {
padding: 2px;
cursor: pointer;
}
.customselect li:nth-child(even) {
background-color: #f0f8ff;
}
.customselect li:hover {
background-color: #7fffd4;
}
.customselect ul {
list-style: none;
margin: 0;
padding: 0;
display: none;
position: absolute;
z-index: 1000;
background: white;
width: 200px;
border-bottom: 1px solid #add8e6;
border-left: 1px solid #add8e6;
border-right: 1px solid #add8e6;
box-sizing: border-box;
}
.customselect.open ul {
display: block;
}

View file

@ -0,0 +1 @@
// ваш код CustomSelect

View file

@ -2,54 +2,51 @@
<html>
<head>
<title>Селект</title>
<script src='http://code.jquery.com/jquery.min.js'></script>
<script src="https://cdn.polyfill.io/v1/polyfill.js?features=CustomEvent,Element.prototype.closest"></script>
<link rel="stylesheet" href="customselect.css"/>
<!-- для вашего кода -->
<script src="customselect.js"></script>
</head>
<body>
<div>Последний результат: <span id="result">...</span></div>
<img src="https://js.cx/clipart/select-button.gif">
<div id="animal-select" class="customselect">
<div class="customselect-title">Выберите</div>
<ol class="customselect-options">
<button class="title">Выберите</button>
<ul>
<!-- значение хранится в свойстве data-value -->
<li data-value="bird">Птицы</li>
<li data-value="fish">Рыбы</li>
<li data-value="animal">Звери</li>
<li data-value="dino">Динозавры</li>
<li data-value="simplest">Одноклеточные</li>
</ol>
</ul>
</div>
<div id="food-select" class="customselect">
<div class="customselect-title">Выберите</div>
<ol class="customselect-options">
<button class="title">Выберите</button>
<ul>
<li data-value="carnivore">Плотоядные</li>
<li data-value="herbivore">Травоядные</li>
<li data-value="omnivore">Всеядные</li>
</ol>
</ul>
</div>
<script>
// создаём два селекта
var animalSelect = new CustomSelect({
elem: $('#animal-select')
elem: document.getElementById('animal-select')
});
var foodSelect = new CustomSelect({
elem: $('#food-select')
elem: document.getElementById('food-select')
});
// выводим выбранное значение
$([animalSelect, foodSelect]).on('select', function (event) {
$('#result').html(event.value)
document.addEventListener('select', function (event) {
document.getElementById('result').innerHTML = event.detail.value;
});
</script>
</body>
</html>
</html>

View file

@ -8,12 +8,12 @@
<ul>
<li>Открытие и закрытие по клику на заголовок.</li>
<li>Закрытие селекта происходит при выборе или клике на любое другое место документа, в том числе на другой аналогичный селект.</li>
<li>Событие `"select"` при выборе опции.</li>
<li>Событие `"select"` при выборе опции возникает на элементе селекта и всплывает.</li>
<li>Значение опции хранится в атрибуте `data-value` (HTML-структура есть в исходном документе).
</ul>
Например:
[iframe src="solution" border="1" height="200" newwin]
[iframe src="solution" height="200"]
В примере выше два селекта, чтобы можно было проверить процесс открытия-закрытия.

View file

@ -1,6 +1,6 @@
# Коллбэки и события на компонентах
Компоненты, хоть и каждый сам по себе, обычно интегрированы друг с другом.
Компоненты, хоть и каждый сам по себе, обычно как-то общаются с остальной частью страницы
Есть несколько способов, при помощи которых компоненты сообщают друг другу о важных событиях, которые в них произошли.
@ -15,8 +15,8 @@
```js
var menu = new Menu({
title: "Сладости",
template: _.template($('#menu-template').html()),
listTemplate: _.template($('#menu-list-template').html()),
template: _.template(document.getElementById('menu-template').innerHTML),
listTemplate: _.template(document.getElementById('menu-list-template').innerHTML,
items: {
"donut": "Пончик",
"cake": "Пирожное",
@ -34,321 +34,84 @@ function showSelected(href) {
*/!*
```
В коде меню нужно будет вызывать её, как-то так:
В коде меню нужно будет вызывать её, например так:
```js
...
function onItemClick(e) {
e.preventDefault();
*!*
var onselect = options.onselect;
if (onselect) {
onselect(e.currentTarget.getAttribute('href').slice(1));
}
*/!*
function select(link) {
options.onselect(link.getAttribute('href').slice(1));
...
}
...
```
Демо:
Полный пример:
[codetabs src="menu-callback" height="180"]
## Свои события
Оповещение через коллбэки -- это примерный аналог назначения обработчика через `onсвойство` для DOM-элементов.
Как мы уже знаем, в современных браузерах DOM-элементы могут [генерировать поизвольные события](/dispatch-events) при помощи встроенных методов, а в IE8- это возможно с использованием фреймворка, к примеру, jQuery.
Да, он работает, но чтобы можно было назначать сколько угодно обработчиков в любой момент, нужна полноценная поддержка событий, то есть аналог `addEventListener`.
Воспользуемся ими, чтобы корневой элемент меню генерировал событие, которое мы назовём `select`, при выборе элемента, и передавал в объект события выбранное значение.
Поддержка событий для компонентов будет выглядеть так:
Для этого модифицируем функцию `select`:
```js
function Menu(options) {
...
function select(link) {
*!*
var widgetEvent = new CustomEvent("select", {
bubbles: true,
// detail - стандартное свойство CustomEvent для произвольных данных
detail: link.getAttribute('href').slice(1)
});
elem.dispatchEvent(widgetEvent);
*/!*
}
...
}
```
Код, который заинтересован в том, чтобы узнавать, что выбрано в меню, подписывается на событие `select` его корневого элемента:
```js
var menu = new Menu(...);
// любой код может подписаться на событие "select"
menu.on("select", function(item) {
// при выборе пункта меню сработает этот обработчик
alert("Выбран элемент " + item);
var elem = menu.getElem();
elem.addEventListener('select', function(event) {
alert(event.detail);
});
```
Код, который заинтересован в том, чтобы узнавать, что происходит с меню, подписывается на нужные события вызовом `menu.on(имя события, обработчик)`.
Вместо `detail` можно было бы выбрать и другое название свойства, но тогда нужно позаботиться о том, чтобы оно не конфликтовало со стандартными. Кроме того, в конструкторе `CustomEvent` разрешено только `detail`, другое свойство понадобилось бы присваивать в отдельной строке.
Далее `Menu` при наступлении события вызывает обработчик методом `trigger`, при необходимости передавая ему важные данные в качестве аргументов, вот так:
Полный пример:
```js
function Menu(options) {
var self = this;
*!*
// ... при клике на item - сгенерировать событие (trigger)
function onItemClick(e) {
e.preventDefault();
self.trigger("select", e.currentTarget.getAttribute('href').slice(1));
}
*/!*
}
```
Обратим внимание -- события будут генерироваться не на элементе `<div class="menu">`, а на объекте `new Menu`. Сейчас мы разберём методы, которые для этого нужны.
## Методы on, off и trigger
Для поддержки событий в любой объект достаточно добавить три метода: `on`, `off` и `trigger`, которые перечислены ниже в свойствах `EventMixin`:
```js
var EventMixin = {
/**
* Подписка на событие
* Использование:
* menu.on('select', function(item) { ... }
*/
on: function(eventName, handler) {
if (!this._eventHandlers) this._eventHandlers = {};
if (!this._eventHandlers[eventName]) {
this._eventHandlers[eventName] = [];
}
this._eventHandlers[eventName].push(handler);
},
/**
* Прекращение подписки
* menu.off('select', handler)
*/
off: function(eventName, handler) {
var handlers = this._eventHandlers && this._eventHandlers[eventName];
if (!handlers) return;
for(var i=0; i<handlers.length; i++) {
if (handlers[i] == handler) {
handlers.splice(i--, 1);
}
}
},
/**
* Генерация события с передачей данных
* this.trigger('select', item);
*/
trigger: function(eventName) {
if (!this._eventHandlers || !this._eventHandlers[eventName]) {
return; // обработчиков для события нет
}
// вызвать обработчики
var handlers = this._eventHandlers[eventName];
for (var i = 0; i < handlers.length; i++) {
handlers[i].apply(this, [].slice.call(arguments, 1));
}
}
};
```
Объект `EventMixin` -- всего лишь хранилище для функций, которые нужно скопировать в тот объект, которому нужны события.
Пример использования:
```js
//+ run
var obj = {}; // произвольный объект
*!*
// скопируем в него методы для работы с событиями
*/!*
for(var m in EventMixin) obj[m] = EventMixin[m];
*!*
// поставить обработчик на событие hello
*/!*
obj.on("hello", function(a, b, c) {
alert(a + ", " + b + ", " + c);
});
// и ещё один обработчик
obj.on("hello", function() {
alert("привет!");
});
*!*
// генерация события с дополнительными данными
// обычно происходит внутри методов объекта
*/!*
obj.trigger("hello", "data1", "data2", "data3");
```
При запуске этого кода будет поставлено два обработчика на событие `hello`, которые (по очереди) будут вызваны при `obj.trigger(...)`.
[smart header="Внутренний алгоритм `on/off/trigger`"]
Большинство фреймворков, предоставляющих события на произвольных объектах, работают примерно так же, как `on/off/trigger` в примере выше.
**Все обработчики событий `obj` хранятся в "скрытом" свойстве самого `obj`.**
<ul>
<li>Вызов `obj.on(event, handler)` создаёт свойство `obj._eventHandlers`, если его ещё нет, и сохраняет в нём обработчик: `obj._eventHandlers[event].push(handler)`.</li>
<li>Далее вызов `obj.trigger(event, ...)` получает обработчики из `obj._eventHandlers[event]` и выполняет их один за другим.</li>
<li>Вызов `obj.off(event, handler)` удаляет обработчик из `obj._eventHandlers`.</li>
</ul>
[/smart]
## Меню с событиями
Для перехода на события в меню, нужно:
<ol>
<li>Добавить методы из `EventMixin` в объект меню. Их можно скопировать либо в объект, либо в прототип.</li>
<li>Вместо вызова коллбэка `onselect` -- генерируем событие вызовом `trigger('select', значение)`.</li>
</ol>
Код:
```js
function Menu(options) {
var elem;
var self = this;
*!*
for(var method in EventMixin) {
this[method] = EventMixin[method]
}
*/!*
function onItemClick(e) {
e.preventDefault();
*!*
self.trigger("select", currentTarget.getAttribute('href').slice(1));
*/!*
}
// другие методы без изменений
}
```
Во внешнем коде:
```js
var menu = new Menu(...)
menu.on('select', function(value) {
alert('выбрано значение ' + value);
});
```
Результат:
[codetabs src="menu-event"]
## События при помощи jQuery
[warn header="Внимание, инкапсуляция!"]
Очень важно, что внешний код ставит обработчик на корневой элемент, но не на внутренние элементы меню.
Фреймворк jQuery предоставляет свои средства для генерации произвольных событий.
Строго говоря, он вообще не знает про то, как устроено меню, есть ли там ссылки и какие, или там вообще всё реализовано через кнопки.
Метод [trigger](http://api.jquery.com/trigger/) позволяет генерировать любое событие на элементе и он же -- работает на любом объекте, "обёрнутом" в jQuery: `$(...)`.
Меню для него -- "чёрный ящик". Корневой элемент -- точка доступа к его функционалу. Событие -- не то, которое произошло на ссылке, а "переработанный вариант", интерпретация действия со стороны меню.
Синтаксис для генерации события на элементе:
```js
elem.trigger(event);
```
Объект `event` должен содержать свойство `type` -- тип события (произвольный) и любые другие свойства, которые будут переданы обработчикам.
Например:
```js
$('body').on('hello', function(e) {
alert(e.user)
});
*!*
$('body').trigger({
type: 'hello', // тип события
user: 'Вася', // любые данные
});
*/!*
$('body').off('hello');
```
Есть также альтернативный синтаксис:
```js
elem.trigger(eventType, args);
```
<ul>
<li>`eventType` -- тип события (строка)</li>
<li>`args` -- массив аргументов, которые будут переданы обработчику.</li>
</ul>
Пример использования:
```js
$('body').on('hello', function(e, user) {
alert(user)
});
*!*
// аргументы - в виде массива
$('body').trigger("hello", ["Вася"]);
*/!*
$('body').off('hello');
```
Как правильно, предпочтителен первый синтаксис: `elem.trigger(event)`, поскольку он гораздо гибче, можно указывать любые свойства в любом порядке.
События, сгенерированные таким образом на элементах, всплывают и обрабатываются jQuery наравне с обычными браузерными событиями.
**Для объекта всё точно так же, как и для элемента:**
```js
//+ run
var obj = {}; // произвольный объект
$(obj).on('hello', function(e) {
alert(e.user)
});
$(obj).trigger({
type: 'hello',
user: 'Вася'
});
```
Конечно, события на объектах не всплывают, всё-таки "всплытие" -- это привилегия DOM.
Внутри jQuery делает в точности то же самое, что `EventMixin` -- вызов `on(...)` добавляет обработчик добавляются в "скрытое" свойство объекта, из которого они потом читаются и выполняются вызовом `trigger(...)`.
Это свойство можно легко увидеть:
```js
//+ run
var obj = {};
$(obj).on('hello', function(e) { /* ... */ });
// свойство называется примерно так: jQuery19105266267273109406
// оно имеет такое псевдослучайное название,
// чтобы не перезаписать "настоящие", важные свойства объекта
for(var prop in obj) alert(prop);
```
Технически, можно генерировать событие и на корневом элементе меню `<div class="menu">` и на JavaScript-объекте `new Menu` (`this` внутри конструктора). Однако, обычно используют именно объект: считается, что DOM компонента -- это его сугубо личное дело. Он может в любой момент поменяться и быть пересоздан "с нуля", если потребуется. Залезать в него снаружи и ставить какие-то обработчики никто не имеет права.
Такое правило позволяет нам не опасаться проблем при оптимизации, расширении и даже полной переделке DOM-структуры меню. Коль скоро события и методы сохраняются, внешний код будет работать как прежде.
Ещё раз -- внешний код не имеет права залезать внутрь DOM-структуры меню, ставить там обработчики и так далее.
[/warn]
## Итого
Для того, чтобы внешний код мог узнавать о важных событиях, произошедших внутри компоненты, используются:
<ul>
<li>Коллбэки -- функции, которые передаются "снаружи" при создании компонента, и которые он обязуется вызвать при наступлении событий.</li>
<li>События -- компонент генерирует их при помощи вызова `trigger` (`EventMixin`, jQuery или другой фреймворк), а внешний код ставит обработчики при помощи `on`.</li>
<li>События -- компонент генерирует их на корневом элементе при помощи `dispatchEvent`, а внешний код ставит обработчики при помощи `addEventListener`. Такие события всплывают, если указан флаг `bubbles`, поэтому можно использовать с ними делегирование.</li>
</ul>
Можно использовать и то и другое одновременно. Например, виджеты фреймворка jQuery UI позволяют передать при создании коллбэк `onselect`, и вместе с тем генерируют событие.
Далее, для простоты, в примерах и задачах мы будем использовать для событий методы `on/off/trigger` из jQuery. Если jQuery не нужен -- всегда можно использовать `EventMixin` или какой-либо другой фреймворк.
[libs]
event-mixin.js
[/libs]

View file

@ -2,9 +2,9 @@
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css">
<script src="http://code.jquery.com/jquery.min.js"></script>
<link rel="stylesheet" href="menu.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js"></script>
<script src="https://cdn.polyfill.io/v1/polyfill.js?features=Element.prototype.closest"></script>
<script src="menu.js"></script>
</head>
<body>
@ -15,10 +15,15 @@
</div>
</script>
<!--
встроенная браузерная функция encodeURIComponent кодирует спец-символы для URL,
например русские буквы и пробелы
в этом примере русских букв в ключах items нет, но потенциально они возможны
-->
<script type="text/template" id="menu-list-template">
<ul>
<% for(var key in items) { %>
<li><a href="#<%-key%>"><%-items[key]%></li>
<% for(var name in items) { %>
<li><a href="#<%=encodeURIComponent(name)%>"><%-items[name]%></a></li>
<% } %>
</ul>
</script>
@ -26,22 +31,21 @@
<script>
var menu = new Menu({
title: "Сладости",
template: _.template($('#menu-template').html()),
listTemplate: _.template($('#menu-list-template').html()),
template: _.template( document.getElementById('menu-template').innerHTML.trim()),
listTemplate: _.template( document.getElementById('menu-list-template').innerHTML.trim()),
items: {
"donut": "Пончик",
"cake": "Пирожное",
"chocolate": "Шоколадка"
cake: "Торт", // cake <a href="#cake">Торт</a>
donut: "Пончик", // donut
chokolate: "Шоколадка" // chokolate
},
onselect: showSelected
});
function showSelected(href) {
alert(href);
function showSelected(itemName) {
alert(itemName);
}
$(document.body).append(menu.getElem());
menu.open();
document.body.appendChild(menu.getElem());
</script>
</body>

View file

@ -0,0 +1,24 @@
.menu ul {
display: none;
margin: 0;
}
.menu .title {
font-weight: bold;
cursor: pointer;
}
.menu .title:before {
content: '▶';
padding-right: 6px;
color: green;
}
.menu.open ul {
display: block;
}
.menu.open .title:before {
content: '▼';
}

View file

@ -7,48 +7,51 @@ function Menu(options) {
}
function render() {
var elemHtml = options.template({title: options.title});
var html = options.template({title: options.title});
elem = $(elemHtml);
elem = document.createElement('div');
elem.innerHTML = html;
elem = elem.firstElementChild;
elem.on('mousedown selectstart', false);
elem.onmousedown = function() {
return false;
}
elem.on('click', '.title', onTitleClick);
elem.on('click', 'a', onItemClick)
}
elem.onclick = function(event) {
if (event.target.closest('.title')) {
toggle();
}
if (event.target.closest('a')) {
event.preventDefault();
select(event.target.closest('a'));
}
function renderItems() {
if (elem.find('ul').length) return;
var listHtml = options.listTemplate({items: options.items});
elem.append(listHtml);
}
function onItemClick(e) {
e.preventDefault();
var onselect = options.onselect;
if (onselect) {
onselect(e.currentTarget.getAttribute('href').slice(1));
}
}
function onTitleClick(e) {
toggle();
function renderItems() {
if (elem.querySelector('ul')) return;
var listHtml = options.listTemplate({items: options.items});
elem.insertAdjacentHTML("beforeEnd", listHtml);
}
function select(link) {
options.onselect(link.getAttribute('href').slice(1));
}
function open() {
renderItems();
elem.addClass('open');
elem.classList.add('open');
};
function close() {
elem.removeClass('open');
elem.classList.remove('open');
};
function toggle() {
if (elem.hasClass('open')) close();
if (elem.classList.contains('open')) close();
else open();
};
@ -56,4 +59,4 @@ function Menu(options) {
this.toggle = toggle;
this.close = close;
this.open = open;
}
}

View file

@ -1,20 +0,0 @@
.menu ul {
display: none;
margin: 0;
}
.menu .title {
font-weight: bold;
cursor: pointer;
background: url(https://js.cx/clipart/arrow-right.png) left center no-repeat;
padding-left: 18px;
}
.menu.open ul {
display: block;
}
.menu.open .title {
background-image: url(https://js.cx/clipart/arrow-down.png);
}

View file

@ -1,48 +0,0 @@
var EventMixin = {
/**
* Подписка на событие
* Использование:
* menu.on('select', function(item) { ... }
*/
on: function(eventName, handler) {
if (!this._eventHandlers) this._eventHandlers = {};
if (!this._eventHandlers[eventName]) {
this._eventHandlers[eventName] = [];
}
this._eventHandlers[eventName].push(handler);
},
/**
* Прекращение подписки
* menu.off('select', handler)
*/
off: function(eventName, handler) {
var handlers = this._eventHandlers && this._eventHandlers[eventName];
if (!handlers) return;
for(var i=0; i<handlers.length; i++) {
if (handlers[i] == handler) {
handlers.splice(i--, 1);
}
}
},
/**
* Генерация события с передачей данных
* this.trigger('select', item);
*/
trigger: function(eventName) {
if (!this._eventHandlers || !this._eventHandlers[eventName]) {
return; // обработчиков для события нет
}
// вызвать обработчики
var handlers = this._eventHandlers[eventName];
for (var i = 0; i < handlers.length; i++) {
handlers[i].apply(this, [].slice.call(arguments, 1));
}
}
};

View file

@ -2,10 +2,9 @@
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css">
<script src="http://code.jquery.com/jquery.min.js"></script>
<link rel="stylesheet" href="menu.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js"></script>
<script src="eventMixin.js"></script>
<script src="https://cdn.polyfill.io/v1/polyfill.js?features=CustomEvent,Element.prototype.closest"></script>
<script src="menu.js"></script>
</head>
<body>
@ -18,8 +17,8 @@
<script type="text/template" id="menu-list-template">
<ul>
<% for(var key in items) { %>
<li><a href="#<%-key%>"><%-items[key]%></li>
<% for(var name in items) { %>
<li><a href="#<%=encodeURIComponent(name)%>"><%-items[name]%></a></li>
<% } %>
</ul>
</script>
@ -27,21 +26,21 @@
<script>
var menu = new Menu({
title: "Сладости",
template: _.template($('#menu-template').html()),
listTemplate: _.template($('#menu-list-template').html()),
template: _.template( document.getElementById('menu-template').innerHTML.trim()),
listTemplate: _.template( document.getElementById('menu-list-template').innerHTML.trim()),
items: {
"donut": "Пончик",
"cake": "Пирожное",
"chocolate": "Шоколадка"
cake: "Торт", // cake <a href="#cake">Торт</a>
donut: "Пончик", // donut
chokolate: "Шоколадка" // chokolate
}
});
$(document.body).append(menu.getElem());
menu.open();
menu.on('select', function(value) {
alert('выбрано значение ' + value);
var elem = menu.getElem();
document.body.appendChild(elem);
elem.addEventListener('select', function(event) {
alert(event.detail);
});
</script>
</body>

View file

@ -0,0 +1,24 @@
.menu ul {
display: none;
margin: 0;
}
.menu .title {
font-weight: bold;
cursor: pointer;
}
.menu .title:before {
content: '▶';
padding-right: 6px;
color: green;
}
.menu.open ul {
display: block;
}
.menu.open .title:before {
content: '▼';
}

View file

@ -1,10 +1,5 @@
function Menu(options) {
var elem;
var self = this;
for(var method in EventMixin) {
this[method] = EventMixin[method]
}
function getElem() {
if (!elem) render();
@ -12,45 +7,55 @@ function Menu(options) {
}
function render() {
var elemHtml = options.template({title: options.title});
var html = options.template({title: options.title});
elem = $(elemHtml);
elem = document.createElement('div');
elem.innerHTML = html;
elem = elem.firstElementChild;
elem.on('mousedown selectstart', false);
elem.onmousedown = function() {
return false;
}
elem.on('click', '.title', onTitleClick);
elem.on('click', 'a', onItemClick)
elem.onclick = function(event) {
if (event.target.closest('.title')) {
toggle();
}
if (event.target.closest('a')) {
event.preventDefault();
select(event.target.closest('a'));
}
}
}
function renderItems() {
if (elem.find('ul').length) return;
if (elem.querySelector('ul')) return;
var listHtml = options.listTemplate({items: options.items});
elem.append(listHtml);
elem.insertAdjacentHTML("beforeEnd", listHtml);
}
function onItemClick(e) {
e.preventDefault();
self.trigger('select', e.currentTarget.getAttribute('href').slice(1));
}
function onTitleClick(e) {
toggle();
function select(link) {
var widgetEvent = new CustomEvent("select", {
bubbles: true,
detail: link.getAttribute('href').slice(1)
});
elem.dispatchEvent(widgetEvent);
}
function open() {
renderItems();
elem.addClass('open');
elem.classList.add('open');
};
function close() {
elem.removeClass('open');
elem.classList.remove('open');
};
function toggle() {
if (elem.hasClass('open')) close();
if (elem.classList.contains('open')) close();
else open();
};

View file

@ -1,20 +0,0 @@
.menu ul {
display: none;
margin: 0;
}
.menu .title {
font-weight: bold;
cursor: pointer;
background: url(https://js.cx/clipart/arrow-right.png) left center no-repeat;
padding-left: 18px;
}
.menu.open ul {
display: block;
}
.menu.open .title {
background-image: url(https://js.cx/clipart/arrow-down.png);
}