216 lines
7.5 KiB
JavaScript
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)
|
|
})
|
|
}
|