canvas-lms/packages/jquery-kyle-menu/index.js

216 lines
7.5 KiB
JavaScript

//
// Copyright (C) 2011 - present Instructure, Inc.
//
// This file is part of Canvas.
//
// Canvas is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, version 3 of the License.
//
// Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
// A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
// details.
//
// You should have received a copy of the GNU Affero General Public License along
// with this program. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery'
import './monkey-patches'
import 'jqueryui/button'
import './popup'
/*
* PLEASE READ BEFORE MODIFYING THIS FILE:
* This provides the 'admin cog' menus amongst other things used throughout
* Canvas. It has been extensively tested for accessibility. Before making
* any changes to this file, please check with someone about the accessibility
* repercussions of what you intend to do.
*/
export default class KyleMenu {
constructor(trigger, options) {
;['onOpen', 'select', 'onClose', 'close', 'keepButtonActive'].forEach(
m => (this[m] = this[m].bind(this))
)
this.$trigger = $(trigger).data('kyleMenu', this)
this.$ariaMenuWrapper = this.$trigger.parent()
this.opts = $.extend(true, {}, KyleMenu.defaults, options)
if (!this.opts.noButton) {
if (this.opts.buttonOpts.addDropArrow) {
this.$trigger.append('<i class="icon-mini-arrow-down"></i>')
}
this.$trigger.button(this.opts.buttonOpts)
// this is to undo the removal of the 'ui-state-active' class that jquery.ui.button
// does by default on mouse out if the menu is still open
this.$trigger.bind('mouseleave.button', this.keepButtonActive)
}
this.$menu = this.$trigger
.next()
.menu(this.opts.menuOpts)
.popup(this.opts.popupOpts)
.addClass('ui-kyle-menu use-css-transitions-for-show-hide')
// passing an appendMenuTo option when initializing a kylemenu helps get around popup being hidden
// by overflow:scroll on its parents
// but by doing so we need to make sure that click events still get propagated up in case we
// were delegating events to a parent container
if (this.opts.appendMenuTo) {
// to keep tab order when appended out of place
this.$menu.on({
keydown: e => {
if (e.keyCode === $.ui.keyCode.TAB) {
let tabKey
if (e.shiftKey) {
tabKey = {
which: $.ui.keyCode.TAB,
shiftKey: true
}
} else {
tabKey = {
which: $.ui.keyCode.TAB
}
}
const pressTab = $.Event('keydown', tabKey)
this.$trigger.focus().trigger(pressTab)
}
}
})
const popupInstance = this.$menu.data('popup')
const _open = popupInstance.open
const self = this
// monkey patch just this plugin instance not $.ui.popup.prototype.open
popupInstance.open = function() {
self.$menu.appendTo(self.opts.appendMenuTo)
return _open.apply(this, arguments)
}
this.$placeholder = $('<span style="display:none;">').insertAfter(this.$menu)
this.$menu.bind('click', (...args) => this.$placeholder.trigger(...args))
}
// passing a notifyMenuActiveOn option when initializing a kylemenu helps
// get around issue of page-specific parent elements needing to know when the menu
// is active and removed. The value of the option is a CSS selector for a parent
// element of the trigger.
if (this.opts.notifyMenuActiveOnParent) {
this.$notifyParent = this.$trigger.closest(this.opts.notifyMenuActiveOnParent)
}
this.$menu.on({
menuselect: this.select,
popupopen: this.onOpen,
popupclose: this.onClose
})
}
onOpen(event) {
this.$ariaMenuWrapper.attr('role', 'application')
this.adjustCarat(event)
this.$menu.addClass('ui-state-open')
if (this.opts.notifyMenuActiveOnParent) this.$notifyParent.addClass('menu_active')
}
open() {
this.$menu.popup('open')
}
select(e, ui) {
let $target
if ((e.originalEvent && e.originalEvent.type) !== 'click' && ($target = $(ui.item).find('a'))) {
e.preventDefault()
const el = $target[0]
const event = document.createEvent('MouseEvent')
event.initEvent('click', true, true)
el.dispatchEvent(event)
}
this.close()
}
onClose() {
if (this.opts.appendMenuTo) this.$menu.insertBefore(this.$placeholder)
this.$trigger.removeClass('ui-state-active')
this.$ariaMenuWrapper.removeAttr('role')
this.$menu.removeClass('ui-state-open')
if (this.opts.notifyMenuActiveOnParent) this.$notifyParent.removeClass('menu_active')
// passing a returnFocusTo option when initializing a kylemenu provides an
// interface to ensure focus is not lost and returned to the body. This was
// introduced specifically to address the complexity of dynamically-
// generated menus. This rule will not be honored if the returnFocusTo
// element becomes disabled.
if (this.opts.returnFocusTo && !this.opts.returnFocusTo.prop('disabled')) {
// Wait for one frame to see if anything else has claimed focus.
requestAnimationFrame(() => {
if (document.body === document.activeElement) {
// If focus still remains on the document body, return focus to the originating element.
this.opts.returnFocusTo.focus()
}
})
}
}
close() {
this.$menu.hasClass('ui-state-open') && this.$menu.popup('close').removeClass('ui-state-open')
}
keepButtonActive() {
if (this.$menu.is('.ui-state-open') && this.$trigger.is('.btn, .ui-button')) {
this.$trigger.addClass('ui-state-active')
}
}
// handle sticking the carat right below where you clicked on the button
adjustCarat(event) {
if (this.$carat) this.$carat.remove()
if (this.$trigger.is('.btn, .ui-button')) this.$trigger.addClass('ui-state-active')
const triggerWidth = this.$trigger.outerWidth()
const triggerOffsetLeft = this.$trigger.offset().left
// the menu may have flipped above the trigger
const mbox = this.$menu[0].getBoundingClientRect()
const tbox = this.$trigger[0].getBoundingClientRect()
const caratIsBelow = mbox.top + mbox.height < tbox.top
const caratY = mbox.height - 2
// if it is a mouse event, it will have a 'pageX' otherwise use the middle of the trigger
const pointToDropDownFrom = event.pageX || triggerOffsetLeft + triggerWidth / 2
const differenceInOffset = triggerOffsetLeft - this.$menu.offset().left
const actualOffset = pointToDropDownFrom - this.$trigger.offset().left
const caratOffset = Math.min(Math.max(6, actualOffset), triggerWidth - 6) + differenceInOffset
this.$carat = $('<span class="ui-menu-carat"><span /></span>').css('left', caratOffset)
if (caratIsBelow) {
this.$carat.css('top', caratY).css('transform', 'rotateX(180deg)')
}
this.$carat.prependTo(this.$menu)
}
static defaults = {
popupOpts: {
position: {
my: 'center top',
at: 'center bottom',
offset: '0 10px',
within: '#main',
collision: 'flipfit'
}
},
buttonOpts: {
addDropArrow: true
}
}
}
// expose jQuery plugin
$.fn.kyleMenu = function(options) {
return this.each(function() {
if (!$(this).data().kyleMenu) new KyleMenu(this, options)
})
}