Грамотное javascript-дерево за 7 шагов

Илья Кантор

Грамотное javascript-дерево за 7 шагов

В этой статье описана DOM/CSS-структура дерева, которую я в свое время разработал для dojo toolkit.

Основные особенности:

  • Семантическая удобная CSS-разметка.

    Внешний вид дерева определяется исключительно CSS.
  • Скрытие/раскрытие узлов
  • Структура дерева обозначена линиями
  • Допускает многострочное HTML-содержимое в узлах
  • Оптимизация по количеству HTML-тагов
  • Легко дополняется новыми фишками

Например:

Root
  • Item 1
  • Item 2

    title long yeah
  • Item 3
    • Item 3.1

Шаг 1. DOM-модель одного узла дерева.

Основной строительный блок дерева — его узел.

Каждый узел имеет класс Node и состоит из иконки Expand, заголовка Content и контейнера для детей Container.

Визуальное представление узла:

Например, вот так выглядит разметка просто корневого узла Root, без детей:

  • Root
1 <ul class="Container">
2   <li class="Node IsRoot ExpandOpen">
3     <div class="Expand"></div>
4     <div class="Content">Root</div>
5   </li>
6 </ul>
Класс IsRoot
говорит о том, что узел является корнем дерева
Класс ExpandOpen
Обозначает, что узел раскрыт
Обратите внимание — вся используемая разметка является исключительно семантической. В данном случае CSS-класс говорит не «каким образом следует выделить элемент», а «что элемент обозначает».

Шаг 2. Два узла дерева.

А вот — внутри узла появился Container с (пока) одним потомком Item 1.

  • Root
    • Item 1
01 <ul class="Container">
02   <li class="Node IsRoot ExpandOpen">
03     <div class="Expand"></div>
04     <div class="Content">Root</div>
05     <ul class="Container">
06       <li class="Node ExpandLeaf">
07         <div class="Expand"></div>
08         <div class="Content">Item 1</div>
09       </li>
10     </ul>
11   </li>
12 </ul>

Семантические элементы:

Контейнер Container
В контейнере содержатся все дети, т.е 1 или больше Node. Это удобно, ведь чтобы скрыть/показать потомков — достаточно обратиться к контейнеру.

Перебрать всех детей можно, используя Container.childNodes.

Класс ExpandLeaf
Обозначает, что узел является листом дерева.

Узел-потомок уже не имеет класса IsRoot.

Шаг 3. CSS для показа дерева

Для начала — немного почистим стили для UL и LI: обнулим по умолчанию заданные значения margin, padding и list-style-type.

01 /* контейнер просто содержит узлы.
02  Узел сам будет отвечать за свой отступ */
03 .Container {
04         padding: 0;
05         margin: 0;
06 }
07  
08 .Container li {
09         list-style-type: none; /* убрать кружочки/точечки */
10 }

Стили узла

Самым базовым является стиль для собственно узла Node.


Он задает иерархическую структуру за счет свойства margin-left, которое отодвигает узел-потомок от левой стенки контейнера.

01 /* узел отодвинут от левой стенки контейнера на 18px
02     благодаря этим отступам вложенные узлы формируют иерархию
03  */
04 .Node {
05     margin-left: 18px;
06     zoom: 1; /* спецсвойство против багов IE6,7. Ставит hasLayout */
07 }
08  
09 /* Корневой узел от родительского контейнера не отодвинут.
10    Ему же не надо демонстрировать отступом, чей он сын.
11    Это правило идет после .Node, поэтому имеет более высокий приоритет
12    Так что class="Node IsRoot" дает margin-left:0
13 */
14 .IsRoot {
15     margin-left: 0;
16 }

Стили компонентов узла

Для того, чтобы иконка Expand находилась слева от содержания — использован принцип двухколоночной верстки.

Левая колонка с фиксированной шириной — Expand, правая колонка — Content.

01 /* иконка скрытого/раскрытого поддерева или листа
02     сами иконки идут дальше, здесь общие свойства
03  */
04 .Expand {
05     width: 18px;
06     height: 18px;
07     /* принцип двухколоночной верстки. */
08     /* float:left и width дива Expand + margin-left дива Content */
09     float: left;
10 }
11  
12 /* содержание (заголовок) узла */
13  .Content {
14     /* чтобы не налезать на Expand */
15     margin-left:18px;
16     /* высота заголовка - как минимум равна Expand
17         Т.е правая колонка всегда выше или равна левой.
18         Иначе нижний float будет пытаться разместиться на получившейся ступеньке
19     */   
20     min-height: 18px;
21 }
22  
23  /* все правила после * html выполняет только IE6 */
24 * html .Content {
25     height: 18px; /* аналог min-height для IE6 */
26 }

Получившаяся структура допускает любые данные внутри Content, включая многострочные и т.п.

Иконки состояния узла

01 /* открытое поддерево */
02 .ExpandOpen .Expand { 
03     background-image: url(/forum/img/minus.gif); 
04 }
05  
06 /* закрытое поддерево */
07 .ExpandClosed .Expand {
08     background-image: url(/forum/img/plus.gif);
09 }
10  
11 /* лист */
12 .ExpandLeaf .Expand {
13     background-image: url(/forum/img/leaf.gif);
14 }

Здесь очень важен порядок, в котором следуют определения.

Поддеревья вложены, из-за этого получается такая конструкция:

1 <li class="...Node ExpandOpen...">
2   ...
3   <li class="...Node ExpandClosed...">
4      <div class="Expand"></div>
5      ..
6   </li>
7 </li>

Внутренний див Expand подходит под оба CSS-правила: и под ExpandOpen .Expand и под .ExpandClosed .Expand.

Правило .ExpandClosed .Expand идет позже, поэтому имеет более высокий приоритет, и будет (правильно) показана иконка закрытого раздела.

Шаг 4. Добавляем структурные линии.

Структурные линии обрисовывают дерево, делая иерархию более наглядной.

В некоторых javascript-деревьях они пунктирные и используют кучу лишних тагов из-за неудачно выбранной DOM/CSS-модели.

Метод построения линий, который будем использовать мы, позволяет сделать линии гладкие, растягивающиеся при изменении размера деревьев.


Впрочем, пунктир добавить тоже никто не помешает.

И все это без добавления дополнительных тагов, исключительно средствами CSS.

Наша цель — получить дерево, которое выглядит так:

Info
  • Root
    • Item 1

      Multiline test
      • Item 1.1
    • Item 2

Каркас из линий образуется дополнительными CSS-правилами.

  1. Узел Node поддерживает вертикальную линию к своему следующему соседу

    1 .Node {
    2     margin-left: 18px;
    3     zoom: 1;
    4     /* линия слева образуется повторяющимся фоновым рисунком */
    5     background-image : url(/forum/img/i.gif);
    6     background-position : top left;
    7     background-repeat : repeat-y;
    8 }
  2. Если соседа ниже нет, то линию вниз продолжать не надо:

    1 /* это правило - ниже .Node, поэтому имеет больший приоритет */
    2 .IsLast {
    3     /* добавить соединительную черточку наверх */
    4     background-image: url(/forum/img/i_half.gif);
    5     background-repeat : no-repeat;
    6 }

Получается, что все узлы на одном уровне соединены вертикальной чертой.

Размер рисунков для фоновых черточек сделан такой, чтобы вертикальная черта проходила строго посередине иконок Expand.

Поэтому получается, что эти иконки автоматически «нанизываются» на вертикальную линию.

Чтобы получить более целостную картину, можно обновить иконки Expand, добавив к ним соединительную черту для подключения заголовка к вертикальной линии.

Вот такие новые иконки для Expand*-классов.

Открытый узел ExpandOpen

Закрытый узел ExpandClosed

Лист ExpandLeaf

Горизонтальные коннекторы готовы.

Вертикальные линии образуют каркас, а новые иконки Expand* присоединяют узлы к каркасу. Структурные линии построены

.

Шаг 5. Скрытие-раскрытие

Для скрытия-раскрытия добавим два CSS-правила.

1 .ExpandOpen .Container {
2     display: block;
3 }
4  
5 .ExpandClosed .Container {
6     display: none;
7 }

Как всегда, важен порядок. ExpandClosed идет после ExpandOpen, поэтому имеет больший приоритет, и вложенные закрытые узлы отображаются закрытыми.

Для скрытия-раскрытия javascript-функция всего лишь меняет класс узла. Остальное делает CSS.

Чтобы в дереве поддерживалось скрытие-раскрытие — достаточно повесить обработчик на самый внешний div.

И для красоты — обязательно поправить курсор при наведении на иконки скрытия/раскрытия:

1 .ExpandOpen .Expand, .ExpandClosed .Expand {
2     cursor: pointer; /* иконки скрытия-раскрытия */
3 }
4  
5 .ExpandLeaf .Expand {
6     cursor: auto; /* листовой узел */
7 }

Обязательно задать определение для листового узла тоже, иначе курсор на нем тоже станет pointer (почему? — из-за вложенности div‘ов).

Root
  • Item 1
  • Item 2

    title long yeah
  • Item 3
    • Item 3.1

А вот и сам обработчик события onclick. После правил CSS делать ему осталось всего ничего:

  1. Определить, произошел ли клик на иконке Expand, используя event.target(или event.srcElement для IE)
  2. Получить узел Node для иконки
  3. Если узел — не лист, то поменять класс ExpandOpen <-> ExpandClosed
01 function tree_toggle(event) {
02     event = event || window.event
03     var clickedElem = event.target || event.srcElement
04  
05     if (!hasClass(clickedElem, 'Expand')) {
06         return // клик не там
07     }
08  
09     // Node, на который кликнули
10     var node = clickedElem.parentNode
11     if (hasClass(node, 'ExpandLeaf')) {
12         return // клик на листе
13     }
14  
15     // определить новый класс для узла
16     var newClass = hasClass(node, 'ExpandOpen') ? 'ExpandClosed' : 'ExpandOpen'
17     // заменить текущий класс на newClass
18     // регексп находит отдельно стоящий open|close и меняет на newClass
19     var re =  /(^|s)(ExpandOpen|ExpandClosed)(s|$)/
20     node.className = node.className.replace(re, '$1'+newClass+'$3')
21 }
22  
23  
24 function hasClass(elem, className) {
25     return new RegExp("(^|\s)"+className+"(\s|$)").test(elem.className)
26 }

Шаг 6. AJAX-индикация

Пока что мы строили дерево исключительно из HTML-разметки.

Полностью аналогично дерево работает при создании разметки при помощи Javascript. Как загружать данные с сервера в формате JSON, и многое другое Вы можете прочитать в цикле статей AJAX.

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

.

Индикатор обработки, вообще говоря, может обозначать любые асинхронные операции. Начиная от загрузки детей и заканчивая удалением всего этого узла с сервера.

Опишем его CSS-правилом:

1 .ExpandLoading   {
2     width: 18px;
3     height: 18px;
4     float: left;
5     background-image: url(/forum/img/expand_loading.gif);
6 }

Класс ExpandLoading на время операции будет заменять обычный класс Expand.

Почему нельзя добавить класс ExpandLoading к ExpandOpen/Closed/.. ?

Индикатор может понадобиться в любом месте. Среди потомков «активного» узла могут быть «неактивные» узлы, и среди его родителей — тоже.

Если поставить класс ExpandLoading в один ряд с ExpandOpen/Closed/.., то он будет либо более приоритетен — и тогда все узлы под ним получат часики, либо менее приоритетен — тогда вообще ничего не будет видно.

И тот и другой варианты — не подходят, когда индикация нужна на одном-единственном узле посередине, например, после редактирования названия узла.

Например, так может выглядеть участок дерева с активным узлом Item 1.1:

  • Item 1
    • Item 1.1
      • Item 1.1.1

Шаг 7. Дополнительные возможности

Вы можете пожелать добавить в дерево дополнительные элементы. Например, чекбоксы или иконки с типом узла.

Для добавления, например, чекбокса <input type="checkbox"> после иконки Expand, нужно для начала вставить его в структуру сразу после иконки открытия/закрытия.

Указываем размеры, отступ и float: left:

1 /* Общий размер 14+2+2 = 18 - такой же как Expand */
2 .Node input {
3     width: 14px;
4     height: 14px;
5     float: left;
6     margin: 2px;
7 }

Теперь, сохраняя двухколоночную верстку, нужно отодвинуть Content вправо уже не на 18, а на общую ширину двух float‘ов — 36px.

После того как сдвинулся заголовок Content — естественно сдвинуть и сам узел Node, чтобы структурная линия шла от заголовка.

Все это осуществляется добавлением пары правил:

1 /* подвинем за оба float'а Node, Content */
2 .Node, .Content {
3     margin-left: 36px;
4 }
5 /* заново переопределим .IsRoot */
6 .IsRoot { margin-left: 0; }
Root
  • Item 1
    • Item 1.1
      • Item 1.1.2
    • Item 1.2
  • Item 2

    title long yeah
    • Item 2.1
  • Item 3
    • Item 3.1

Скачать CSS, JS и картинки

В принципе, можно использовать и CSS/JS/картинки напрямую со страницы, но они содержат некоторое количество лишних классов.

Для удобства дерево все-в-одном с JS/CSS/HTML находится на отдельной странице. В этом примере полностью расписано дерево без AJAX-индикации и чекбоксов.

Кроме того, можно скачать материалы по статье:

При использовании в своем окружении Вы, наверное, захотите удлинить все классы. добавив какой-то префикс. Например, TreeContainer, TreeNode, и т.п.

Другой вариант, возможно, более удобный — ограничить классы внешним селектором. Например, .Tree .Container, .Tree .Node, * html .Tree .Content и т.п.


Автор: Dmitry A. Soshnikov, дата: 7 апреля, 2008 — 22:02

#permalink

Замечательная и полезная статья!


Автор: tenshi, дата: 7 апреля, 2008 — 23:13

#permalink

неплохо.


я бы предложил использовать классы с единым префиксом (нэймспэйсом) при создании подобных абстрактных реализаций, дабы избежать случайного конфликта с другими не менее абстрактными реализациями ^_^

например, для элементов ( e — element ):


tree-e-root


tree-e-branch


tree-e-leaf

для состояний ( s — state ):


tree-s-opened


tree-s-closed


tree-s-loading

.ня


Автор: Илья Кантор, дата: 8 апреля, 2008 — 02:39

#permalink

Update: Добавил downloads и замечание о префиксах/неймспейсах в конце статьи.

Небольшие редакторские правки для лучшего раскрытия некоторых моментов.


Автор: remitmaster, дата: 7 мая, 2008 — 22:33

#permalink

А вот такой вопрос. По умолчанию дерево полностью раскрыто, как сделать его закрытым?


Автор: remitmaster, дата: 7 мая, 2008 — 22:34

#permalink

Или чтобы запоминалось, ну это наверное в куки надо закидывать…


Автор: Илья Кантор, дата: 10 мая, 2008 — 09:45

#permalink

Посмотри последний пример или скачай исходники… Там дерево полностью закрыто должно быть.


Автор: Andrzej (не зарегистрирован), дата: 8 июля, 2008 — 16:38

#permalink

Да Твоё дерево не очень, так как сразу глотает всю структуру, а если у тебя будет 5 тыщ узлов

?


Автор: Илья Кантор, дата: 9 июля, 2008 — 17:38

#permalink

Да, в статье не разобран вопрос динамической подгрузки данных.

С другой стороны, её достаточно просто реализовать самому.

Структура дерева удобна для добавления и удаления узлов, а как получить AJAX’ом с сервера данные — это уже можно, например, раздел про AJAX посмотреть.

UPDATE: В разделе по AJAX появились статьи про интеграцию AJAX в интерфейс и статья про AJAX-дерево.


Автор: Гость (не зарегистрирован), дата: 23 июля, 2008 — 13:04

#permalink

дерево и в правду сразу открывается((


Автор: Илья Кантор, дата: 24 июля, 2008 — 00:47

#permalink

Конечно, дерево в примере раскрыто. Там ведь у каждого узла класс ExpandOpen стоит. Если Вам хочется закрытое дерево — замените его на ExpandClosed.

В этом примере стоит ExpandClosed, так что дерево закрыто.


Автор: Гость (не зарегистрирован), дата: 20 августа, 2008 — 00:51

#permalink

Отличная статья! Долго искал нечто подобное. Респект автору за грамотный подход к задаче!


Большинство подобных деревьев обычно делается через Хм… пень колоду. В данном случае, все четко соответствует спецификациям и обеспечивает широкую кроссбраузерность.


Несомненный плюс данного дерева в том, что оно не генерируется скриптом, а полностью выполнено в виде HTML кода. Что в данном случае сохраняет саму логическую разметку документа, плюс позволяет свободно индексировать содержимое поисковиками.

Ну и несомненную ценность имеет не только конечный результат, но и сама статья.

Спасибо огромное автору! Добавил в избранное!


Автор: Андрей Кумыков (не зарегистрирован), дата: 30 сентября, 2008 — 12:42

#permalink

Если расширить условие функции раскурывания/закрывания таким образом, поведение дерево станет более юзабельным: для раскрытия узла не нужно целиться в крестик, а можно нажать на его имя.

if (!hasClass(clickedElem,’Expand’) && !hasClass(clickedElem,’Content’)) {


return // клик не там


}


Автор: Илья Кантор, дата: 24 октября, 2008 — 16:13

#permalink

В статье сделан именно крестик, т.к на клик на имени часто вешается что-то другое, например, открытие страницы с этим именем.

Хотя, конечно, в вашем случае может быть целесообразно расширить дерево именно так


Автор: Гость (не зарегистрирован), дата: 24 ноября, 2008 — 13:44

#permalink

Что-то у меня такой вариант не работает, а очень нужно, не могу понять почему?


Автор: король ящериц (не зарегистрирован), дата: 24 октября, 2008 — 13:14

#permalink

В Drupal интегрируется как пить дать, причем javascript просто дописывается к уже имеющемуся одному из файлов скриптов, а css соответственно к имеющейся таблице стилей, которые используются на этой странице и все. Нюанс: все классы, используемые деревом, желательно переименовать или хотя бы единичку дописать в имени каждого класса во избежание случайного пересечения описания классов, к примеру, класс node уже используется системой drupal, а вот node1 — нет


Автор: Вася (не зарегистрирован), дата: 26 октября, 2008 — 16:22

#permalink

Спасибо!


Автор: EugenyK, дата: 26 октября, 2008 — 23:13

#permalink

В статье http://javascript.ru/ajax/tutorial/intro приведён пример ajax-бесконечного дерева. Его просто создать, когда подгрузка узлов осуществляется без анимации.


Хотелось бы знать, как оптимально сделать, чтобы список дочерних узлов «выезжал» вниз или вверх (при сворачивании)?


Это делается через некую глобальную переменную типа width и через setTimeout вызывается функция, которая её меняет для данного тага?


Автор: Илья Кантор, дата: 27 октября, 2008 — 00:44

#permalink

В чем именно проблема? Это вопрос про AJAX или про анимацию или про то как они связаны ?


Автор: EugenyK, дата: 28 октября, 2008 — 03:43

#permalink

Именно про анимацию. Чем достигается появление результата ответа не сразу, а через «выкатывание» вниз?

У меня было предположение по поводу неё такое:


Получают результат запроса в div, далее дают ему более меньший zIndex и относительное позиционирование, смещают top на -offsetHeight и привязывают к DOM, а потом в вызывают функцию рекурсивно, которая увеличивает top до 0 и возвращают прежний zIndex.


Реализовывать это не пробовал (вижу в этом некую нерациональность)


Автор: Илья Кантор, дата: 28 октября, 2008 — 12:21

#permalink

Там изменение height + setTimeout.

То есть, появляется div, у него размер 0.


Дальше, скажем, каждые 5 ms размер увеличивается на 1px.


И так — до полного выкатывания, т.е пока height < div.scrollHeight


Автор: EugenyK, дата: 18 июня, 2009 — 15:47

#permalink

Если менять высоту div’а итерациями от 0 до scrollHeight через глобальную функцию, то на достаточно большом кол-ве содержащихся в этом div’е дочерних элементов время загрузки очень сильно возрастает (независимо от пересчёта интервала времени вызова на бОльшую высоту). Такого не наблюдается, если создать класс и нужное кол-во раз вызывать метод.

Но эффектнее это выглядит, если сделать анимацию высоты от 0 до 5-10px, а от 5-10px сразу до scrollHeigh. В обратном порядке реализовать скрытие.


Автор: yarich (не зарегистрирован), дата: 27 октября, 2008 — 15:05

#permalink

хорошо бы добавить функционал сохранения состояния дерева при обновлении страницы


Автор: kirilloid (не зарегистрирован), дата: 5 июня, 2009 — 21:10

#permalink

Проблем-то. Добавляем функции serialize/unserialize и прикручиваем метод хранения данных по желанию: cookie / DOM storage / flash LSO …


Автор: alex_css (не зарегистрирован), дата: 7 ноября, 2008 — 15:37

#permalink

Отличная статья!


Код — предельно прост, ясен, без хаков, а главное — КОМПАКТЕН и РАБОЧИЙ!

Вот бы ещё додумать как сохранять состояние узлов в куки, а потом разворачивать.


Есть некий пример с DTHMLGoodies.com (называется folder-tree-static), там на JS написаны мудреные функции как раз для таких целей…. Однако моя башка никак не допрет как его можно применить здесь 🙁

Кстати насчёт индексации поисковиками — не думаю, что в областях «display: none;» они что-то будут индексировать. Всё-таки поисковики умнеют и защищаются от спама. ИМХО, мнение что гугльяндекс не разбирает css и не парсит эти display и none миф…


Автор: Гость (не зарегистрирован), дата: 24 ноября, 2008 — 13:46

#permalink

Хотел сделать так:


if (!hasClass(clickedElem,’Expand’) && !hasClass(clickedElem,’Content’)) {


return // клик не там


}


но что0-то не работает, нужно нажимать именно на крестих, а нужно ещё и чтобы при нажатии на имя нода раскрывался контейнер


Автор: Гость (не зарегистрирован), дата: 28 ноября, 2008 — 03:49

#permalink

Кто-то хотел запоминать состояние дерева. Есть вариант для простоты запоминать в куках последний активный узел, если считать, что активный узел — это раскрытый узел. При обновлении страницы JS скрипт после загрузки берет значение из кук и активирует (раскрывает) требуемый узел. Соответственно надо раскрыть и всех его предков перебором узлов родителей с классом, где нет «Leaf» в пределах дерева. Это просто сделать циклом, используя свойство .parentNode и пару проверок. Если предок не является «листом», то поставить ему стиль ExpandOpen. На мой взгляд, запоминать состояние всего дерева не практично, да и зачем это может быть нужно. Чтобы задача имела нормальное решение надо вводить разумные ограничения — иначе заколебешься.


Автор: Гость (не зарегистрирован), дата: 17 декабря, 2008 — 22:02

#permalink

а можете написать как это сделать?


Автор: Shock (не зарегистрирован), дата: 16 января, 2009 — 05:28

#permalink

Кстати, есть способ избежать использования класса IsRoot:


.Tree .Node {


background-image : url(img/i.gif);


background-position : top left;


background-repeat : repeat-y;


margin-left: 0;


zoom: 1;


}


.Tree .Node .Node {


margin-left: 18px;


}


Автор: Shock, дата: 16 января, 2009 — 06:04

#permalink

Очень понравился этот скрипт, потому незначительное улучшение от меня + класс на PHP для быстрого создания подобного списка.


Автор: Shock, дата: 16 января, 2009 — 06:09

#permalink

Файл JsTree.php

01 <?php
02 class JsTree {
03     private $final_html = null;
04     private $finally_generated = false;
05      
06     private $title = null;
07     private $tree = null;
08  
09     public function __construct($array) {
10         if(!is_array($array)) return;
11         $this->tree = $this->_parse_array_level($array);
12     }
13      
14     public function set_title($title) {
15         $this->title = $title;
16     }
17      
18     private function _node_generate($content, $children=null, $islast=false) {
19         if(!is_array($children)) $children = null;
20         $islast = $islast ? " IsLast" : "";
21         $expand = $children ? " ExpandClosed" : " ExpandLeaf";
22         $children