Prevent background items from receiving focus while modal overlay is covering them? - javascript

I am working on making an overlay modal more accessible. It works essentially like this JSFiddle. When you open the modal, the focus doesn't properly go into the modal, and it continues to focus on other (hidden, background) items in the page.
You can see in my JSFiddle demo that I have already used aria-controls, aria-owns, aria-haspopup and even aria-flowto.
<button
aria-controls="two"
aria-owns="true"
aria-haspopup="true"
aria-flowto="two"
onclick="toggleTwo();"
>
TOGGLE DIV #2
</button>
However, while using MacOS VoiceOver, none of these do what I intend (though VoiceOver does respect the aria-hidden that I set on div two).
I know that I could manipulate the tabindex, however, values above 0 are bad for accessibility, so my only other option would be to manually find all focusable elements on the page and set them to tabindex=-1, which is not feasible on this large, complicated site.
Additionally, I've looked into manually intercepting and controlling tab behavior with Javascript, so that the focus is moved into the popup and wraps back to the top upon exiting the bottom, however, this has interfered with accessibility as well.

Focus can be moved with the focus() method. I've updated the jsFiddle with the intended behavior. I tested this on JAWS on Windows and Chrome.
I've added a tabindex="-1" on the "two" div to allow it to be focusable with the focus method.
I split the toggle function into two functions, this can probably be refactored to fit your needs, but one function sets the aria-hidden attribute to true and moves the focus on the newly opened modal, and the other function does the reverse.
I removed the excessive aria attributes, the first rule of aria is to only use it when necessary. This can cause unexpected behavior if you're just mashing in aria.
To keep focus within the modal, unfortunately one of the best options is to set all other active elements to tabindex="-1" or aria-hidden="true". I've applied an alternative where an event listener is added to the last element in the modal upon tabbing. To be compliant, another listener must be added to the first element to move focus to the last element upon a shift+tab event.
Unfortunately, to my knowledge there isn't a cleaner answer than those above solutions to keeping focus within a modal.

Use role = "dialog" aria-modal="true" on your modal popup

aria-disabled vs aria-hidden
First, note that aria-hidden is not intended to be used when the element is visible on the screen:
Indicates that the element and all of its descendants are not visible or perceivable to any user
The option you should use is aria-disabled
Indicates that the element is perceivable but disabled, so it is not editable or otherwise operable.
on using tabindex
Removing a link from the tabindex is a WCAG failure if this link is still perceivable from a screenreader or clickable. It has to be used conjointly with aria-disabled or better the disabled attribute.
Disabling mouse events using pointer-events css property
The easiest way to disable mouse events is by using the pointer-events css property:
pointer-events: none;
Disabling keyboard focus
The jQuery :focusable selecter is the easiest thing you could use
$("#div1 :focusable").attr("tabindex", -1);
sample code
$("#div1 :focusable")
.addClass("unfocus")
.attr("tabindex", -1)
.attr("disabled", true);
$("button").on("click", function(){
$(".unfocus").attr("tabindex", 0)
.removeClass("unfocus")
.removeAttr("disabled");
});
.unfocus {
pointer-events: none;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://code.jquery.com/ui/1.12.0/jquery-ui.min.js"></script>
<div id="div1">
non clickable link
<div tabindex="0">
non focusable div
</div>
</div>
<div id="div2">
<button>click here to restore other links</button>
</div>

Make the first and the last focusable element of your modal react on event, resp. on pressing tab and shift+tab. As far as I tested, it works everywhere.
Example:
function createFocusCycle (first, last) {
first.addEventListener('keydown', function(e){
if (e.keyCode===9 && e.shiftKey) {
last.focus();
e.preventDefault();
}});
last.addEventListener('keydown', function(e){
if (e.keyCode===9) {
first.focus();
e.preventDefault();
}});
}
Naturally, you need to know what is the first and the last focusable element of your modal. Normally it shouldn't be too complicated.
Otherwise if you don't know what are the first and last focusable elements of your modal, it's perhaps a sign that you are making a too complex UI.

In the future this could be solved with the inert attribute: https://github.com/WICG/inert/blob/7141197b35792d670524146dca7740ae8a83b4e8/explainer.md

I used this solution of focusguard element that focus on it moves the focus to the desired element, using JS.
Found it here:
https://jsfiddle.net/dipish/F82Xj/
<p>Some sample content here...</p>
<p>Like, another <input type="text" value="input" /> element or a <button>button</button>...</p>
<!-- Random content above this comment -->
<!-- Special "focus guard" elements around your
if you manually set tabindex for your form elements, you should set tabindex for the focus guards as well -->
<div class="focusguard" id="focusguard-1" tabindex="1"></div>
<input id="firstInput" type="text" tabindex="2" />
<input type="text" tabindex="3" />
<input type="text" tabindex="4" />
<input type="text" tabindex="5" />
<input type="text" tabindex="6" />
<input id="lastInput" type="text" tabindex="7" />
<!-- focus guard in the end of the form -->
<div class="focusguard" id="focusguard-2" tabindex="8"></div>
<!-- Nothing underneath this comment -->
JS:
$('#focusguard-2').on('focus', function() {
$('#firstInput').focus();
});
$('#focusguard-1').on('focus', function() {
$('#lastInput').focus();
});

As far as I know, there is no native HTML aria support to get back the same focus when a modal is closed.
aria-modal is going to replace aria-hidden. It should used in combination with role="alertdialog". This www.w3.org/TR/wai-aria-practices-1.1 page explains what they do and offers a complex example. Inspired by this, I made a minimal snippet.
Never use tabindex higher than 0. tabindex="0" is set to the modals heading. So it gets focused with the tab key. The opening button is saved in a variable lastFocusedElement. When the modal is closed, the focus gets back to there.
window.onload = function () {
var lastFocusedElement;
// open dialog
document.querySelector('#open-dialog').addEventListener('click', (e) => {
document.querySelector('#dialog').classList.add('d-block');
document.querySelector('#backdrop').classList.add('d-block');
lastFocusedElement = e.currentTarget;
});
// close dialog and back to last focused element
document.querySelector('#close-dialog').addEventListener('click', (e) => {
document.querySelector('#dialog').classList.remove('d-block');
document.querySelector('#backdrop').classList.remove('d-block');
lastFocusedElement.focus();
});
}
h2 { font-size: 1em }
.d-block {
display: block !important;
}
.dialog {
display: none;
position: fixed;
top: 1rem;
width: 25rem;
padding: 1rem;
background: #fff;
border: 1px solid #000;
z-index: 1050;
font-family: arial, sans-serif;
font-size: .8em;
}
#backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 1040;
background: rgba(0, 0, 0, 0.5);
}
<label for="just-a-label">Just a label</label>
<button id="open-dialog" type="button" aria-labelledby="just-a-label">open dialog</button>
<div id="dialog" class="dialog" role="alertdialog" aria-modal="true" aria-labelledby="dialog-label" aria-describedby="dialog-desc">
<h2 id="dialog-label" tabindex="0">PRESS TAB to get here</h2>
<div id="dialog-desc">
<p>Dialog Description.</p>
</div>
<div>
<label for="formfield">
<span>another formfield:</span>
<input id="formfield" type="text">
</label>
</div>
<hr>
<div>
<button id="close-dialog" type="button" tabindex="0">CLOSE (and focus back to open button)</button>
</div>
</div>
<div id="backdrop"></div>

I know it's a little late but that's how I resolve the issue of background focus on the modal. I will provide two solutions one for "talkback" and another one is for "Switch Access" which will work for the tab key too.
For Talkback:
function preventFocusOnBackground(ariaHide) {
$("body > *").not("#modalId").attr("aria-hidden", ariaHide);
}
// when you close the modal
preventFocusOnBackground(false);
// when you open the modal
preventFocusOnBackground(true)
For Switch Access/Control copy/paste this code in your file:
var aria = aria || {};
aria.Utils = aria.Utils || {};
(function () {
/*
* When util functions move focus around, set this true so the focus
listener
* can ignore the events.
*/
aria.Utils.IgnoreUtilFocusChanges = false;
aria.Utils.dialogOpenClass = 'has-dialog';
/**
* #desc Set focus on descendant nodes until the first focusable
element is
* found.
* #param element
* DOM node for which to find the first focusable descendant.
* #returns
* true if a focusable element is found and focus is set.
*/
aria.Utils.focusFirstDescendant = function (element) {
for (var i = 0; i < element.childNodes.length; i++) {
var child = element.childNodes[i];
if (aria.Utils.attemptFocus(child) ||
aria.Utils.focusFirstDescendant(child)) {
return true;
}
}
return false;
}; // end focusFirstDescendant
/**
* #desc Find the last descendant node that is focusable.
* #param element
* DOM node for which to find the last focusable descendant.
* #returns
* true if a focusable element is found and focus is set.
*/
aria.Utils.focusLastDescendant = function (element) {
for (var i = element.childNodes.length - 1; i >= 0; i--) {
var child = element.childNodes[i];
if (aria.Utils.attemptFocus(child) ||
aria.Utils.focusLastDescendant(child)) {
return true;
}
}
return false;
}; // end focusLastDescendant
/**
* #desc Set Attempt to set focus on the current node.
* #param element
* The node to attempt to focus on.
* #returns
* true if element is focused.
*/
aria.Utils.attemptFocus = function (element) {
if (!aria.Utils.isFocusable(element)) {
return false;
}
aria.Utils.IgnoreUtilFocusChanges = true;
try {
element.focus();
}
catch (e) {
}
aria.Utils.IgnoreUtilFocusChanges = false;
return (document.activeElement === element);
}; // end attemptFocus
/* Modals can open modals. Keep track of them with this array. */
aria.OpenDialogList = aria.OpenDialogList || new Array(0);
/**
* #returns the last opened dialog (the current dialog)
*/
aria.getCurrentDialog = function () {
if (aria.OpenDialogList && aria.OpenDialogList.length) {
return aria.OpenDialogList[aria.OpenDialogList.length - 1];
}
};
aria.Utils.isFocusable = function(element) {
return element.classList && element.classList.contains('focusable');
}
aria.closeCurrentDialog = function () {
var currentDialog = aria.getCurrentDialog();
if (currentDialog) {
currentDialog.close();
return true;
}
return false;
};
document.addEventListener('keyup', aria.handleEscape);
/**
* #constructor
* #desc Dialog object providing modal focus management.
*
* Assumptions: The element serving as the dialog container is present
in the
* DOM and hidden. The dialog container has role='dialog'.
*
* #param dialogId
* The ID of the element serving as the dialog container.
* #param focusAfterClosed
* Either the DOM node or the ID of the DOM node to focus
* when the dialog closes.
* #param focusFirst
* Optional parameter containing either the DOM node or the
ID of the
* DOM node to focus when the dialog opens. If not specified, the
* first focusable element in the dialog will receive focus.
*/
aria.Dialog = function (dialogId, focusAfterClosed, focusFirst) {
this.dialogNode = document.getElementById(dialogId);
if (this.dialogNode === null) {
throw new Error('No element found with id="' + dialogId + '".');
}
var validRoles = ['dialog', 'alertdialog'];
var isDialog = (this.dialogNode.getAttribute('role') || '')
.trim()
.split(/\s+/g)
.some(function (token) {
return validRoles.some(function (role) {
return token === role;
});
});
if (!isDialog) {
throw new Error(
'Dialog() requires a DOM element with ARIA role of dialog or
alertdialog.');
}
// Wrap in an individual backdrop element if one doesn't exist
// Native <dialog> elements use the ::backdrop pseudo-element, which
// works similarly.
var backdropClass = 'dialog-backdrop';
if (this.dialogNode.parentNode.classList.contains(backdropClass)) {
this.backdropNode = this.dialogNode.parentNode;
}
else {
this.backdropNode = document.createElement('div');
this.backdropNode.className = backdropClass;
this.dialogNode.parentNode.insertBefore(this.backdropNode,
this.dialogNode);
this.backdropNode.appendChild(this.dialogNode);
}
this.backdropNode.classList.add('active');
// Disable scroll on the body element
document.body.classList.add(aria.Utils.dialogOpenClass);
if (typeof focusAfterClosed === 'string') {
this.focusAfterClosed = document.getElementById(focusAfterClosed);
}
else if (typeof focusAfterClosed === 'object') {
this.focusAfterClosed = focusAfterClosed;
}
else {
throw new Error(
'the focusAfterClosed parameter is required for the aria.Dialog
constructor.');
}
if (typeof focusFirst === 'string') {
this.focusFirst = document.getElementById(focusFirst);
}
else if (typeof focusFirst === 'object') {
this.focusFirst = focusFirst;
}
else {
this.focusFirst = null;
}
// If this modal is opening on top of one that is already open,
// get rid of the document focus listener of the open dialog.
if (aria.OpenDialogList.length > 0) {
aria.getCurrentDialog().removeListeners();
}
this.addListeners();
aria.OpenDialogList.push(this);
this.clearDialog();
this.dialogNode.className = 'default_dialog'; // make visible
if (this.focusFirst) {
this.focusFirst.focus();
}
else {
aria.Utils.focusFirstDescendant(this.dialogNode);
}
this.lastFocus = document.activeElement;
}; // end Dialog constructor
aria.Dialog.prototype.clearDialog = function () {
Array.prototype.map.call(
this.dialogNode.querySelectorAll('input'),
function (input) {
input.value = '';
}
);
};
/**
* #desc
* Hides the current top dialog,
* removes listeners of the top dialog,
* restore listeners of a parent dialog if one was open under the one
that just closed,
* and sets focus on the element specified for focusAfterClosed.
*/
aria.Dialog.prototype.close = function () {
aria.OpenDialogList.pop();
this.removeListeners();
aria.Utils.remove(this.preNode);
aria.Utils.remove(this.postNode);
this.dialogNode.className = 'hidden';
this.backdropNode.classList.remove('active');
this.focusAfterClosed.focus();
// If a dialog was open underneath this one, restore its listeners.
if (aria.OpenDialogList.length > 0) {
aria.getCurrentDialog().addListeners();
}
else {
document.body.classList.remove(aria.Utils.dialogOpenClass);
}
}; // end close
/**
* #desc
* Hides the current dialog and replaces it with another.
*
* #param newDialogId
* ID of the dialog that will replace the currently open top dialog.
* #param newFocusAfterClosed
* Optional ID or DOM node specifying where to place focus when the
new dialog closes.
* If not specified, focus will be placed on the element specified by
the dialog being replaced.
* #param newFocusFirst
* Optional ID or DOM node specifying where to place focus in the new
dialog when it opens.
* If not specified, the first focusable element will receive focus.
*/
aria.Dialog.prototype.replace = function (newDialogId,
newFocusAfterClosed,
newFocusFirst) {
var closedDialog = aria.getCurrentDialog();
aria.OpenDialogList.pop();
this.removeListeners();
aria.Utils.remove(this.preNode);
aria.Utils.remove(this.postNode);
this.dialogNode.className = 'hidden';
this.backdropNode.classList.remove('active');
var focusAfterClosed = newFocusAfterClosed || this.focusAfterClosed;
var dialog = new aria.Dialog(newDialogId, focusAfterClosed,
newFocusFirst);
}; // end replace
aria.Dialog.prototype.addListeners = function () {
document.addEventListener('focus', this.trapFocus, true);
}; // end addListeners
aria.Dialog.prototype.removeListeners = function () {
document.removeEventListener('focus', this.trapFocus, true);
}; // end removeListeners
aria.Dialog.prototype.trapFocus = function (event) {
if (aria.Utils.IgnoreUtilFocusChanges) {
return;
}
var currentDialog = aria.getCurrentDialog();
if (currentDialog.dialogNode.contains(event.target)) {
currentDialog.lastFocus = event.target;
}
else {
aria.Utils.focusFirstDescendant(currentDialog.dialogNode);
if (currentDialog.lastFocus == document.activeElement) {
aria.Utils.focusLastDescendant(currentDialog.dialogNode);
}
currentDialog.lastFocus = document.activeElement;
}
}; // end trapFocus
window.openDialog = function (dialogId, focusAfterClosed, focusFirst){
var dialog = new aria.Dialog(dialogId, focusAfterClosed,focusFirst);
};
window.closeDialog = function (closeButton) {
var topDialog = aria.getCurrentDialog();
if (topDialog.dialogNode.contains(closeButton)) {
topDialog.close();
}
}; // end closeDialog
window.replaceDialog = function (newDialogId, newFocusAfterClosed,
newFocusFirst) {
var topDialog = aria.getCurrentDialog();
if (topDialog.dialogNode.contains(document.activeElement)) {
topDialog.replace(newDialogId, newFocusAfterClosed,newFocusFirst);
}
}; // end replaceDialog
}());
And call it where you open the modal like this:
openDialog('modalID', this);
Add these attributes in the modal div tag:
<div id="modalId" aria-modal="true" role="dialog">
Add "tabindex" attributes on all the elements where you want the focus. Like this:
<a href="#" onclick="resizeTextFixed(1.4);return false;" tabindex="1"
aria-label="Some text">A</a>
<a href="#" onclick="resizeTextFixed(1.2);return false;" tabindex="2"
aria-label="Some text">A</a>
Add "focusable" class to the first focusable element:
<div class="focuable"></div>
That's it.

I found a very simple vanillaJS solution that should work in any modern browser:
const container=document.querySelector("#yourIDorwhatever")
//optional: needed only if the container element is not focusable already
container.setAttribute("tabindex","0")
container.addEventListener("focusout", (ev)=>{
if (ev.relatedTarget && !container.contains(ev.relatedTarget)) container.focus()
})
The mode of operation is very simple:
makes the container focusable, if not already
adds an event listener to the focusout event which fires when the focus is about to go outside of the container
Checks if the next target of the focus is in fact outside of the container, and if so, then puts the focus back to the container itself
The last check is needed because the focusout event also fires when the focus moves from one element to the another within the container.
Note: the focus can leave the page, eg the address bar of the browser. This doesn't seem to be preventable - at least according to my testing in Chrome.

Related

Use XPath or onClick or onblur to select an element and use jQuery to blur this element

*UPDATE:I am new to jQuery, as well as using XPath, and I am struggling with getting a proper working solution that will blur a dynamically created HTML element. I have an .onblur event hooked up (doesn't work as expected), and have tried using the $(document.activeElement), but my implementation might be incorrect. I would appreciate any help in creating a working solution, that will blur this element (jqInput) when a user clicks anywhere outside the active element. I have added the HTML and jQuery/JavaScript below.
Some ideas I have had:
(1) Use XPath to select a dynamic HTML element (jqInput), and then use jQuery's .onClick method to blur a this element, when a user clicks anywhere outside of the area of the XPath selected element.
(2) Use the $(document.activeElement) to determine where the .onblur should fire:
var thisTitle = input0;
var activeElement = $(document.activeElement);
if (thisTitle != activeElement) {
jqInput.hide();
_layout.viewHeaderTextInput.inputOnBlurHandler(canvasObj, jqHeaderText, jqInput);
}
I am open to all working solutions. And hopefully this will answer someone else's question in the future.
My challenge: Multiple elements are active, and the .onblur does not fire. See the image below:
NOTE: The <input /> field has focus, as well as the <div> to the left of the (the blue outline). If a user clicks anywhere outside that <input />, the blur must be applied to that element.
My Code: jQuery and JavaScript
This is a code snippet where the variable jqInput and input0 is created:
var jqInput = null;
if (jqHeaderText.next().hasClass("inline-editable"))
{
//Use existing input if it already exists
jqInput = jqHeaderText.next();
}
else
{
//Creaet a new editable header text input
jqInput = $("<input class=\"inline-editable\" type=\"text\"/>").insertAfter(jqHeaderText);
}
var input0 = jqInput.get(0);
//Assign key down event for the input when user preses enter to complete entering of the text
input0.onkeydown = function (e)
{
if (e.keyCode === 13)
{
jqInput.trigger("blur");
e.preventDefault();
e.stopPropagation();
}
};
This is my .onblur event, and my helper method to blur the element:
input0.onblur = function ()
{
_layout.viewHeaderTextInput.inputOnBlurHandler(canvasObj, jqHeaderText, jqInput);
};
inputOnBlurHandler: function (canvasObj, jqHeaderText, jqInput)
{
// Hide input textbox
jqInput.hide();
// Store the value in the canvas
canvasObj.headingText = jqInput.val();
_layout.updateCanvasControlProperty(canvasObj.instanceid, "Title", canvasObj.headingText, canvasObj.headingText);
// Show header element
jqHeaderText.show();
_layout.$propertiesContent.find(".propertyGridEditWrapper").filter(function ()
{
return $(this).data("propertyName") === "Title";
}).find("input[type=text]").val(canvasObj.headingText); // Update the property grid title input element
}
I have tried using the active element, but I don't think the implementation is correct:
var thisTitle = input0;
var activeElement = $(document.activeElement);
if (thisTitle != activeElement) {
jqInput.hide();
_layout.viewHeaderTextInput.inputOnBlurHandler(canvasObj, jqHeaderText, jqInput);
}
My HTML code:
<div class="panel-header-c">
<div class="panel-header-wrapper">
<div class="panel-header-text" style="display: none;">(Enter View Title)</div><input class="inline-editable" type="text" style="display: block;"><div class="panel-header-controls">
<span></span>
</div>
</div>
</div>
I thank you all in advance.

Best way to hide 10000 dropdown menus

Context -
I have a chat component and each individual chat message has a dropdown.
And the dropdown menu is opened by clicking the "More Options icon"(3 dots).
Each individual chat message is a "backbone item view"
One solution is to listen to click on "body", loop through all the menus and then close the dropdown by removing a class on it.
$("body").on("click", function() {
$(".drop-down-menu").each(function(idx, item) {
$(item).removeClass("open"); // open class indicated it is open via CSS
});
});
The CSS -
.drop-down-menu {
visibility: hidden;
opacity: 0;
&.open {
opacity: 1;
visibility: visible;
}
}
Will there be any performance impact if there are 10,000 messages or more?
Hence, I am looking for the best solution to hide the drop down if user clicks anywhere on the screen.
Thanks.
You can make some trivial changes that should improve the performance of your code. The first thing is that there's no reason to loop like you are doing. jQuery objects are collections and jQuery operations usually loop over the elements of a jQuery object. So:
$("body").on("click", function() {
$(".drop-down-menu").removeClass("open");
});
This will automatically remove the class open from all elements matched by the selector ".drop-down-menu". jQuery will still go over a loop internally, but it is faster to let jQuery iterate by itself than to have .each call your own callback and then inside the callback create a new jQuery object on which to call .removeClass.
Furthermore, you logically know that removing the open class from elements that do not have this class is pointless. So you can narrow the operation to only those elements where removing open makes sense:
$("body").on("click", function() {
$(".drop-down-menu.open").removeClass("open");
});
These are principles that are widely applicable and that have trivial cost to implement. Anything more than this runs into the realm of optimizations that may have downsides, and should be supported by actually profiling your code. You could replace the jQuery code with code that only uses stock DOM calls but then if you need support for old browsers the cost of dealing with this and that quirk may not be worth it. And if you are using stock DOM methods, there are different approaches that may yield different performance increases, at the cost of code complexity.
Louis is offering a quick fix with efficient jQuery selectors.
For the long run, I would suggest making each message a MessageView component which has a ContextMenuView component. That way, each view only has one menu to take care of.
Catching clicks outside of an element
Then, use the following ClickOutside view as the context menu base view. It looks complicated, but it only wraps the blur and focus DOM events to know if you clicked outside the view.
It offers a simple onClickOutside callback for the view itself and a click:outside event which is triggered on the element.
The menu view now only has to implement the following:
var ContextMenuView = ClickOutside.extend({
toggle: function(val) {
this.$el.toggleClass("open", val);
this.focus(); // little requirement
},
// here's where the magic happens!
onClickOutside: function() {
this.$el.removeClass("open");
}
});
See the demo
var app = {};
(function() {
var $body = Backbone.$(document.body);
/**
* Backbone view mixin that enables the view to catch simulated
* "click:outside" events (or simple callback) by tracking the
* mouse and focusing the element.
*
* Additional information: Since the blur event is triggered on a mouse
* button pressed and the click is triggered on mouse button released, the
* blur callback gets called first which then listen for click event on the
* body to trigger the simulated outside click.
*/
var ClickOutside = app.ClickOutside = Backbone.View.extend({
events: {
"mouseleave": "_onMouseLeave",
"mouseenter": "_onMouseEnter",
"blur": "_onBlur",
},
/**
* Overwrite the default constructor to extends events.
*/
constructor: function() {
this.mouseInside = false;
var proto = ClickOutside.prototype;
this.events = _.extend({}, proto.events, this.events);
ClickOutside.__super__.constructor.apply(this, arguments);
this.clickOnceEventName = 'click.once' + this.cid;
},
/**
* Hijack this private method to ensure the element has
* the tabindex attribute and is ready to be used.
*/
_setElement: function(el) {
ClickOutside.__super__._setElement.apply(this, arguments);
var focusEl = this.focusEl;
if (focusEl && !this.$focusElem) {
this.$focusElem = focusEl;
if (!(focusEl instanceof Backbone.$)) {
this.$focusElem = Backbone.$(focusEl);
}
} else {
this.$focusElem = this.$el;
}
this.$focusElem.attr('tabindex', -1);
},
focus: function() {
this.$focusElem.focus();
},
unfocus: function() {
this.$focusElem.blur();
$body.off(this.clickOnceEventName);
},
isMouseInside: function() {
return this.mouseInside;
},
////////////////////////////
// private Event handlers //
////////////////////////////
onClickOutside: _.noop,
_onClickOutside: function(e) {
this.onClickOutside(e);
this.$focusElem.trigger("click:outside", e);
},
_onBlur: function(e) {
var $focusElem = this.$focusElem;
if (!this.isMouseInside() && $focusElem.is(':visible')) {
$body.one(this.clickOnceEventName, this._onClickOutside.bind(this));
} else {
$focusElem.focus(); // refocus on inside click
}
},
_onMouseEnter: function(e) {
this.mouseInside = true;
},
_onMouseLeave: function(e) {
this.mouseInside = false;
},
});
var DropdownView = app.Dropdown = ClickOutside.extend({
toggle: function(val) {
this.$el.toggle(val);
this.focus();
},
onClickOutside: function() {
this.$el.hide();
}
});
})();
var DemoView = Backbone.View.extend({
className: "demo-view",
template: $("#demo-template").html(),
events: {
"click .toggle": "onToggleClick",
},
initialize: function() {
this.dropdown = new app.Dropdown();
},
render: function() {
this.$el.html(this.template);
this.dropdown.setElement(this.$(".dropdown"));
return this;
},
onToggleClick: function() {
this.dropdown.toggle(true);
},
});
$("#app")
.append(new DemoView().render().el)
.append(new DemoView().render().el);
html,
body {
height: 100%;
width: 100%;
}
.demo-view {
position: relative;
margin-bottom: 10px;
}
.dropdown {
z-index: 2;
position: absolute;
top: 100%;
background-color: gray;
padding: 10px;
outline: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.3.3/backbone-min.js"></script>
<div id="app"></div>
<script type="text/template" id="demo-template">
<button type="button" class="toggle">Toggle</button>
<div class="dropdown" style="display:none;">
This is a drop down menu.
</div>
</script>
Alternatives to detect a click outside an element
If you don't want, or can't use blur and focus events, take a look at How do I detect a click outside an element? for alternative techniques.
Lazy initialization of views
Another way to make an SPA more efficient is to delay the creation of new view to the very moment you need it. Instead a creating 10k context menu views, wait for the first time the user clicks on the toggle button and create a new view if it doesn't exist yet.
toggleMenu: function(){
var menuView = this.menuView;
if (!menuView) {
menuView = this.menuView = new ContextMenuView();
this.$('.dropdown').html(menuView.render().el);
}
menuView.toggle();
}
Pagination
Passed a certain threshold of HTML inside a webpage, the browser starts to lag and it impedes the user experience. Instead of dumping 10k views into a div, only show like a 100, or the minimum to cover the visible space.
Then, when scrolling to an edge (top or bottom), append or prepend new views on demand. Like the message list in any web-based chat app, like messenger.com.
Since you will only have one drop down menu open at a time, maybe you can keep a pointer to the element or index of the element it is attached to, instead of looping through all the menus.

Using checkboxes to update UI in realtime

I'm currently in the process of trying to develop a smarter UI for one of my clients. However the only code I can use to develop this 'feature', is pure JS. I have no access to the source HTML or CSS files the only access I have is the ability to inject JavaScript through an external .js file.
I'm not too familiar with JS, but I can work my way around a basic script or two.
Scenario
What we're doing is allowing users to edit PDF Templates online using a software called Core Create. The UI accessed through the browser is quite cluttered and I would like to provide an option to hide and show UI elements <textareas>/<inputs> through the use of checkboxes.
Here is a very basic JS Fiddle that I have built with the
intention of hiding and displaying UI.
The page in question
Above is a screen grab of the page I am working with, on the left you can see the UI and its composition on the right within the 'Inspect Element' tool.
I have come to the conclusion that I need to iterate through the highlighted selection and link them accordingly with seven checkboxes. The result would then be a selection of checkboxes that would hide / display the correct UI element.
The Caveat
In realizing I cannot edit or introduce new HTML I noticed the lack of on-click attributes. So I'm a bit lost on how to invoke the JavaScript I will eventually build.
My Question
With my limited knowledge of JS I don't know how I would iterate though div elements editoraccvar - editoraccvar6 picking out the ones I need to manipulate.
Due to the lack of ID's / Names (I assume it would have to be done using Parent/Child rules somehow, as the classes are widley used by the rest of the UI. I would appreciate a small example demonstrating how I could achieve this, so I can learn from it.
I should clarify, I have already added the checkboxes to the page, I just need to build the JS link between the Checkbox and the UI element I'm attempting to target. You can find all attributes linking to these checkboxes included in the JS Fiddle.
EDIT // A Working Simplified Example;
Due to some confusion I have 'frankensteined' some code together to show the final result I am after. A working example of sorts. The actual result needs to target 7 Checkboxes and 7 Divisions. I'll list thier common properties below.
// This script is already in place and constructed by the system.
// Written inside script tags and located straight after 'editopt1'.
// $(document).ready(function() {
// $('#checkboxopt1').click(function() {
// if ($('#checkboxopt1').val() == 'true') {
// $('#opt1').val('false');
// $('#checkboxopt1').val('false');
// $('#checkboxopt1').prop('checked', false);
// $('#previewrefresh').trigger('click');
// } else {
// $('#opt1').val('true');
// $('#checkboxopt1').val('true');
// $('#checkboxopt1').prop('checked', true);
// $('#previewrefresh').trigger('click');
// };
// });
// });
function exFunction() {
// Check the function is called
console.log("200 : OK");
// grab all elements with the class, .field-summernote
var uiblocks = document.querySelectorAll('.field-summernote');
for (var i = 0; i < uiblocks.length; i++) {
var current = uiblocks[i];
if (current.className.indexOf('editoraccvar') < 0) //not found: -1
return;
// check elements in the array
console.log(current);
// control the elemets in the array.
if (document.getElementById('checkboxopt1').checked) {
uiblocks[0].style.display = 'block'; // display the element
} else {
uiblocks[0].style.display = 'none'; // hide the element
}
}
};
// Trigger the collection the check, and the control.
var x = document.getElementById("checkboxopt1");
x.addEventListener("click", function() {
console.log("Opt");
exFunction();
});
.editoraccvar1 {
width: 300px;
background: #0ff;
padding: .5em;
}
.editoropt1 {
width: 300px;
background: #ff0;
padding: .5em;
}
textarea {
display: block;
width: 95%;
resize: none;
padding: .5em;
}
<!-- I'm trying to hide & show this entire division... -->
<div class="seq-box-form-field field-summernote editoraccvar1 ">
<label for="accvar1">Ground Floor Info</label>
<div class="clearfix"></div>
<textarea id="richaccvar1" name="richaccvar1" class="summernote"></textarea>
<input type="hidden" name="accvar1" id="accvar1" value="" />
</div>
<!-- Using only what the system has supplied. -->
<div class="seq-box-form-field editoropt1 ">
<label for="opt1"><span style="padding-right: 10px; vertical-align: 1px;">Ground Floor </span>
<input type="checkbox" name="checkboxopt1" id="checkboxopt1" value="true" checked="true" />
<input type="hidden" name="opt1" id="opt1" value="true" />
</label>
</div>
Divisions <div class=""></div>
* editoraccvar,
editoraccvar1,
editoraccvar2,
editoraccvar3,
editoraccvar4,
editoraccvar5,
editoraccvar6*
Checkboxes <input id=""></input>
* checkboxopt,
checkboxopt1,
checkboxopt2,
checkboxopt3,
checkboxopt4,
checkboxopt5,
checkboxopt6,*
As far as I can see, your problem boils down to link checkboxes (that seem to have been generated in some way) to "division" parts of your html that you want to hide. Plus, you have to inject javascript code in the page (so I guess the less code the better).
One approach could be as follows:
// Wrap the code in an anonymus function, to avoid clustering the global space.
(function (domElements) {
// This is the callback that will fire when a checkbox is clicked.
function clickCallback() {
// the context of this callback is the DOM element thus we can access its attributes through this.
// extract the checkNumber of the class of the element. This number is the link to the division that we want to hide/show.
var checkNumber = ((/ editoropt(\d*) /).exec(this.className))[1],
checkBox = document.getElementById('checkboxopt' + checkNumber),
division = document.querySelectorAll('.editoraccvar' + checkNumber)[0];
// Hide/show division, update checkBox state.
toggleElements(division, checkBox, window.getComputedStyle(division).display === 'none');
}
function toggleElements(division, checkBox, isShown) {
// Toggle the division (show/hide) accordingly.
division.style.display = isShown ? 'block' : 'none';
// Due to the fact that the event listener is attached to the parent of the checkBox, we need to maintain consistency manually.
checkBox.checked = isShown;
}
// Remove from the array of DOMElements those that aren't checkboxes and add a click event listener to each of them.
domElements
.filter(function (el) {
return el.className.indexOf('editoropt') !== -1;
})
.forEach(function (el) {
el.addEventListener('click', clickCallback, false);
});
// Call the function passing the dom elements with class '.seq-box-form-field' as argument. Checkboxes are contained within them. Also, transform the nodelist
// into a proper array so that methods defined in Array.prototype can be used.
})([].slice.call(document.querySelectorAll('.seq-box-form-field')));
The code is commented and (I think) quite self-explanatory. However, if you have any doubt or want me to elaborate any point further, please, let me know.
Finally, here's the working fiddle.
UPDATE
Same function (more or less) but now it accepts an array of values that will correspond to the initial state of the checkboxes:
(function (domElements, cbState) {
function clickCallback() {
toggleElements(this.className);
}
function toggleElements(className, initialShow) {
var checkNumber = ((/ editoropt(\d*) /).exec(className))[1],
checkBox = document.getElementById('checkboxopt' + checkNumber),
division = document.querySelectorAll('.editoraccvar' + checkNumber)[0],
isShown = initialShow === undefined ? window.getComputedStyle(division).display === 'none' : initialShow;
division.style.display = isShown ? 'block' : 'none';
checkBox.checked = isShown;
}
domElements
.filter(function (el) {
return el.className.indexOf('editoropt') !== -1;
})
.forEach(function (el, index) {
el.addEventListener('click', clickCallback, false);
toggleElements(el.className, cbState[index]);
});
// Initial state of the checkboxes goes in the second parameter. The index in the array correspond to the checkbox position in the page.
})([].slice.call(document.querySelectorAll('.seq-box-form-field')), [false, false]);
Here's the Fiddle to play with. Hope it helps.
The other half of your problem, not addressed in the other answer has to do with events. Generally, adding an "onclick" attribute to the actual HTML is considered bad practice. You can attach event handlers with Javascript.
var a = document.getElementById("checkboxopt1");
a.addEventListener("click", exFunction, false);
See the manual for more info about how to use this.
Looks like that you need the elements that have the class "field-summernote", but not the class "editorbdyvar".
You can use a query selector to get elements by class name using the default tools from Javascript:
var items = document.querySelectorAll('.field-summernote');
for(var i = 0; i<items.length; i++){
var current = items[i];
if( current.className.indexOf('editoraccvar') < 0) //not found: -1
return;
//now you can manipulate the current element
console.log(current);
}
well ... you should either learn javascript, DOM, HTML and CSS or hire an somebody that can do it.
in my opinion the latter would come cheaper.
if not,
here goes something to put in your script.js file.
the checkboxes must have the id="toggleTextareas" respectively id="toggleInputs".
(function isolateScope() {
tryInit();
function tryInit() {
if(document.readyState!="complete"){
setTimeout(tryInit, 100);
}else{
createUI();
init();
}
}
function createUI(){
var div=document.createElement("div");
div.className="addon-floating-toolbar"
div.style.position="fixed";
div.style.zIndex="999999";
div.style.background="#EEE";
div.style.padding="5px";
div.innerHTML='<input type="checkbox" id="toggleTextareas">toggle Textareas<br>'
+'<input type="checkbox" id="toggleInputs">toggle Inputs';
document.body.appendChild(div);
}
function init() {
var tta=document.getElementById("toggleTextareas");
var ti=document.getElementById("toggleInputs");
var textareaVisible=true;
var inputVisible=true;
tta.onclick=toggleTextareas;
ti.onclick=toggleInputs;
function toggleTextareas() {
var elms=document.querySelectorAll("textarea");
textareaVisible=!textareaVisible;
if (textareaVisible) {
show(elms);
}else{
hide(elms);
}
}
function toggleInputs() {
var elms=document.querySelectorAll("input");
inputVisible=!inputVisible;
if (inputVisible) {
show(elms);
}else{
hide(elms);
}
}
function show(collection) {
for (var i = 0; i < collection.length; i++) {
collection[i].style.display="";
}
}
function hide(collection) {
for (var i = 0; i < collection.length; i++) {
collection[i].style.display="none";
}
}
}
})();
let me know if it works,
cheers.
You can traverse all your fields and generate a checkbox that will toggle it open/close for each of your fields. Also set the checkbox label as innerText of the corresponding field.
// Block to be run
generateCheckboxes = function() {
var button = document.getElementById("generateButton");
button.parentNode.removeChild(button);
// grab all elements with the class, .field-summernote
var uiblocks = [].slice.call(document.querySelectorAll('.field-summernote')).filter(function(x) {
return x.className.indexOf('editoraccvar') >= 0
});
if (!uiblocks.length) return;
var chcontainer = document.createElement('div');
chcontainer.style.display = "inline-block";
document.body.insertBefore(chcontainer, document.body.children[0]);
uiblocks.forEach(function(x) {
var cdiv = document.createElement('div');
var clabel = document.createElement('label');
clabel.innerHTML = x.innerText.trim();
var cinput = document.createElement('input');
cinput.type = 'checkbox';
cinput.checked = true;
cinput.onchange = function(ev) {
var checked = this.checked;
x.style.display = checked ? "" : "none";
}
cdiv.appendChild(clabel);
cdiv.appendChild(cinput);
cdiv.appendChild(document.createElement('br'));
chcontainer.appendChild(cdiv);
})
};
#container {
width: 150px;
}
input {
float: left;
}
label {
width: 120px;
display: block;
float: right;
text-align: left;
}
<button onclick="generateCheckboxes()" id="generateButton">Generate Checkboxes</button>
<div id="example" class="field-summernote editoraccvar">
<br/>
<br/>
<span>Zero</span>
<br/>
<textarea></textarea>
</div>
<div id="example1" class="field-summernote editoraccvar1">
<br/>
<br/>
<span>One</span>
<br/>
<textarea></textarea>
</div>
<div id="example2" class="field-summernote">
<br/>
<br/>
<span>Two</span>
<br/>
<textarea></textarea>
</div>
Fiddle

jQuery highlight plugin cancels text selection, making copy impossible while also rendering links unclickable

I'm using the jQuery Highlight plugin to select some text on a web page.
I've hooked up selecting and deselecting with mouse events:
document.addEventListener('mouseup', doSelect);
document.addEventListener('mousedown', doDeselect);
The functions are:
function doSelect() {
var selectionRange = window.getSelection();
var selection = selectionRange.toString();
if (selection.trim().length > 0) {
$('body').highlight(selection);
}
}
function doDeselect() {
$('body').unhighlight();
}
Short and easy. The library searches for the selected text and wraps each occurrence in a <span> and so the text stands out.
It's working great, but I have two issues with how it behaves.
The problem is that once the span elements are applied, I cannot click hyperlinks (the ones that were found/selected), they don't react to clicks (I have to deselect the text first).
Once the span elements are added, the original selection is somehow lost, i.e. I cannot copy what I selected with CTRL+C.
These issues can be seen in this jsfiddle.
Why is this happening?
The code
The working demo is available here: jsfiddle
JavaScript
var $body = $('body');
var $copyArea = $('#copyArea');
document.addEventListener('mouseup', doSelect);
document.addEventListener('mousedown', doDeselect);
document.addEventListener('keydown', keyPressHandler);
function keyPressHandler(e) {
if(e.ctrlKey && e.keyCode == 67) {
$copyArea.focus().select();
}
}
function doSelect() {
var selectionRange = window.getSelection();
var selection = selectionRange.toString();
if (selection.trim().length > 0) {
$copyArea.val(selection);
$body.highlight(selection);
}
}
function doDeselect(e) {
var elem = $(e.target).parents('a');
if(elem.length == 0) {
$copyArea.val('');
$body.unhighlight();
}
}
HTML
Sample text to select.
<br/>Sample text to select.
<br/>Sample text to select.
<br/>google.com
google.com
<a href="http://google.com" target="_blank">
<span>
<span>google.com</span>
</span>
</a>
<textarea id="copyArea"></textarea>
CSS
.highlight {
background-color: #FFFF88;
}
#copyArea {
position:fixed;
top:-999px;
height:0px;
}
Part 1 - Clicking through the selection
Presumably, the reason clicking on a highlighted link doesn't work is because the process that disables the highlighting kicks in first and cancels the click.
To bypass that, we implement a condition that checks if the target element of the mousedown event has an a element as ancestor. If that is true, we simply do not execute $body.unhighlight();, allowing the click to pass through and open the link.
function doDeselect(e) {
var elem = $(e.target).parents('a');
if(elem.length == 0) {
$copyArea.val('');
$body.unhighlight();
}
}
Part 2 - Copying the selection
Presumably, again, the reason the selection is lost is because the document is modified by the highlighting, which introduces elements into the DOM.
My first idea was to reapply the selection after the modification was done. This became annoying and I went in a different direction, which allowed me to stumble upon this:
The Definitive Guide to Copying and Pasting in JavaScript
This offered a simple and efficient idea: using an hidden element that could contain selectable text.
Therefore, to allow copying the selected text that we highlighted despite having lost the original selection:
We add a hidden textarea element to our document.
<textarea id="copyArea"></textarea>
We get a reference to that element.
var $copyArea = $('#copyArea');
We add an event handler for the keydown event.
document.addEventListener('keydown', keyPressHandler);
We add the event handler.
function keyPressHandler(e) {
if(e.ctrlKey && e.keyCode == 67) {
$copyArea.focus().select();
}
}
We modify doSelect() to add some logic that will set the selection as the value of the textarea element, in the form of $copyArea.val(selection);.
function doSelect() {
var selectionRange = window.getSelection();
var selection = selectionRange.toString();
if (selection.trim().length > 0) {
$copyArea.val(selection);
$body.highlight(selection);
}
}
What does the handler do ? it captures the combination CTRL+C and focuses on the text in the hidden textarea, which ends up being copied by the keyboard command we just issued.

Javascript/HTML: How to make a small UI pop out on select

I am making a chrome extension that interacts with the current page when the user selects some text.
What we want it to do is to make a small window pop up to let the user select within different options.
Something along the lines of this:
http://img-ipad.lisisoft.com/img/1/5/1526-1-pdf-highlighter.jpg
What we have so far is the following:
document.addEventListener('mouseup',boxOption)
function boxOption(){
var yourSelection = window.getSelection();
if (yourSelection!=""){
/* insert popup here */
}
}
Any help would be greatly appreciated
You can implement the popup by inserting an element into the DOM (or revealing an existing element within the DOM) which simply has a "z-index" property that puts it above the other elements. Ex:
// Create a class that encapsulates the menu element
// This particular implementation constructs a new element
// and adds it to the DOM, but you could instead take the
// element as a parameter or have it retrieve an existing element
var PopupMenu = function() {
this.element = document.createElement('div');
this.element.className = 'popup-menu';
document.body.appendChild(this.element);
// ...
// set up event listeners for this element
// ...
};
// The menu is hidden unless it also has the 'enabled' class
PopupMenu.prototype.setVisible = function(isVisible) {
if (isVisible) {
this.element.classList.add('enabled');
} else {
this.elemnt.classlist.remove('enabled');
}
};
And then in your CSS, you could do:
.popup-menu {
display: none;
}
.popup-menu.enabled {
display: block;
/* this just needs to be larger than the z-index of the items it covers */
z-index: 100;
}
I'll leave the rest of the styling/handlers of the menu up to you.

Categories

Resources