app/scripts/ui-lib/widgets/tree.coffee

Tree

The Tree class provides a tree widget.

author: Julien Ramboz version: 1.0 references: WAI-ARIA tree role, AOL's Tree View style guide usage: Tree examples requires: Toolbar

AMD loader

Try loading as AMD module or fall back to default loading

((widget) -> if typeof define is "function" and define.amd define ["jslib", "core", "widget", "toolbar"], widget else widget @$, _, _.AbstractWidget, _.Toolbar ) tree = ($, _, AbstractWidget, Toolbar) -> "use strict"

Widget

The actual widget class

class Tree extends Toolbar

Some default options

@defaultOptions:
  • autocollapse: should top nodes be automatically collapsed on selection of other nodes
autocollapse: false

Initialisation

Initializer function.

initialize: (options) -> super options @groups.each (i, el) => $group = $(el) if el.getAttribute("role") is "group" and not el.getAttribute("aria-expanded") $toggle = @createToogle() $group.before($toggle) $toggle["#{_.namespace}collapse"]()

Accessibility markup

Add aria attributes

aria: () -> @element.attr("role", "tree") @groups.attr("role", "group") that = @ @items.attr("role", "treeitem").each () -> $item = $(this) $group = $item.find("[role='group']") if $group.length $item.attr("aria-haspopup", "true") $label = $item.children(":not([data-widget])").filter () -> this.getAttribute("role") isnt "group" if $label.length and not this.getAttribute("aria-labelledby") id = $label.attr "id" if not id id = _.getGUID("tree-item-label-") $label.attr("id", id) $item.attr("aria-labelledby", id) $group.attr("aria-labelledby", id) $item.children("[aria-controls]").attr "aria-labelledby", $item.attr("aria-labelledby")

Make first item focusable

@items.attr("tabindex", -1).first().attr("tabindex", 0) @element.find("[aria-controls]").attr("tabindex", "-1")

Event handling

Attach evenets to the widget

bindEvents: () -> @handleKeys(@element) @element.on "click", "[role='treeitem']", (e) => if e.target.getAttribute("aria-disabled") is "true" return e.preventDefault() $(e.target).blur() $target = $(e.currentTarget) if @items.index($target) > -1 and (not @currentItem or e.currentTarget isnt @currentItem.get(0)) @selectItem($target, e) @element.on "click", "[aria-controls]", (e) => if e.target.getAttribute("aria-disabled") is "true" return e.preventDefault() $target = $(e.target).parents("[role='treeitem']").first() if @items.index($target) > -1 @selectItem($target, e) if @options.autocollapse is "true" or @options.autocollapse is true @hideOtherSubmenus(e)

Show the submenu for the current item if any

showSubmenu: (e) -> return if @currentItem.get(0).getAttribute("aria-haspopup") isnt "true" $toggle = if @currentItem.get(0).getAttribute("aria-controls") then @currentItem else @currentItem.find("[aria-controls]") $group = @currentItem.find("[role='group']").first() if $group.get(0).getAttribute("aria-hidden") is "true" _.getInstance($toggle, ["toggle","collapse"]).show(e) @currentItem else @selectItem($group.find("[role='treeitem']").first(), e)

Hide the submenu for the current item if any

hideSubmenu: (e) -> return if @currentItem.get(0).getAttribute("aria-haspopup") isnt "true" $toggle = if @currentItem.get(0).getAttribute("aria-controls") then @currentItem else @currentItem.find("[aria-controls]") _.getInstance($toggle, ["toggle","collapse"]).hide(e)

Hide all submenus except the ones in the path of the current item (auto collapse)

hideOtherSubmenus: (e) -> $parents = @currentItem.parents("[role='treeitem']").add(@currentItem) $toggles = @element.find("[aria-controls]").filter () -> not $parents.find($(this)).length $toggles.each () -> _.getInstance(this, ["toggle","collapse"]).hide(e)

Select the specified item and open the tree if necessary

selectItem: ($item, e) -> super $item $item .parents("[role='treeitem']") .children("[data-widget='collapse'][aria-expanded='false']") .each () -> _.getInstance(this, "collapse").show(e) $item

Structure discovery

Find the widget structure using the specified configuration

Tree groups

Find the tree groups, specified in one of the following ways:

  • elements with role="group"
  • using groups option, specified as a selector
findGroups: () -> $groups = @element.find("[role='group']") return $groups if $groups.length if @options.groups $(@options.groups, @element) else $()

Tree items

Find the tree items, specified in one of the following ways:

  • elements with role="treeitem"
  • using items option, specified as a selector
  • descendants of groups and the tree itself
  • descendants of the element
findItems: () -> $items = @element.find("[role='treeitem']") return $items if $items.length if @options.items $(@options.items, @element) else if @groups.length @element.children(":not([role])") .add(@groups.children(":not([role])")) else @element.children(":not([role])")

Structure creation

Create elements required by the structure if they are not present

Toggle element

createToogle: () -> $(document.createElement("span")).attr("data-widget", "collapse")

Key handling

Handle left key press

keyLeft: (e) -> $parent = @currentItem.parents("[role='treeitem']").first() $group = @currentItem.find("[role='group']").first() if @currentItem.get(0).getAttribute("aria-haspopup") is "true" and $group.get(0).getAttribute("aria-hidden") is "false" @hideSubmenu(e) else if $parent.length @selectItem($parent, e).focus()

Handle right key press

keyRight: (e) -> return if @currentItem.get(0).getAttribute("aria-haspopup") isnt "true" e.stopPropagation() @showSubmenu(e).focus() if @options.autocollapse is "true" or @options.autocollapse is true @hideOtherSubmenus(e)

Handle up key press

keyUp: (e) -> e.preventDefault() e.stopPropagation() @selectPreviousItem(e).focus()

Handle down key press

keyDown: (e) -> e.preventDefault() e.stopPropagation() @selectNextItem(e).focus()

Handle enter key press

keyEnter: (e) -> $children = @currentItem.children() if @currentItem.get(0).getAttribute("aria-haspopup") is "true" $toggle = if @currentItem.get(0).getAttribute("aria-controls") then @currentItem else @currentItem.find("[aria-controls]") _.getInstance($toggle, ["toggle","collapse"]).toggle(e) else if $children.length $children.click()

Handle home key press

keyHome: (e) -> e.preventDefault() @currentItem = @items.last() @selectNextItem(e).focus()

Handle end key press

keyEnd: (e) -> e.preventDefault() @currentItem = @items.first() @selectPreviousItem(e).focus()

Handle key press on numbers

keyNumber: (e) -> @keyLetter(e)

Handle key press on letters

keyLetter: (e) -> index = @items.index(e.target) + 1 loop return if index >= @items.length $node = @items.eq(index) label = $node.text().toUpperCase() if label.indexOf(String.fromCharCode(e.keyCode)) is 0 and not $node.parents("[role='group'][aria-expanded='false']").length break index++ $node.attr("tabindex", "0").focus()

Handle asterisk key press

keyAsterisk: (e) -> if not @options.autocollapse or @options.autocollapse isnt "true" $toggles = @element.find("[aria-controls]") if @element.find("[role='group'][aria-hidden='true']").length $toggles.each () -> _.getInstance(this, ["toggle","collapse"]).show(e) else $toggles.each () -> _.getInstance(this, ["toggle","collapse"]).hide(e)

Cleanup the widget and remove remaining references

destructor: () -> @element.removeAttr("role") super() @groups.removeAttr("role")

Installation

Install the widget into the JS library

Tree.install("Tree", () -> $("[data-widget='tree']")["#{_.namespace}tree"]())