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

Menu

The Menu class provides a simple menu widget.

author: Julien Ramboz version: 1.0 references: WAI-ARIA menu role, AOL's Menu style guide usage: Menu examples

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"], widget else widget @$, _, _.AbstractWidget ) menu = ($, _, AbstractWidget) -> "use strict"

Widget

The actual widget class

class Menu extends AbstractWidget

Default options for the widget

@defaultOptions:
  • items: a selector pointing to the items (from within the groups if specified, or the menu)
items: ""

Initialisation

Initializer function.

initialize: (options) -> super options @items = @findItems() @currentItem = @items.first()

Enhance submenus on small devices

@element.closest('[role="menuitem"]').each (index, el) => text = $("##{el.getAttribute('aria-labelledby')}").text() || $(el).children('[aria-controls],span').text() @element.prepend( $(document.createElement('span')) .addClass 'menu-item menu-item-back' .attr 'data-role', 'back' .text text )

Accessibility markup

Add aria attributes

aria: () -> @element.attr("role", "menu") @items.attr("role", "menuitem").each () -> $item = $(this) if $item.children("[data-widget='menu'],[role='menu']").length $item.attr("aria-haspopup", "true") $label = $item.children().filter () -> not $(this).is("[data-widget='menu'],[role='menu']") if $label.length > 1 and not $item.attr("aria-labelledby") id = $label.attr("id") if not id id = _.getGUID("menu-item-label-") $label.attr("id", id) $item.attr("aria-labelledby", id) @items.first().attr("tabindex", 0)

Event handling

Attach evenets to the widget

bindEvents: () -> @handleKeys(@element) @element.on "click", "[role='menuitem']", (e) => if e.target.getAttribute("aria-disabled") is "true" return e.preventDefault() e.stopPropagation()

Close opened submenus

@items.filter(() -> this isnt e.target and not $(this).find(e.target).length) .find("[aria-controls][aria-expanded='true']").each () -> _.getInstance(this, ["collapse", "toggle"]).hide(e) $item = $(e.target) @selectItem($item)

Open submenu if any

$toggle = @getToggle($item) if $toggle and $toggle.length instance = _.getInstance($toggle, ["collapse", "toggle"]) instance.toggle(undefined, e) @element.on "beforeToggle", (e, o) -> $(o.toggle) .parents '[role="menu"],[role="menubar"]' .addClass 'menu-parent' $(o.toggle) .parents '[role="menuitem"]' .addClass 'menu-item-ancestor' .removeClass 'menu-item-parent' if o.status $(o.toggle) .closest '[role="menuitem"]' .addClass 'menu-item-parent' else $(o.toggle) .closest '[role="menuitem"]' .removeClass 'menu-item-parent menu-item-ancestor' .closest '[role="menu"],[role="menubar"]' .removeClass 'menu-parent' $(o.toggle) .parents('[role="menuitem"]').eq(1) .addClass 'menu-item-parent' @element.on "afterToggle", (e, o) => e.stopPropagation() if o.event and o.event.type is "keydown" if o.status @selectItem().focus() else if @items.index($(o.toggle)) > -1 @selectItem($(o.toggle)).focus() @element.on "click", "[data-role='back']", (e) => e.stopPropagation() $toggle = $("[aria-controls=\"#{@element.attr('id')}\"]") _.getInstance($toggle, ["collapse", "toggle"]).hide(e)

Select the previous item

selectPreviousItem: () -> index = @items.index(@currentItem or @items.first()) - 1 @selectItem(@items.eq(index))

Select the next item

selectNextItem: () -> index = (@items.index(@currentItem or @items.last()) + 1) % @items.length @selectItem(@items.eq(index))

Select the specified item

selectItem: ($item) -> $item = @items.first() if not $item @currentItem.removeAttr("tabindex") if @currentItem @currentItem = $item $item.attr("tabindex", 0)

Close the menu

close: (e) -> $("[aria-controls*='#{@element.attr("id")}']").each () -> _.getInstance(this, ["collapse", "toggle"]).hide(e)

Close the menu and its parents

closeAll: (e) -> $toggle = @close(e) $menu = $toggle.parents("[role='menu']") if $menu.length $menu["#{_.namespace}menu"]("closeAll")

Structure discovery

Find the widget structure using the specified configuration

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

  • elements with role="menuitem"
  • using items option (i.e. "id1 id2")
  • the children elements of the menu
findItems: () -> $items = @element.find("[role='menuitem']") return $items if $items.length if @options.items $(@options.items, @element) else @element.children()

Toggles

Get the toggles for a specified item

getToggle: ($item) -> $item = @currentItem if not $item return if $item.get(0).getAttribute("aria-haspopup") isnt "true" #return $item if $item.is("[aria-controls]") $item.children("[aria-controls]")

Controls

Get the controls for the menu

getControls: () -> $controls = $("[aria-controls*='#{@element.attr("id")}']") $menuitem = $controls.parent "[role='menuitem']" $menuitem.length and $menuitem or $controls

Key handling

Handle space key press

keySpace: (e) -> e.preventDefault() e.stopPropagation() return if @currentItem.get(0).getAttribute("aria-disabled") is "true" if @currentItem.get(0).getAttribute("aria-haspopup") is "true" _.getInstance(@getToggle(), ["collapse", "toggle"]).toggle(undefined, e)

Handle enter key press

keyEnter: (e) -> @keySpace(e)

Handle up key press

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

Handle down key press

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

Handle left key press

keyLeft: (e) -> $target = $(e.target) $menus = $target.parents("[role='menu']") if $menus.length > 1 e.stopPropagation() @close(e) @getControls().focus()

Handle right key press

keyRight: (e) -> $target = $(e.target) if e.target.getAttribute("aria-haspopup") is "true" e.stopPropagation() @keySpace(e)

Handle escape key press

keyEscape: (e) -> e.preventDefault() @closeAll(e) @getControls().focus()

Handle tab key press

keyTab: (e) -> @closeAll(e)

Cleanup

Cleanup the widget and remove remaining references

destructor: () ->

Installation

Install the widget into the JS library

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

Close all menus when exiting one...

$(window).on "click", (e) -> $target = $(e.target) $parent = $target.parents("[aria-controls]") $target = $parent.eq(0) if $parent.length $menus = $("[role='menu'][aria-hidden='false']")

... and do not close the menus controlled by a toggle

if e.target.getAttribute "aria-controls" $menus = $menus.filter(() -> not ($target.is("[aria-controls='#{this.id}']") or $(this).find($target).length))

... and do not close parent menus or submenus when clicking a menuitem

else if e.target.getAttribute("role") is "menuitem" $menus = $menus.filter () -> not ($(this).find($target).length or $target.find($(this)).length) else

... and do not close the mainnav menus

$menus = $menus.filter () -> not $(this).closest('.mainnav').length

... and do not close parent menu

$menus = $menus.filter () -> not $(this).find($target).length $menus.each () -> _.getInstance(this, "menu").close(e)