Table of Contents

Interactive Disabled Input

About

This page contains the code for cross-browser interactive disabled elements (Internet Explorer not included) and how I came up with it.

The Result

Try clicking / tapping on the following disabled input fields taken from my HTML Style Test!

Disabled Inputs


The Code

Javascript

disabled_shake.js

// events.js

function addEvent(evnt, elem, func) {
  if (elem.addEventListener) { // W3C DOM
    elem.addEventListener(evnt, func, false);
  }
  else if (elem.attachEvent) { // IE DOM
    elem.attachEvent('on' + evnt, func);
  }
  else { // No much to do
    elem['on' + evnt] = func;
  }
}

// classes.js

/**
 * Returns whether the given element has the given class.
 *
 * @param {Element} element
 * @param {string} className
 * @returns {boolean}
 */
function hasClass(element, className) {
	className = " " + className + " ";
	return (" " + element.className + " ").replace(/[\n\t]/g, " ").indexOf(className) > -1
}

function addClass(elements, className) {
  if ( elements.length === undefined ) elements = [elements];
  for (var i = 0; i < elements.length; i++) {
    var element = elements[i];
    if (!hasClass(element, className)) {
      if (element.classList) {
        element.classList.add(className);
      } else {
        element.className = ( element.className + ' ' + className ).replace( /\s+/g, ' ');
      }
    }
  }
}

function removeClass(elements, className) {
  if ( elements.length === undefined ) elements = [ elements ];
  for (var i = 0; i < elements.length; i++) {
    var element = elements[i];
    if (hasClass(element, className)) {
      if (element.classList) {
        element.classList.remove(className);
      } else {
        element.className = element.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' ');
      }
    }
  }
}

// throttle.js

var throttleGateKeys = {
  mousemove: false,
  click: false
};

var throttleGateTimes = {
  mousemove: 0,
  click: 0
};

var throttleGateDebounceEvents = {
  mousemove: false,
  scroll: false,
  click: false
};

var throttleGateIntervals = {
  mousemove: 100,
  click: 200 // 200ms is disabled animation duration
};

var throttleGateDebounceIntervalMin = 10;

function throttleGate(affectedKey, intervalKey) {
// function throttleGate(affectedKey, forceGate) {
  if (throttleGateKeys[intervalKey]) return true;
  // if (throttleGateKeys[affectedKey] || forceGate) return true;

  throttleGateKeys[affectedKey] = true;

  setTimeout(function() {
    throttleGateKeys[affectedKey] = false;
  }, throttleGateIntervals[affectedKey]);

  throttleGateTimes[affectedKey] = (new Date()).getTime(); // for debounce timing

  return false;
}

function debounceThrottleGate(key, debounceKey, func, event) {
  var intervalKey = key;

  if (key != 'click') {
    if(throttleGateDebounceEvents['click']) {
      // if within current click debounce time and inside last clicked rectangle.
      //    do not set interval key, instead call true in the throttle gate anyways (forceGate parameter)
      intervalKey = 'click';
    }
  }

  if (throttleGate(key, intervalKey)) {
    var throttleInterval = throttleGateIntervals[intervalKey];

    clearTimeout(throttleGateDebounceEvents[debounceKey]);

    throttleGateDebounceEvents[debounceKey] = setTimeout(function() {
      if (event) {
        func(event);
      }
      else {
        func();
      }
    }, Math.min(Math.max(throttleInterval - ((new Date()).getTime() - throttleGateTimes[intervalKey]), 0), throttleInterval) + throttleGateDebounceIntervalMin);

    return true;
  }

  return false;
}

// disabled_shake.js

var root = document.documentElement;

var lastMouseX = 0;
var lastMouseY = 0;

function updateMouseCoords(coordX, coordY) {
  lastMouseX = coordX;
  lastMouseY = coordY;
}

function isMouseCoordsInElem(elem) {
  var rect = elem.getBoundingClientRect();
  return parseInt(rect.left, 10) < lastMouseX && lastMouseX < parseInt(rect.left + rect.width, 10) && parseInt(rect.top, 10) < lastMouseY && lastMouseY < parseInt(rect.top + rect.height, 10);
}

var topLevelElemOverlapped;

function getTopLevelElemOverlapped(elem) {
  var isWithin = isMouseCoordsInElem(elem);

  if (isWithin) {
    topLevelElemOverlapped = elem;

    var hasDecendants = elem.hasChildNodes();

    if (hasDecendants) {
      var decendants = elem.children;
      var len = decendants.length;

      for (var i = 0; i < len; i++) {
        getTopLevelElemOverlapped(decendants[i]);
      }
    }
  }
}

function isElementDisabled(element) {
  return element && (element.disabled || hasClass(element, 'disabled'));
}

function tryDisabledClickRejection(elem) {
  if (isElementDisabled(elem)) {
    addClass(elem, 'show-disabled-animation');

    setTimeout(function() {
      removeClass(elem, 'show-disabled-animation');
    }, throttleGateIntervals['click']);
  }
}

function tryClick(event) {
  if(debounceThrottleGate('click', 'click', tryClick, event)) return;

  updateMouseCoords(event.x, event.y);
  getTopLevelElemOverlapped(root);

  if (topLevelElemOverlapped && isElementDisabled(topLevelElemOverlapped)) {
    tryDisabledClickRejection(topLevelElemOverlapped.parentNode);

    var elems = topLevelElemOverlapped.parentNode.children;
    var len = elems.length;

    for (var i = 0; i < len; i++) {
      var elem = elems[i];
      tryDisabledClickRejection(elem);
    }
  }
}

function tryDisabledCursor() {
  getTopLevelElemOverlapped(root);

  if( isElementDisabled(topLevelElemOverlapped) ) {
    addClass(root, 'show-disabled-cursor');
  }
  else {
    removeClass(root, 'show-disabled-cursor');
  }
}

function tryScroll() {
  if(debounceThrottleGate('mousemove', 'scroll', tryScroll, false)) return;

  tryDisabledCursor();
}

function tryMousemove(event) {
  if(debounceThrottleGate('mousemove', 'mousemove', tryMousemove, event)) return;

  updateMouseCoords(event.x, event.y);
  tryDisabledCursor();
}

var areDisabledElements = false;

function initDisabledShake() {
  if ( areDisabledElements ) return; // already running;

  var inputs = document.querySelectorAll('input, textarea');

  for ( var i = 0; i < inputs.length; i++ )
  {
    if ( inputs[i].disabled )
    {
      areDisabledElements = true;
      break;
    }
  }

  if ( areDisabledElements ) {
    addEvent('click', document, function(event) {
      // event.preventDefault(); // only if over disabled element || is click anim throttled?
      tryClick(event);
    });

    addEvent('scroll', document, function() {
      tryScroll();
    });

    addEvent('mousemove', document, function(event) {
      tryMousemove(event);
    });
  }
}

initDisabledShake();

CSS

disabled_shake.css

html.show-disabled-cursor {
  cursor: not-allowed;
}

@keyframes disabledAnimation {
  0% {
    transform: translateX(0);
  }
  25% {
    transform: translateX(-$page_gap);
  }
  75% {
    transform: translateX($page_gap);
  }
  100% {
    transform: translateX(0);
  }
}

.disabled, :not(.disabled) > :disabled {
  display: inline-block;
  pointer-events: none;
}

.show-disabled-animation {
  animation-duration: 0.2s;
  animation-iteration-count: 1;
  animation-name: disabledAnimation;
}

How I Wrote the Code

The Idea

I wanted to have disabled inputs act as if they shook thier head when users clicked on them for more user feedback. I wanted to see if this would enhance or degrade the end user experience.


1: The First Attempt

A couple JavaScript class toggles and a CSS animation later, I discovered that disabled inputs are blocked form being clicked on in the browser level. They dont even fire a JS click or touch event.


2: Looking Online for a Solution

Every source I found online said that it could not be done, some said it was impossible. At least not without having to wrap every instance of the disabled inputs on a page with an element. I did not like this solution because it meant manually going out of one's way to enable the behavior for new elements. This also meant one more element to add to the DOM. I was confortable with managing an extra class for the input labels to ensure they received the correct styling, but I do not appreciate adding more DOM elements if I can help it. Simply enabeling some level of interaction between the end user and a disabled element was considered by most, if not all, sources online. That was enough for me to want to prove them wrong. I love a good challenge and love to make the impossible possible. There are few pleasures I enjoy more.


3: Getting Click Events Working

The first task was to make sure that clicks could be fired when hovering over a disabled input field. This was easily acheivable by setting pointer-events: none; on the desired .disabled, and input:disabled elements. With this method clicks would pass through the disabled inputs as if I were clicking on the elements behind them, because I now was. Onto the next step.


4: Traversing The DOM

Click events now working, I needed to be able to get to the disabled element the mouse was hovering over using JavaScript. To do so I wrote a function that would begin with the mouse event coordinates and the root html element of the page and traverse through all the decendants of the page which were behind the mouse cursor until I got to the last one. This would become the top-most element behind the click location and thus include elements such as the disabled inputs which were otherwise out of reach. This was acheived by:

  1. making the function begin with the root element,
  2. looping through all of its decendants,
  3. using getBoundingClientRect to check if any of them were behind the click event location,
  4. and passing these decendants into a self-reference of the same function,
  5. causing a loop that would end when no more decendants were left to traverse.

5: Applying the Classes

With the topmost element behind the mouse saved to a variable, a check was added to confirm if it was one of the .disabled, and input:disabled elements needed, and apply the animation toggle logic to them if they were.


6: Applying the Animation Logic

To make sure the animations worked as intended, a CSS class was added on click called .show-disabled-animation and a setTimer() JS function would remove that class after a duration of time equal to that of the css animation. This resulted in the disabled field shaking as if to say "no"! I had could finally target a disabled element and manipulate it as I wished, and this worked in Chrome, Firefox and Edge browsers!


7: Animating Labels and Inputs Together

With the animations finally working they did not always affect all of the associated input elements. Sometimes a label for an input would animate but not it's input and vice-versa. This is because an input would wrap around that input and other times it would sit beside it as a neighbor. To fix this, before applying the animation, the ancestor element and it's decendants were passed into a new function which contained the same validation and toggle for the animation used before. This resulted in all of the correct elements being animated together and animate thier positions synchronously. Any elements that did not pass the validation check for .disabled, and input:disabled would be ignored.


8: Adding the Cursor Back

It was time to add the cursor: not-allowed; CSS property back to the mouse pointer, since adding pointer-events: none; to the disabled elements caused hover events in CSS to no longer work for them. In order to acomplish this, 'mousemove' and 'scroll' events were added to run the same DOM traversal check that was being run on the click event. If the top-level element was disabled upon execution of these events, a new .show-disabled-cursor class was added to the html element which would add back the desired cursor style.


9: Throttling the Events

All of these events came at a cost. While 'click' events did not pose much of a threat of running too often, the 'mousemove' and 'scroll' events were running every single possible frame. This would undoubtly cause lag on lesser performing systems. To fix this problem throttling of the events was introduced and added to each event. I will spare the details here, except to that this resulted in roughly 100-200 events and DOM traversals per second all the way down to 10 or less.


10: Debouncing the Events

Throttling the events came at another cost. Sometimes the mouse would hover off or onto a disabled element and stop moving within the update interval, causing a final update to not occur and the mouse would be stuck with a styling it was not supposed to have until it was moved again or the page was scrolled. To fix this problem, a debounce was added into the throttle code to ensure if the trottle check was ever ran for any of these events while the throttle was enabled, that a final iteration of that event's function would be scheduled to run at the end of that event's throttle time period.


The Final Product

The end result was behavior strickingly similar to that of the native browser, plus the ability to affect disabled elements without the need to add any aditional DOM elements! Even the occationally slight delay between cursor styles upon page scroll, mouse movement or clicks seemed to be similar or the same when comparing them to the default delays already present without these changes implemented.


Conclusion

I finally got to have disabled inputs act as if they shook thier head when clicked in order to provide more feedback to end users. The effect definetly seems to have done the trick and seems stable enough for me to keep it in my own website. I do not have to add any more elements to my pages when writing HTML, and I can rest assured that the effect will work in a variety of situations. Taking on this task has been a valuable exercise in various JavaScript methodologies that whilst took me 12 hours to complete, and an additional 3 and a half more hours to document by writing this page, I don't regret a single minute of it. Perhaps I have problems, or perhaps I am extremly dedicated. Either way I acomplished what many considered impossible or impractical and made the most performant and versitile version of it at the same time. I am very proud of this work. As far as I am concerned, with the experience and time that I had, I've done my best and can finally go to rest.


Additional Fixes and Cleanups

  1. The debounce event needed to have a delay that matched it's throttle counterparts in order to get rid of any unintended behavior ("jank") that would occur from debounces firing earlier than intended, paticularly between the 200ms 'click' debounce and the origional 100ms throttle interval.
  2. Multiple declared deboucning functions were simplified into a single one, shortening and cleaning up code and increasing readability of the entire JavaScript file (and likely reducing load times).
  3. Debouncing needed to have a slightly longer delay than the equivalent throttle in order to avoid edge cases where the debounce would occur on the last frame of the throttle, thus preventing the debounce form getting rejected on the last execution.
  4. Occationally, an end user might move the mouse or scroll while hovering near the left or right side over a disbled element while it was animating. This resulted in the mouse cursor sometimes getting updated and toggling the disabled styling while seemingly still hovering over the correct area. To fix this issue, the 'click' throttle's remaining time, and thus the reamining time of the animation, was used in place of the typical 'scroll' or 'mousemove' events delays.

Additional Fixes and Cleanups 2

This is a list of changes without any tracking of time below. The time taken is shown per entry here.

  1. Added logic to JavaScript to prevent events registering on pages without any disabled elements. This can be now enforced after an element is disabled after page load by using the initDisabledShake(); function from within the same file ( 15 mins, January 19, 2021 ).
  2. Added ability to initialize regestering diabled input events from an external JavaScript file by using the following example ( 1 hour, March 9, 2021 ).
    var button_submit = document.getElementById('button_submit');
    
    addEvent( 'click', button_submit, function( event ) {
      setTimeout( function() {
        button_submit.disabled = 'disabled';
    
        document.dispatchEvent( new CustomEvent( "updateDisabledShake", { detail: {
          event: event, // not optional, needed to access the parent event in the custom event
          force: true // will not check for disabled inputs before adding disabled events
        } } ) );
      }, 100 );
    
      setTimeout( function() {
        button_submit.removeAttribute('disabled');
      }, 6000 );
    } );

Notes on Intended Design Choices That May Appear as Bugs

  1. When a disabled element is clicked on, the mouse cursor waits until the animation is over before updating even when clearly outside of the affected element's area. This is a result of an intentional design choice to limit firing of event code, and will not be changed. Animations last only 200ms, be patient.
  2. Animations on a second element ( or the last element clicked ), while a previous element is animated, will not fire until after the previously animated element has completed it's animation. To change this behavior would involve keeping separate timings for each element and would allow for more events to be fired than is intended with the implemented debouncing and throttling. This behavior is intentional, and will not be changed. Again, animations last only 200ms, be patient.

Time Taken

  • Time taken to develop feature: 12 hours and 5 minutes ( 11:32a to 11:37p January 18, 2021 ).
  • Time taken to document feature: 3 hours and 32 minutes ( 11:47p January 18, 2021 to 3:19a January 19, 2021 ).

Additional Time Taken

  • Additional time taken to fix edge cases: 1 hour and 7 minutes ( 12:33p to 1:40p January 19, 2021 ).
  • Additional time taken to document feature: 24 minutes ( 1:40p to 2:04p January 19, 2021 ).

Special Thanks

This page is comprised of my own additions and either partially or heavily modified elements from the following source(s):