UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

619 lines (549 loc) 20.4 kB
import $ from './jquery'; import 'jquery.hotkeys/jquery.hotkeys'; import '../jquery/jquery.moveto'; import * as logger from './internal/log'; import { I18n } from './i18n'; import { popup } from './dialog'; import globalize from './internal/globalize'; import keyCode from './key-code'; import { isEqual } from 'underscore'; const EMPTY_SELECTOR = false; var isMac = navigator.platform.indexOf('Mac') !== -1; var isSafari = navigator.userAgent.indexOf('Safari') !== -1; var multiCharRegex = /^(backspace|tab|r(ight|eturn)|s(hift|pace|croll)|c(trl|apslock)|alt|pa(use|ge(up|down))|e(sc|nd)|home|left|up|d(el|own)|insert|f\d\d?|numlock|meta)/i; /** * Trigger native click event. * @param $el */ const triggerClickEvent = ($el) => { const element = $el[0]; if (element) { // Native event bubbles are compatible with Synthetic Event from React const bubbles = true; const cancelable = true; const event = document.createEvent('MouseEvent'); event.initMouseEvent( 'click', bubbles, cancelable, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null ); element.dispatchEvent(event); } }; /** * Keyboard commands with syntactic sugar. * * <strong>Usage:</strong> * <pre> * whenIType("gh").or("gd").goTo("/secure/Dashboard.jspa"); * whenIType("c").click("#create_link"); * </pre> * * @param keys - Key combinations, modifier keys are "+" deliminated. e.g "ctrl+b" */ function whenIType(keys) { var boundKeyCombos = []; var executor = $.Callbacks(); function keypressHandler(e) { if (!popup.current && executor) { executor.fire(e); } } function defaultPreventionHandler(e) { e.preventDefault(); } // Bind an arbitrary set of keys by calling bindKeyCombo on each triggering key combo. // A string like "abc 123" means (a then b then c) OR (1 then 2 then 3). abc is one key combo, 123 is another. function bindKeys(keys) { var keyCombos = keys && keys.split ? $.trim(keys).split(' ') : [keys]; keyCombos.forEach(function (keyCombo) { bindKeyCombo(keyCombo); }); } function hasUnprintables(keysArr) { // a bit of a heuristic, but works for everything we have. Only the unprintable characters are represented with > 1-character names. var i = keysArr.length; while (i--) { if (keysArr[i].length > 1 && keysArr[i] !== 'space') { return true; } } return false; } // bind a single key combo to this handler // A string like "abc 123" means (a then b then c) OR (1 then 2 then 3). abc is one key combo, 123 is another. function bindKeyCombo(keyCombo) { var keysArr = keyCombo instanceof Array ? keyCombo : keyComboArrayFromString(keyCombo.toString()); var eventType = hasUnprintables(keysArr) ? 'keydown' : 'keypress'; boundKeyCombos.push(keysArr); $(document).on(eventType, EMPTY_SELECTOR, keysArr, keypressHandler); // Override browser/plugins $(document).on(eventType + ' keyup', EMPTY_SELECTOR, keysArr, defaultPreventionHandler); } // parse out an array of (modifier+key) presses from a single string // e.g. "12ctrl+3" becomes [ "1", "2", "ctrl+3" ] function keyComboArrayFromString(keyString) { var keysArr = []; var currModifiers = ''; while (keyString.length) { var modifierMatch = keyString.match(/^(ctrl|meta|shift|alt)\+/i); var multiCharMatch = keyString.match(multiCharRegex); if (modifierMatch) { currModifiers += modifierMatch[0]; keyString = keyString.substring(modifierMatch[0].length); } else if (multiCharMatch) { keysArr.push(currModifiers + multiCharMatch[0]); keyString = keyString.substring(multiCharMatch[0].length); currModifiers = ''; } else { keysArr.push(currModifiers + keyString[0]); keyString = keyString.substring(1); currModifiers = ''; } } return keysArr; } function addShortcutsToTitle(selector) { var elem = $(selector); var title = elem.attr('title') || ''; var keyCombos = boundKeyCombos.slice(); var existingCombos = elem.data('boundKeyCombos') || []; var kbShortcutAppended = elem.data('kbShortcutAppended') || ''; var kbShortcutAppendedScreenReader = elem.data('kbShortcutAppendedScreenReader') || ''; var ariaLabel = elem.attr('aria-label'); var isFirstShortcut = !kbShortcutAppended; var isFirstShortcutScreenReader = !kbShortcutAppendedScreenReader; var originalTitle = isFirstShortcut ? title : title.substring(0, title.length - kbShortcutAppended.length); var originalAriaLabel = isFirstShortcutScreenReader ? title : ariaLabel.substring(0, ariaLabel.length - kbShortcutAppendedScreenReader.length); while (keyCombos.length) { var keyCombo = keyCombos.shift(); var comboAlreadyExists = existingCombos.some(function (existingCombo) { return isEqual(keyCombo, existingCombo); }); if (!comboAlreadyExists) { kbShortcutAppended = appendKeyComboInstructions( keyCombo.slice(), kbShortcutAppended, isFirstShortcut ); kbShortcutAppendedScreenReader = appendKeyComboInstructions( keyCombo.slice(), kbShortcutAppendedScreenReader, isFirstShortcutScreenReader, { workaroundJaws: true } ); isFirstShortcut = false; isFirstShortcutScreenReader = false; } } if (isMac) { kbShortcutAppended = kbShortcutAppended .replace(/Meta/gi, '\u2318') //Apple cmd key .replace(/Shift/gi, '\u21E7'); //Apple Shift symbol kbShortcutAppendedScreenReader = kbShortcutAppendedScreenReader .replace(/Meta/gi, '\u2318') //Apple cmd key .replace(/Shift/gi, '\u21E7'); //Apple Shift symbol } elem.attr('title', originalTitle + kbShortcutAppended); elem.attr('aria-label', originalAriaLabel + kbShortcutAppendedScreenReader); elem.data('kbShortcutAppended', kbShortcutAppended); elem.data('kbShortcutAppendedScreenReader', kbShortcutAppendedScreenReader); elem.data('boundKeyCombos', existingCombos.concat(boundKeyCombos)); } function removeShortcutsFromTitle(selector) { var elem = $(selector); var shortcuts = elem.data('kbShortcutAppended'); if (!shortcuts) { return; } var title = elem.attr('title'); elem.attr('title', title.replace(shortcuts, '')); elem.attr('aria-label', title.replace(shortcuts, '')); elem.removeData('kbShortcutAppended'); elem.removeData('kbShortcutAppendedScreenReader'); elem.removeData('boundKeyCombos'); } function appendKeyComboInstructions(keyCombo, title, isFirst, options) { var openParenthesis = '('; var closeParenthesis = ')'; if (options && options.workaroundJaws) { openParenthesis = ''; closeParenthesis = ''; } if (isFirst) { title += ' ' + openParenthesis + I18n.getText('aui.keyboard.shortcut.type.x', keyCombo.shift()); } else { title = title.replace(/\)$/, ''); title += ' ' + I18n.getText('aui.keyboard.shortcut.or.x', keyCombo.shift()); } keyCombo.forEach(function (key) { title += ' ' + I18n.getText('aui.keyboard.shortcut.then.x', key); }); title += closeParenthesis; return title; } bindKeys(keys); return whenIType.makeShortcut({ executor: executor, bindKeys: bindKeys, addShortcutsToTitle: addShortcutsToTitle, removeShortcutsFromTitle: removeShortcutsFromTitle, keypressHandler: keypressHandler, defaultPreventionHandler: defaultPreventionHandler, }); } whenIType.makeShortcut = function (options) { var executor = options.executor; var bindKeys = options.bindKeys; var addShortcutsToTitle = options.addShortcutsToTitle; var removeShortcutsFromTitle = options.removeShortcutsFromTitle; var keypressHandler = options.keypressHandler; var defaultPreventionHandler = options.defaultPreventionHandler; var selectorsWithTitlesModified = []; function makeMoveToFunction(getNewFocus) { return function (selector, options) { options = options || {}; var focusedClass = options.focusedClass || 'focused'; var wrapAround = options.hasOwnProperty('wrapAround') ? options.wrapAround : true; var escToCancel = options.hasOwnProperty('escToCancel') ? options.escToCancel : true; executor.add(function () { const $items = $(selector); let $focusedElem = $items.filter('.' + focusedClass); const moveToOptions = $focusedElem.length === 0 ? undefined : { transition: true }; if (escToCancel) { $(document).one('keydown', function (e) { if (e.keyCode === keyCode.ESCAPE && $focusedElem) { $focusedElem.removeClass(focusedClass); } }); } if ($focusedElem.length) { $focusedElem.removeClass(focusedClass); } $focusedElem = getNewFocus($focusedElem, $items, wrapAround); if ($focusedElem && $focusedElem.length > 0) { $focusedElem.addClass(focusedClass); $focusedElem.moveTo(moveToOptions); if ($focusedElem.is('a')) { $focusedElem.focus(); } else { $focusedElem.find('a:first').focus(); } } }); return this; }; } return { /** * Scrolls to and adds <em>focused</em> class to the next item in the jQuery collection * * @method moveToNextItem * @param selector * @param options * @return {Object} */ moveToNextItem: makeMoveToFunction(function ($focusedElem, $items, wrapAround) { var index; if (wrapAround && $focusedElem.length === 0) { return $items.eq(0); } else { index = $.inArray($focusedElem.get(0), $items); if (index < $items.length - 1) { index = index + 1; return $items.eq(index); } else if (wrapAround) { return $items.eq(0); } } return $focusedElem; }), /** * Scrolls to and adds <em>focused</em> class to the previous item in the jQuery collection * * @method moveToPrevItem * @param selector * @param focusedClass * @return {Object} */ moveToPrevItem: makeMoveToFunction(function ($focusedElem, $items, wrapAround) { var index; if (wrapAround && $focusedElem.length === 0) { return $items.filter(':last'); } else { index = $.inArray($focusedElem.get(0), $items); if (index > 0) { index = index - 1; return $items.eq(index); } else if (wrapAround) { return $items.filter(':last'); } } return $focusedElem; }), /** * Clicks the element specified by the <em>selector</em> argument. * * @method click * @param selector - jQuery selector for element * @return {Object} */ click: function (selector) { selectorsWithTitlesModified.push(selector); addShortcutsToTitle(selector); executor.add(function () { const $el = $(selector); triggerClickEvent($el); }); return this; }, /** * Navigates to specified <em>location</em> * * @method goTo * @param {String} location - http location * @return {Object} */ goTo: function (location) { executor.add(function () { window.location.href = location; }); return this; }, /** * navigates browser window to link href * * @method followLink * @param selector - jQuery selector for element * @return {Object} */ followLink: function (selector) { selectorsWithTitlesModified.push(selector); addShortcutsToTitle(selector); executor.add(function () { var elem = $(selector)[0]; if (elem && { a: true, link: true }[elem.nodeName.toLowerCase()]) { window.location.href = elem.href; } }); return this; }, /** * Executes function * * @method execute * @param {function} func * @return {Object} */ execute: function (func) { var self = this; executor.add(function () { func.apply(self, arguments); }); return this; }, /** * @deprecated This implementation is uncool. Kept around to satisfy Confluence plugin devs in the short term. * * Executes the javascript provided by the shortcut plugin point _immediately_. * * @method evaluate * @param {Function} command - the function provided by the shortcut key plugin point */ evaluate: function (command) { command.call(this); }, /** * Scrolls to element if out of view, then clicks it. * * @method moveToAndClick * @param selector - jQuery selector for element * @return {Object} */ moveToAndClick: function (selector) { selectorsWithTitlesModified.push(selector); addShortcutsToTitle(selector); executor.add(function () { const $el = $(selector); if ($el.length > 0) { triggerClickEvent($el); $el.moveTo(); } }); return this; }, /** * Scrolls to element if out of view, then focuses it * * @method moveToAndFocus * @param selector - jQuery selector for element * @return {Object} */ moveToAndFocus: function (selector) { selectorsWithTitlesModified.push(selector); addShortcutsToTitle(selector); executor.add(function (e) { var $elem = $(selector); if ($elem.length > 0) { $elem.focus(); if ($elem.moveTo) { $elem.moveTo(); } if ($elem.is(':input')) { e.preventDefault(); } } }); return this; }, /** * Binds additional keyboard controls * * @method or * @param {String} keys - keys to bind * @return {Object} */ or: function (keys) { bindKeys(keys); return this; }, /** * Unbinds shortcut keys * * @method unbind */ unbind: function () { $(document) .unbind('keydown keypress', keypressHandler) .unbind('keydown keypress keyup', defaultPreventionHandler); for (var i = 0, len = selectorsWithTitlesModified.length; i < len; i++) { removeShortcutsFromTitle(selectorsWithTitlesModified[i]); } selectorsWithTitlesModified = []; }, }; }; /** * Creates keyboard commands and their actions from json data. Format looks like: * * <pre> * [ * { * "keys":[["g", "d"]], * "context":"global", * "op":"followLink", * "param":"#home_link" * }, * { * "keys":[["g", "i"]], * "context":"global", * "op":"followLink", * "param":"#find_link" * }, * { * "keys":[["/"]], * "context":"global", * "op":"moveToAndFocus", * "param":"#quickSearchInput" * }, * { * "keys":[["c"]], * "context":"global", * "op":"moveToAndClick", * "param":"#create_link" * } * ] * </pre> * * @method fromJSON * @static * @param json */ whenIType.fromJSON = function (json, switchCtrlToMetaOnMac) { var shortcuts = []; if (json) { $.each(json, function (i, item) { const operation = item.op; const param = item.param; let params; if (operation === 'execute' || operation === 'evaluate') { // need to turn function string into function object params = [new Function(param)]; } else if (/^\[[^\]\[]*,[^\]\[]*\]$/.test(param)) { // pass in an array to send multiple params try { params = JSON.parse(param); // eslint-disable-next-line no-unused-vars } catch (e) { logger.error( 'When using a parameter array, array must be in strict JSON format: ' + param ); } if (!$.isArray(params)) { logger.error( 'Badly formatted shortcut parameter. String or JSON Array of parameters required: ' + param ); } } else { params = [param]; } $.each(item.keys, function () { var shortcutList = this; if (switchCtrlToMetaOnMac && isMac) { shortcutList = $.map(shortcutList, function (shortcutString) { return shortcutString.replace(/ctrl/i, 'meta'); }); } var newShortcut = whenIType(shortcutList); newShortcut[operation].apply(newShortcut, params); shortcuts.push(newShortcut); }); }); } return shortcuts; }; // Trigger this event on an iframe if you want its keypress events to be propagated (Events to work in iframes). $(document).on('iframeAppended', function (e, iframe) { $(iframe).load(function () { var $target = $(iframe).contents(); $target.on('keyup keydown keypress', function (e) { // safari propagates keypress events from iframes if (isSafari && e.type === 'keypress') { return; } if (!$(e.target).is(':input')) { $.event.trigger( e, arguments, // Preserve original event data. document, // Bubble this event from the iframe's document to its parent document. true // Use the capturing phase to preserve original event.target. ); } }); }); }); globalize('whenIType', whenIType); export default whenIType;