Related
Problem description:
I want to create an add-on on my google sheet (g-1). When the user opens the add-on, I want to immediately read another google spreadsheet (g-2) and populate dropdowns on g-1 based on those columns.
I have enabled the Googlesheet Api
in Code.js:
function readSpreadsheet() {
var questions = Sheets.Spreadsheets.Values.get("_ID_", "SHEET!A2:k").values
if (!questions) {
return 'No data found.';
} else {
return questions
}
}
the function above works, because if I add that to the title on the add-on I see the correct number of columns:
HtmlService.createHtmlOutputFromFile('QuestionBank').setTitle(readSpreadsheet().length)
or I can print the first row
readSpreadsheet()[0]
............
So far so good.
Now in my html file, QuestionBank.html,
Problem #1. I am not able to call readSpreadsheet, it returns undefined. var question_rows returns undefined.
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js">
</script>
<script>
/**
read all rows, upon clicking on sync button. But this is not necessary if I can populate the dropdowns on load
**/
$(function() {
$('#sync').click(readSpreadsheet);
});
function readSpreadsheet() {
this.disabled = true;
$('#error').remove();
var question_rows = google.script.run
.withSuccessHandler(
function(textAndTranslation, element) {
element.disabled = false;
})
.withFailureHandler(
function(msg, element) {
element.disabled = false;
})
.withUserObject(this)
.readSpreadsheet();
for (var row = 0; row < question_rows.length; row++) {
alert(question_rows[row])
}
}
</script>
Problem #2: I have several dropdowns that I want their value to be unique value of the g-2 columns. I want those dropdowns to be populated when the add-on opens.
<select class="filter">
<option value="volvo">Volvo</option>
<option value="saab">Saab</option>
<option value="mercedes">Mercedes</option>
<option value="audi">Audi</option>
</select>
so instead of Volvo, etc, I want the first column of my data, unique values
Problem #3: if on load is not possible, I can include a button to read the data and populate the dropdowns
<button class="sync-button" id="sync">sync</button>
How about the following answers?
Answer for Problem #1
google.script.run doesn't return values. When it uses values from google.script.run, in your case, textAndTranslation of withSuccessHandler is the returned value. So you can modify readSpreadsheet() as follows.
function readSpreadsheet() {
this.disabled = true;
$('#error').remove();
google.script.run
.withSuccessHandler(withSuccessHandler)
.withFailureHandler(
function(msg, element) {
element.disabled = false;
})
.withUserObject(this)
.readSpreadsheet();
}
function withSuccessHandler(textAndTranslation, element) {
element.disabled = false;
var question_rows = textAndTranslation;
for (var row = 0; row < question_rows.length; row++) {
alert(question_rows[row])
}
}
Answer for Problem #2
You can achieve it by putting google.script.run in $(function() {}). The sample is as follows. This is a sample. So please modify the variables to yours.
<select class="filter"></select>
<script>
$(function() {
google.script.run.withSuccessHandler(sample).readSpreadsheet();
});
function sample(data) {
for (var i=0; i < data.length; i++) {
$('.filter').append($('<option>').html(data[i]).val(data[i]));
}
}
Answer for Problem #3
Of course, you can set values to <select class="filter"></select> using a button.
If I misunderstand your questions, I'm sorry.
I have an HTML5/Bootstrap form with hidden fields:
style="display: none"
which i show/hide via jQuery:
show() | hide()
For field validation i use the attribute required.
I want to have all my hidden fields as required but when some of them don't appear then the form can't proceed to submission.
Any thoughts about how can i have validation enabled only to fields displayed by user selections?
You can use this trick:
inside HTML form:
<input type="text" name="username" required="required" class="input-hidden">
CSS class:
.input-hidden{
height:0;
width:0;
visibility: hidden;
padding:0;
margin:0;
float:right;
}
You can add a class name for all the required attributes in the html:
<input type="text" name="first_name" class="card-payment-info" required>
<input type="text" name="last_name" class="card-payment-info" required>
and, from js event like a click, you can enable or disable the required attributes:
// disable require:
$(".card-payment-info").attr('required', false);
// enable require
$(".card-payment-info").attr('required', true);
Add a class .hideable to your hideable required inputs.
Then use these functions instead of your show() and hide():
function show1() {
//Your show() code here
$('.hideable').attr('required', 'required');
}
function hide1() {
//Your hide() code here
$('.hideable').removeAttr('required');
}
1 - Change the form to "novalidate"
2 - Catch the submit event
3 - Force the browser to check individually each visible input with input.reportValidity()
$('form')
.attr('novalidate', true)
.on('submit', function(){
var isValid = true;
$('input:visible,select:visible,textarea:visible', this).each(function() {
// report validity returns validity of input and display error tooltip if needed
isValid = isValid && this.reportValidity();
// break each loop if not valid
return isValid;
});
// do not submit if not valid
return isValid;
})
I've made a JQuery tool for this that also uses a mutation observer to automatically apply on dynamically created forms.
https://github.com/severinmoussel/VisibilityFormValidator/blob/master/VisibilityFormValidator.js
I wrote a drop-in replacement for the built-in show() and hide() that removes required on hide and restores them back on show (for all child elements).
(function ($) {
var oldShow = $.fn.show;
$.fn.show = function () {
oldShow.apply(this, arguments); //run the original "show" method
this.find("[notrequired]").prop("required", true).removeAttr("notrequired");
return this;
};
var oldHide = $.fn.hide;
$.fn.hide = function () {
oldHide.apply(this, arguments); //run the original "hide" method
this.find("[required]").prop("required", false).attr("notrequired", "1");
return this;
};
})(jQuery);
UPD: published as a gist here in case anyone wants to add anything https://github.com/alex-jitbit/jquery-required-visibility/blob/main/showhide.js
You can add pointer-events: none;, so the user can't click on the hidden element and also the cursor doesn't change when you hover it. As #DeadApe answered in this question
https://stackoverflow.com/a/65356856/6938902
One solution is to write your own validation-function:
This function checks all textareas and inputs in the default way, but first check if the input is displayed.
function validateFormOnlyVisableInputs(elForm) {
var inputs = elForm.querySelectorAll("input, textarea");
for (var i = 0; i < inputs.length; i++) {
console.log(i);
// display:none inputs ignorieren
if (inputs[i].offsetParent === null) continue;
if (!inputs[i].checkValidity()){
inputs[i].reportValidity();
return false;
}
}
return true;
}
You have to add an eventlistener to the submit-button and call the valdation function:
// this is out of a class, so you have to remove this...
// if you have problems you can write me ;)
this.elSendButton = this.elForm.querySelector("Button");
this.elSendButton.addEventListener("click", async (e) => {
e.preventDefault();
if (validateFormOnlyVisableInputs(this.elForm)) {
this.elForm.submit();
...
I've got this select element with different option in it. Normally the select element would get its width from the biggest option element, but I want the select element to have the default option value's width which is shorter. When the user selects another option, the select element should resize itself so the whole text is always visible in the element.
$(document).ready(function() {
$('select').change(function(){
$(this).width($('select option:selected').width());
});
});
Problem:
On Chrome (Canary) it always gets a width returned of 0.
On Firefox the width get's added and not resized when selected a shorter option.
Safari: Same result as Chrome.
Demo # JSFiddle
You are right there is no easy or built-in way to get the size of a particular select option. Here is a JsFiddle that does what you want.
It is okay with the latest versions of Chrome, Firefox, IE, Opera and Safari.
I have added a hidden select #width_tmp_select to compute the width of the visible select #resizing_select that we want to be auto resizing. We set the hidden select to have a single option, whose text is that of the currently-selected option of the visible select. Then we use the width of the hidden select as the width of the visible select. Note that using a hidden span instead of a hidden select works pretty well, but the width will be a little off as pointed out by sami-al-subhi in the comment below.
$(document).ready(function() {
$('#resizing_select').change(function(){
$("#width_tmp_option").html($('#resizing_select option:selected').text());
$(this).width($("#width_tmp_select").width());
});
});
#resizing_select {
width: 50px;
}
#width_tmp_select{
display : none;
}
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.js"></script>
<select id="resizing_select">
<option>All</option>
<option>Longer</option>
<option>A very very long string...</option>
</select>
<select id="width_tmp_select">
<option id="width_tmp_option"></option>
</select>
Here's a plugin I just wrote for this question that dynamically creates and destroys a mock span so it doesn't clutter up your html. This helps separate concerns, lets you delegate that functionality, and allows for easy reuse across multiple elements.
Include this JS anywhere:
(function($, window){
$(function() {
let arrowWidth = 30;
$.fn.resizeselect = function(settings) {
return this.each(function() {
$(this).change(function(){
let $this = $(this);
// get font-weight, font-size, and font-family
let style = window.getComputedStyle(this)
let { fontWeight, fontSize, fontFamily } = style
// create test element
let text = $this.find("option:selected").text();
let $test = $("<span>").html(text).css({
"font-size": fontSize,
"font-weight": fontWeight,
"font-family": fontFamily,
"visibility": "hidden" // prevents FOUC
});
// add to body, get width, and get out
$test.appendTo($this.parent());
let width = $test.width();
$test.remove();
// set select width
$this.width(width + arrowWidth);
// run on start
}).change();
});
};
// run by default
$("select.resizeselect").resizeselect();
})
})(jQuery, window);
You can initialize the plugin in one of two ways:
HTML - Add the class .resizeselect to any select element:
<select class="btn btn-select resizeselect">
<option>All</option>
<option>Longer</option>
<option>A very very long string...</option>
</select>
JavaScript - Call .resizeselect() on any jQuery object:
$("select").resizeselect()
Demo in jsFiddle and StackSnippets:
(function($, window){
$(function() {
let arrowWidth = 30;
$.fn.resizeselect = function(settings) {
return this.each(function() {
$(this).change(function(){
let $this = $(this);
// get font-weight, font-size, and font-family
let style = window.getComputedStyle(this)
let { fontWeight, fontSize, fontFamily } = style
// create test element
let text = $this.find("option:selected").text();
let $test = $("<span>").html(text).css({
"font-size": fontSize,
"font-weight": fontWeight,
"font-family": fontFamily,
"visibility": "hidden" // prevents FOUC
});
// add to body, get width, and get out
$test.appendTo($this.parent());
let width = $test.width();
$test.remove();
// set select width
$this.width(width + arrowWidth);
// run on start
}).change();
});
};
// run by default
$("select.resizeselect").resizeselect();
})
})(jQuery, window);
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.js"></script>
<select class="resizeselect">
<option>All</option>
<option>Longer</option>
<option>A very very long string...</option>
</select>
Updated to include sizing suggestions from Garywoo & Eric Warnke
This working solution makes use here of a temporary auxiliary select into which the selected option from the main select is cloned, such that one can assess the true width which the main select should have.
The nice thing here is that you just add this code and it's applicable to every selects, thus no need to ids and extra naming.
$('select').change(function(){
var text = $(this).find('option:selected').text()
var $aux = $('<select/>').append($('<option/>').text(text))
$(this).after($aux)
$(this).width($aux.width())
$aux.remove()
}).change()
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<select>
<option>REALLY LONG TEXT, REALLY LONG TEXT, REALLY LONG TEXT</option>
<option>ABCDEFGHIJKL</option>
<option>ABC</option>
</select>
Here's a more modern vanilla JS approach to solve this. It's more or less the same principle like in this answer just without jQuery.
Get the select element and listen for changes on it.
Create a new select element and option and pass the text of the current selectedIndex to the option.
Add position: fixed and visibility: hidden styles to the new select element. This ensures, that it is not affecting your layout but its bounding box can still be measured.
Append the option to the select.
Append the select to the original select element.
Get the needed dimensions of that new one using getBoundingClientRect().width
Set the width of the original one based on the dimensions of the new one.
Remove the new one.
Dispatch a change event to trigger this logic initially.
const select = document.querySelector('select')
select.addEventListener('change', (event) => {
let tempSelect = document.createElement('select'),
tempOption = document.createElement('option');
tempOption.textContent = event.target.options[event.target.selectedIndex].text;
tempSelect.style.cssText += `
visibility: hidden;
position: fixed;
`;
tempSelect.appendChild(tempOption);
event.target.after(tempSelect);
const tempSelectWidth = tempSelect.getBoundingClientRect().width;
event.target.style.width = `${tempSelectWidth}px`;
tempSelect.remove();
});
select.dispatchEvent(new Event('change'));
<select>
<option>Short option</option>
<option>Some longer option</option>
<option>A very long option with a lot of text</option>
</select>
A Pure Javascript 2022 version based on #Kmas's answer.
Uses querySelectorAll to get all the <select>'s
Uses a visibility: hidden element to copy/paste the value to get the width
Uses getBoundingClientRect to get the width of the hidden element
Uses dispatchEvent to trigger the first resize
function resize(event) {
const fakeEl = document.querySelector('#fakeEl');
const option = event.target.options[event.target.selectedIndex];
fakeEl[0].innerHTML = event.target.value;
event.target.style.width = fakeEl.getBoundingClientRect().width + 'px';
}
for (let e of document.querySelectorAll('select.autoresize')) {
e.onchange = resize;
e.dispatchEvent(new Event('change'));
}
<select class='autoresize'>
<option>Foo</option>
<option>FooBar</option>
<option>FooBarFooBarFooBar</option>
</select>
<select id='fakeEl' style='visibility: hidden'><option></option></select>
You Might Not Need Jquery
Try the following simple JavaScript and convert it in jQuery :)
<html><head><title>Auto size select</title>
<script>
var resized = false;
function resizeSelect(selected) {
if (resized) return;
var sel = document.getElementById('select');
for (var i=0; i<sel.options.length; i++) {
sel.options[i].title=sel.options[i].innerHTML;
if (i!=sel.options.selectedIndex) sel.options[i].innerHTML='';
}
resized = true;
sel.blur();
}
function restoreSelect() {
if (!resized) return;
var sel = document.getElementById('select');
for (var i=0; i<sel.options.length; i++) {
sel.options[i].innerHTML=sel.options[i].title;
}
resized = false;
}
</script>
</head>
<body onload="resizeSelect(this.selectedItem)">
<select id="select"
onchange="resizeSelect(this.selectedItem)"
onblur="resizeSelect(this.selectedItem)"
onfocus="restoreSelect()">
<option>text text</option>
<option>text text text text text</option>
<option>text text text </option>
<option>text text text text </option>
<option>text</option>
<option>text text text text text text text text text</option>
<option>text text</option>
</select>
</body></html>
Here is a jsfiddle for it: https://jsfiddle.net/sm4dnwuf/1/
Basically what it does is temporarily remove unselected elements when it is not in focus (causing it to size to just the size of the selected).
You can use a simple function:
// Function that helps to get the select element width.
$.fn.getSelectWidth = function() {
var width = Math.round($(this).wrap('<span></span>').width());
$(this).unwrap();
return width;
}
Then just use it on your form select element:
$(this).getSelectWidth();
Here's yet another plain jQuery solution:
$('#mySelect').change(function(){
var $selectList = $(this);
var $selectedOption = $selectList.children('[value="' + this.value + '"]')
.attr('selected', true);
var selectedIndex = $selectedOption.index();
var $nonSelectedOptions = $selectList.children().not($selectedOption)
.remove()
.attr('selected', false);
// Reset and calculate new fixed width having only selected option as a child
$selectList.width('auto').width($selectList.width());
// Add options back and put selected option in the right place on the list
$selectedOption.remove();
$selectList.append($nonSelectedOptions);
if (selectedIndex >= $nonSelectedOptions.length) {
$selectList.append($selectedOption);
} else {
$selectList.children().eq(selectedIndex).before($selectedOption);
}
});
Here is how I've achieved the following behavior:
On document load: The select's width is already based on the width of the selected option (repainting does not occur).
On change: The select's width is updated to the width of the newly selected option.
/* WIDTH, ADJUSTMENT, CONTENT BASED */
$( document ).on( 'change', '.width-content-based', function(){
let text_modified_font;
let text_modified_string;
let descendants_emptied_elements;
text_modified_font =
$( this ).css( 'font-weight' ) + ' ' + $( this ).css( 'font-size' ) + ' ' + $( this ).css( 'font-family' );
if
(
$( this ).is( 'select' )
)
{
text_modified_string =
$( this ).find( ':selected' ).text();
descendants_emptied_elements =
$( this ).find( '[data-text]' );
}
else
{
text_modified_string =
$( this ).val();
}
$( this ).css( { 'width': text_width_estimate( text_modified_string, text_modified_font ) + 'px' } );
if
(
descendants_emptied_elements.length
)
{
descendants_emptied_elements.each( function(){
$( this ).text( $( this ).attr( 'data-text' ) ).removeAttr( 'data-text' );
});
}
});
/* DOCUMENT, ON READY */
$( document ).ready( function(){
$( '.width-content-based' ).trigger( 'change' );
});
/* TEXT WIDTH ESTIMATE */ function text_width_estimate( text, font ) { let canvas = text_width_estimate.canvas || ( text_width_estimate.canvas = document.createElement( 'canvas' ) ); let context = canvas.getContext( '2d' ); context.font = font; let metrics = context.measureText( text ); return metrics.width + 3; }
select { -webkit-appearance: none; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<select class="width-content-based">
<option value="AF" data-text="Afghanistan"></option>
<option value="AX" data-text="Åland Islands"></option>
<option value="AL" selected>Albania</option>
</select>
Here's an example of one way to start with a select box auto sized and then how to auto adjust the width when a new box is selected.
Just copy and paste this into a visual studio code document and call it index.html and double click the file (for super noobs).
$(document).ready(function() {
// Auto set the width of the select box for Manufacture on startup
$("#width_tmp_option").html($('#Manufacture')[0][0].label);
$('#Manufacture').width($('#width_tmp_select').width());
// Adust the width of the select box each time Manufacture is changed
$('#Manufacture').change(function(){
$("#width_tmp_option").html($('#Manufacture option:selected').text());
$(this).width($("#width_tmp_select").width());
});
});
#Manufacture {
font-size:17px;
}
#width_tmp_select{
display : none;
font-size:17px;
}
<!-- JS (Jquery) -->
<script src="https://code.jquery.com/jquery-3.6.0.js"
integrity="sha256-H+K7U5CnXl1h5ywQfKtSj8PCmoN9aaq30gDh27Xc0jk="
crossorigin="anonymous"></script>
<!-- HTML -->
<!-- These are hidden dummy selects that I use to store some html in them and then retrieve the width to resize my select boxes. -->
<select id="width_tmp_select">
<option id="width_tmp_option"></option>
</select>
<select id="Manufacture">
<option>Toyota</option>
<option>Ford</option>
<option>Lamborghini</option>
</select>
I transformed João Pimentel Ferreira's jquery answer to vanilla js for anyone who needs a plain js solution
function changeWidth() {
let ghostSelect = document.createElement('select');
const select = document.getElementById('select');
var x = select.options[select.selectedIndex].text;
const ghostOption = document.createElement("option");
ghostOption.setAttribute("value", x);
var t = document.createTextNode(x);
ghostOption.appendChild(t);
ghostSelect.appendChild(ghostOption);
window.document.body.appendChild(ghostSelect)
select.style.width = ghostSelect.offsetWidth + 'px';
window.document.body.removeChild(ghostSelect)
}
<select id="select" onChange="changeWidth()">
<option value="all">Choose one</option>
<option value="1">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</option>
<option value="2">bbbbbbbbbbbbbbb</option>
<option value="3">cccc</option>
<option value="4">ddd</option>
<option value="5">ee</option>
<option value="6">f</option>
</select>
So what it actually does is that it creates a dummy select with only the selected option, adds it temporarily to the DOM, then calculates the width, sets the original's select width to the new width and then it removes the dummy select from the DOM.
try this:
const select = $('select');
function calcTextWidth(text, font) {
let canvas = calcTextWidth.canvas || (calcTextWidth.canvas = document.createElement("canvas"));
let context = canvas.getContext("2d");
context.font = font;
let metrics = context.measureText(text);
return metrics.width;
}
select.change(function () {
select.width(calcTextWidth(select.find(":selected").text(), 'normal normal 500 15px sans-serif'));
});
Using OffscreenCanvas to measure selected option text with, and then updating select element with
function updateWidth(el) {
const text = el.selectedOptions[0].text;
const canvas = new OffscreenCanvas(0, 0);
const ctx = canvas.getContext('2d')!;
ctx.font = getComputedStyle(el).font;
const width = ctx.measureText(text).width;
const padding = 35;
el.style.width = width + padding + 'px';
}
const select = document.querySelector('select');
updateSize(select);
select.addEventListener('change', evt => updateSize(evt.target));
You can try this also
select option{width:0; }
select option:hover{width:auto;}
I've got a jQuery code, which
$("a.reply").click(function() {
//code
});
When I click the link with .reply class the first time, nothing happens. The second time I click, the code inside the click function works.
The link is being inserted on the page using PHP from a mysql database. so it's not being inserted dynamically.
Why is this happening? Any solution?
The BadASS Code:
$(function(){
//TextArea Max Width
var textmaxwidth = $('#wrapper').css('width');
//Initialize Focus ids To Different Initially
var oldcommentid = -1;
var newcommentid = -2;
//End Of initialization
$("a.reply").click(function() {
newcommentid = $(this).attr('id');
if (newcommentid == oldcommentid)
{
oldcommentid=newcommentid;
$("#comment_body").focus();
}
else
{
$('#comment_form').fadeOut(0, function(){$(this).remove()});
var commetformcode = $('<form id="comment_form" action="post_comment.php" method="post"><textarea name="comment_body" id="comment_body" class="added_comment_body" rows="2"></textarea> <input type="hidden" name="parent_id" id="parent_id" value="0"/> <div id="submit_button"> <input type="submit" value="Share"/><input type="button" id="cancelbutton" value="Cancel"/></div></form>');
commetformcode.hide().insertAfter($(this)).fadeIn(300);
//
var id = $(this).attr("id");
$("#parent_id").attr("value", id);
oldcommentid=newcommentid;
//dynamicformcreation function
dynarun();
//
}
return false;
});
dynarun();
function dynarun()
{
//Form Re-Run Functions
$('#comment_body').elastic();
texthover();
$("#comment_form input, select, button").uniform();
textareasizer();
$("#comment_body").focus();
$("abbr.timestamp").timeago();
return false;
}
//TextArea Resizer Function
function textareasizer(){$("#comment_body").css('max-width', textmaxwidth);return false;}
//Other Miscellaneous Functions
$('.comment-holder').hover(
function(event) {
$(this).addClass('highlight');
},
function(event) {
$('.comment-holder').removeClass('highlight');
}
);
function texthover()
{
$('.added_comment_body').hover(
function(event) {
$(this).parent().parent().addClass('highlight');
},
function(event) {
$('.comment-holder').removeClass('highlight');
}
);
return false;
}
});
This is a longshot, but are you running some sort of tracking script? Like webtrends or coremetrics (or even some of your own script, that's globally looking for all clicks)? I ran into a similar problem a while ago, where the initial-click was being captured by coremetrics. Just a thought.
Does it still happen if you comment out all your code and simply have an alert("hi") inside the click function?
Update
I think Sarfaz has the right idea, but I would use the document ready function like so
$(document).ready(function(){
$("a.reply").click(function() {
//code
});
});
I just ran into same problem and I resolved my problem by removing:
<script src="bootstrap.js"></script>
you can use bootstrap.min.js
Use Inline CSS for hiding div and use JS/jQuery to show . This way Jquery Click Event will Fire On First Click
<div class="about-block">
<div class="title">About us</div>
<div class="" id="content-text" style="display:none;">
<p>Show me.</p>
</div>
</div>
<script>
var x = document.getElementById("content-text");
jQuery( '.about-block' ).click(function() {
if (x.style.display === "none") {
x.style.display = "block";
} else {
x.style.display = "none";
}
});
</script>
I'd like to be able to highlight the drop area as soon as the cursor carrying a file enters the browser window, exactly the way Gmail does it. But I can't make it work, and I feel like I'm just missing something really obvious.
I keep trying to do something like this:
this.body = $('body').get(0)
this.body.addEventListener("dragenter", this.dragenter, true)
this.body.addEventListener("dragleave", this.dragleave, true)`
But that fires the events whenever the cursor moves over and out of elements other than BODY, which makes sense, but absolutely doesn't work. I could place an element on top of everything, covering the entire window and detect on that, but that'd be a horrible way to go about it.
What am I missing?
I solved it with a timeout (not squeaky-clean, but works):
var dropTarget = $('.dropTarget'),
html = $('html'),
showDrag = false,
timeout = -1;
html.bind('dragenter', function () {
dropTarget.addClass('dragging');
showDrag = true;
});
html.bind('dragover', function(){
showDrag = true;
});
html.bind('dragleave', function (e) {
showDrag = false;
clearTimeout( timeout );
timeout = setTimeout( function(){
if( !showDrag ){ dropTarget.removeClass('dragging'); }
}, 200 );
});
My example uses jQuery, but it's not necessary. Here's a summary of what's going on:
Set a flag (showDrag) to true on dragenter and dragover of the html (or body) element.
On dragleave set the flag to false. Then set a brief timeout to check if the flag is still false.
Ideally, keep track of the timeout and clear it before setting the next one.
This way, each dragleave event gives the DOM enough time for a new dragover event to reset the flag. The real, final dragleave that we care about will see that the flag is still false.
Modified version from Rehmat (thx)
I liked this idea and instead of writing a new answer, I am updating it here itself. It can be made more precise by checking window dimensions.
var body = document.querySelector("body");
body.ondragleave = (e) => {
if (
e.clientX >= 0 && e.clientX <= body.clientWidth
&& e.clientY >= 0 && e.clientY <= body.clientHeight
) {} else {
// do something here
}
}
Old Version
Don't know it this works for all cases but in my case it worked very well
$('body').bind("dragleave", function(e) {
if (!e.originalEvent.clientX && !e.originalEvent.clientY) {
//outside body / window
}
});
Adding the events to document seemed to work? Tested with Chrome, Firefox, IE 10.
The first element that gets the event is <html>, which should be ok I think.
var dragCount = 0,
dropzone = document.getElementById('dropzone');
function dragenterDragleave(e) {
e.preventDefault();
dragCount += (e.type === "dragenter" ? 1 : -1);
if (dragCount === 1) {
dropzone.classList.add('drag-highlight');
} else if (dragCount === 0) {
dropzone.classList.remove('drag-highlight');
}
};
document.addEventListener("dragenter", dragenterDragleave);
document.addEventListener("dragleave", dragenterDragleave);
Here's another solution. I wrote it in React, but I'll explain it at the end if you want to rebuild it in plain JS. It's similar to other answers here, but perhaps slightly more refined.
import React from 'react';
import styled from '#emotion/styled';
import BodyEnd from "./BodyEnd";
const DropTarget = styled.div`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
background-color:rgba(0,0,0,.5);
`;
function addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions) {
document.addEventListener(type, listener, options);
return () => document.removeEventListener(type, listener, options);
}
function setImmediate(callback: (...args: any[]) => void, ...args: any[]) {
let cancelled = false;
Promise.resolve().then(() => cancelled || callback(...args));
return () => {
cancelled = true;
};
}
function noop(){}
function handleDragOver(ev: DragEvent) {
ev.preventDefault();
ev.dataTransfer!.dropEffect = 'copy';
}
export default class FileDrop extends React.Component {
private listeners: Array<() => void> = [];
state = {
dragging: false,
}
componentDidMount(): void {
let count = 0;
let cancelImmediate = noop;
this.listeners = [
addEventListener('dragover',handleDragOver),
addEventListener('dragenter',ev => {
ev.preventDefault();
if(count === 0) {
this.setState({dragging: true})
}
++count;
}),
addEventListener('dragleave',ev => {
ev.preventDefault();
cancelImmediate = setImmediate(() => {
--count;
if(count === 0) {
this.setState({dragging: false})
}
})
}),
addEventListener('drop',ev => {
ev.preventDefault();
cancelImmediate();
if(count > 0) {
count = 0;
this.setState({dragging: false})
}
}),
]
}
componentWillUnmount(): void {
this.listeners.forEach(f => f());
}
render() {
return this.state.dragging ? <BodyEnd><DropTarget/></BodyEnd> : null;
}
}
So, as others have observed, the dragleave event fires before the next dragenter fires, which means our counter will momentarily hit 0 as we drag files (or whatever) around the page. To prevent that, I've used setImmediate to push the event to the bottom of JavaScript's event queue.
setImmediate isn't well supported, so I wrote my own version which I like better anyway. I haven't seen anyone else implement it quite like this. I use Promise.resolve().then to move the callback to the next tick. This is faster than setImmediate(..., 0) and simpler than many of the other hacks I've seen.
Then the other "trick" I do is to clear/cancel the leave event callback when you drop a file just in case we had a callback pending -- this will prevent the counter from going into the negatives and messing everything up.
That's it. Seems to work very well in my initial testing. No delays, no flashing of my drop target.
Can get the file count too with ev.dataTransfer.items.length
#tyler's answer is the best! I have upvoted it. After spending so many hours I got that suggestion working exactly as intended.
$(document).on('dragstart dragenter dragover', function(event) {
// Only file drag-n-drops allowed, http://jsfiddle.net/guYWx/16/
if ($.inArray('Files', event.originalEvent.dataTransfer.types) > -1) {
// Needed to allow effectAllowed, dropEffect to take effect
event.stopPropagation();
// Needed to allow effectAllowed, dropEffect to take effect
event.preventDefault();
$('.dropzone').addClass('dropzone-hilight').show(); // Hilight the drop zone
dropZoneVisible= true;
// http://www.html5rocks.com/en/tutorials/dnd/basics/
// http://api.jquery.com/category/events/event-object/
event.originalEvent.dataTransfer.effectAllowed= 'none';
event.originalEvent.dataTransfer.dropEffect= 'none';
// .dropzone .message
if($(event.target).hasClass('dropzone') || $(event.target).hasClass('message')) {
event.originalEvent.dataTransfer.effectAllowed= 'copyMove';
event.originalEvent.dataTransfer.dropEffect= 'move';
}
}
}).on('drop dragleave dragend', function (event) {
dropZoneVisible= false;
clearTimeout(dropZoneTimer);
dropZoneTimer= setTimeout( function(){
if( !dropZoneVisible ) {
$('.dropzone').hide().removeClass('dropzone-hilight');
}
}, dropZoneHideDelay); // dropZoneHideDelay= 70, but anything above 50 is better
});
Your third argument to addEventListener is true, which makes the listener run during capture phase (see http://www.w3.org/TR/DOM-Level-3-Events/#event-flow for a visualization). This means that it will capture the events intended for its descendants - and for the body that means all elements on the page. In your handlers, you'll have to check if the element they're triggered for is the body itself. I'll give you my very dirty way of doing it. If anyone knows a simpler way that actually compares elements, I'd love to see it.
this.dragenter = function() {
if ($('body').not(this).length != 0) return;
... functional code ...
}
This finds the body and removes this from the set of elements found. If the set isn't empty, this wasn't the body, so we don't like this and return. If this is body, the set will be empty and the code executes.
You can try with a simple if (this == $('body').get(0)), but that will probably fail miserably.
I was having trouble with this myself and came up with a usable solution, though I'm not crazy about having to use an overlay.
Add ondragover, ondragleave and ondrop to window
Add ondragenter, ondragleave and ondrop to an overlay and a target element
If drop occurs on the window or overlay, it is ignored, whereas the target handles the drop as desired. The reason we need an overlay is because ondragleave triggers every time an element is hovered, so the overlay prevents that from happening, while the drop zone is given a higher z-index so that the files can be dropped. I am using some code snippets found in other drag and drop related questions, so I cannot take full credit. Here's the full HTML:
<!DOCTYPE html>
<html>
<head>
<title>Drag and Drop Test</title>
<meta http-equiv="X-UA-Compatible" content="chrome=1" />
<style>
#overlay {
display: none;
left: 0;
position: absolute;
top: 0;
z-index: 100;
}
#drop-zone {
background-color: #e0e9f1;
display: none;
font-size: 2em;
padding: 10px 0;
position: relative;
text-align: center;
z-index: 150;
}
#drop-zone.hover {
background-color: #b1c9dd;
}
output {
bottom: 10px;
left: 10px;
position: absolute;
}
</style>
<script>
var windowInitialized = false;
var overlayInitialized = false;
var dropZoneInitialized = false;
function handleFileSelect(e) {
e.preventDefault();
var files = e.dataTransfer.files;
var output = [];
for (var i = 0; i < files.length; i++) {
output.push('<li>',
'<strong>', escape(files[i].name), '</strong> (', files[i].type || 'n/a', ') - ',
files[i].size, ' bytes, last modified: ',
files[i].lastModifiedDate ? files[i].lastModifiedDate.toLocaleDateString() : 'n/a',
'</li>');
}
document.getElementById('list').innerHTML = '<ul>' + output.join('') + '</ul>';
}
window.onload = function () {
var overlay = document.getElementById('overlay');
var dropZone = document.getElementById('drop-zone');
dropZone.ondragenter = function () {
dropZoneInitialized = true;
dropZone.className = 'hover';
};
dropZone.ondragleave = function () {
dropZoneInitialized = false;
dropZone.className = '';
};
dropZone.ondrop = function (e) {
handleFileSelect(e);
dropZoneInitialized = false;
dropZone.className = '';
};
overlay.style.width = (window.innerWidth || document.body.clientWidth) + 'px';
overlay.style.height = (window.innerHeight || document.body.clientHeight) + 'px';
overlay.ondragenter = function () {
if (overlayInitialized) {
return;
}
overlayInitialized = true;
};
overlay.ondragleave = function () {
if (!dropZoneInitialized) {
dropZone.style.display = 'none';
}
overlayInitialized = false;
};
overlay.ondrop = function (e) {
e.preventDefault();
dropZone.style.display = 'none';
};
window.ondragover = function (e) {
e.preventDefault();
if (windowInitialized) {
return;
}
windowInitialized = true;
overlay.style.display = 'block';
dropZone.style.display = 'block';
};
window.ondragleave = function () {
if (!overlayInitialized && !dropZoneInitialized) {
windowInitialized = false;
overlay.style.display = 'none';
dropZone.style.display = 'none';
}
};
window.ondrop = function (e) {
e.preventDefault();
windowInitialized = false;
overlayInitialized = false;
dropZoneInitialized = false;
overlay.style.display = 'none';
dropZone.style.display = 'none';
};
};
</script>
</head>
<body>
<div id="overlay"></div>
<div id="drop-zone">Drop files here</div>
<output id="list"><output>
</body>
</html>
I see a lot of overengineered solutions out there. You should be able to achieve this by simply listening to dragenter and dragleave as your gut seemingly told you.
The tricky part is that when dragleave fires, it seems to have its toElement and fromElement inverted from what makes sense in everyday life (which kind of makes sense in logical terms since it's the inverted action of dragenter).
Bottom-line when you move the cursor from the listening element to outside that element, toElement will have the listening element and fromElement will have the outer non-listening element. In our case, fromElement will be null when we drag outside the browser.
Solution
window.addEventListener("dragleave", function(e){
if (!e.fromElement){
console.log("Dragging back to OS")
}
})
window.addEventListener("dragenter", function(e){
console.log("Dragging to browser")
})
The ondragenter is fired quite often. You can avoid using a helper variable like draggedFile. If you don't care how often your on ondragenter function is being called, you can remove that helper variable.
Solution:
let draggedFile = false;
window.ondragenter = (e) => {
if(!draggedFile) {
draggedFile = true;
console.log("dragenter");
}
}
window.ondragleave = (e) => {
if (!e.fromElement && draggedFile) {
draggedFile = false;
console.log("dragleave");
}
}
Have you noticed that there is a delay before the dropzone disappears in Gmail? My guess is that they have it disappear on a timer (~500ms) that gets reset by dragover or some such event.
The core of the problem you described is that dragleave is triggered even when you drag into a child element. I'm trying to find a way to detect this, but I don't have an elegantly clean solution yet.
really sorry to post something that is angular & underscore specific, however the way i solved the problem (HTML5 spec, works on chrome) should be easy to observe.
.directive('documentDragAndDropTrigger', function(){
return{
controller: function($scope, $document){
$scope.drag_and_drop = {};
function set_document_drag_state(state){
$scope.$apply(function(){
if(state){
$document.context.body.classList.add("drag-over");
$scope.drag_and_drop.external_dragging = true;
}
else{
$document.context.body.classList.remove("drag-over");
$scope.drag_and_drop.external_dragging = false;
}
});
}
var drag_enters = [];
function reset_drag(){
drag_enters = [];
set_document_drag_state(false);
}
function drag_enters_push(event){
var element = event.target;
drag_enters.push(element);
set_document_drag_state(true);
}
function drag_leaves_push(event){
var element = event.target;
var position_in_drag_enter = _.find(drag_enters, _.partial(_.isEqual, element));
if(!_.isUndefined(position_in_drag_enter)){
drag_enters.splice(position_in_drag_enter,1);
}
if(_.isEmpty(drag_enters)){
set_document_drag_state(false);
}
}
$document.bind("dragenter",function(event){
console.log("enter", "doc","drag", event);
drag_enters_push(event);
});
$document.bind("dragleave",function(event){
console.log("leave", "doc", "drag", event);
drag_leaves_push(event);
console.log(drag_enters.length);
});
$document.bind("drop",function(event){
reset_drag();
console.log("drop","doc", "drag",event);
});
}
};
})
I use a list to represent the elements that have triggered a drag enter event. when a drag leave event happens i find the element in the drag enter list that matches, remove it from the list, and if the resulting list is empty i know that i have dragged outside of the document/window.
I need to reset the list containing dragged over elements after a drop event occurs, or the next time I start dragging something the list will be populated with elements from the last drag and drop action.
I have only tested this on chrome so far. I made this because Firefox and chrome have different API implementations of HTML5 DND. (drag and drop).
really hope this helps some people.
When the file enters and leaves child elements it fires additional dragenter and dragleave so you need to count up and down.
var count = 0
document.addEventListener("dragenter", function() {
if (count === 0) {
setActive()
}
count++
})
document.addEventListener("dragleave", function() {
count--
if (count === 0) {
setInactive()
}
})
document.addEventListener("drop", function() {
if (count > 0) {
setInactive()
}
count = 0
})
I found out from looking at the spec that if the evt.dataTransfer.dropEffect on dragEnd match none then it's a cancelation.
I did already use that event to handle copying without affecting the clipboard. so this was good for me.
When I hit Esc then the drop effect was equal to none
window.ondragend = evt => {
if (evt.dataTransfer.dropEffect === 'none') abort
if (evt.dataTransfer.dropEffect === 'copy') copy // user holds alt on mac
if (evt.dataTransfer.dropEffect === 'move') move
}
on "dropend" event you can check the value of the document.focus() was the magic trick in my case.