@atlassian/aui
Version:
Atlassian User Interface library
619 lines (549 loc) • 20.4 kB
JavaScript
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;