eCommerce Accessibility A11y
A11y eCom UX Tutorial

eCommerce Accessibility A11y

Scott C. Krause | Monday, Dec 7, 2020

Website accessibility design and development can be both challenging and rewarding. However eCommerce properties by their very nature present an extreme set of challenges and rewards. Focusing on the positive; Improved accessibility can indeed lead to greater online sales. Building a better experience for a greater number of people cannot be bad for the bottom line. Unfortunately the negative side of that coin is that popular eCom sites are often targets of predatory troll litigation. For an eCom front-end developer accessibility has become an extreme sport with real consequences. We’ve had to redefine success, rethink our tool chain, and go back to HTML 101.

Accessibility is not so much a destination as it is a journey. It's a cyclical process of constant improvement and re-evaluation.

I have experience making large eCommerce sites accessible. I’ve authored a11y pattern guides, automated tests, and best practices that have helped disparate teams achieve WCAG / 508 success criteria. In this article I present tips and tools for achieving eCom accessibility nirvana.

In order for improvements to stand the test of time, there must be a company culture that supports accessibility and be willing to make it a requirement even when faced with competing priorities. For example, imagine explaining to an art director that the brand colors cannot be perceived by the color-blind. Imagine telling an SEO consultant that there are too many characters in an alt attribute. These can be awkward conversations if a11y is not prioritized within the organization.

Every tier of management must embrace the “engage everyone” mantra because it’s simply too easy to justify cutting corners. The website is not successful until it is accessible.

Large eCom sites are particularly challenging because they are always changing. Product information and imagery are managed within a CMS, therefore, there is always a risk of newly-added content being inaccessible. Ideally this situation would be addressed with a cyclical audit. That is a web crawler that captures a11y issues throughout the entire site. Similarly an a11y audit could run as part of automated unit testing upon software build or check in.

Finding a11y issues during development is preferred but keep in mind the developers will require more time to become proficient in this discipline and the project estimates should reflect that.

Color Contrast

Chrome Dev Tools
Pro ⚡ Tips

💡 If you do not see this dialog in Chrome dev tools try swapping the foreground and background colors.

Before I get into the technical details I want to emphasize what I consider the most important a11y skill for a front-end engineer - Succinct communication. You must be able to communicate to a larger team exactly why a particular element is failing WCAG success criteria and provide examples of how to fix it. Don’t fix it for them but help your team understand how to identify the issue and fix it. Don’t simply make the change, be the agent of change.

The most significant technical challenges for eCom sites involve interaction complexity. Micro-interaction components such as carousels, mini shopping carts, pinch zoom, and promotional popup dialogs are transitional and difficult to describe to the visually impaired. The fact that these interactions are actuated with JavaScript makes it impractical to test automatically. I’ve found the best way to find and fix these types of interactions is with a screen reader and then applying state-full ARIA patterns attributes as needed.

Manual Audit

Here I describe a basic 10 minute audit that you can perform just to get a feeling for how accessible a page is. Common issues should be apparent when answering these questions:

  1. Is there a Skip to Main Content Link from the omnibox?

  2. Can a shopper navigate with the tab key and are the focus styles visible?

    Pro ⚡ Tips

    Generally, do not use a tab index value greater than 0. Use tabindex="0" on an A element that does not have an HREF.

    //  Run in browser console or save as a browser snippet.
    //  Remove ALL tabIndex on the entire page - That is a fun thing to do.  ES5
    //  From the entire document remove tabIndex if its value is greater than 0
    [].slice.call( document.querySelectorAll("[tabIndex]") ).filter(function(el){ // Neodigm 2020
        console.log("-- | " + el.tabIndex );
        if(  el.tabIndex >= 1 ){
            el.tabIndex = "";
        }
    }); 
    // From the entire document add a tabIndex="0" attrib if an A has no href
    [].slice.call( document.querySelectorAll("A") ).filter(function(el){ 
        //console.log("== | " + el.tabIndex, el.href );
        if( !el.href ){
            el.tabIndex = 0;
        }
    }); 
    

    💡 Always show which element has the focus. In dev tools console click on the 👁️ eye icon and create this expression:

    document.activeElement
    

    💡 When debugging keyboard access issues in Chrome make sure that the DevTools panel is disconnected from the browser. If it is connected it shares the tab order with the document you are viewing. This makes it difficult to test issues related to "Skip to Main Content".

  3. Is there off-screen content that receives the focus?

  4. Can a shopper search, filter, and add to cart with a screen reader?

  5. Do landmark elements / roles represent the document’s information hierarchy?

  6. Is the document's accessibility tree well formed (chrome://accessibility/)?

Chrome Extensions

There are some pretty good Chrome extensions that will give you an actionable audit without many false-positives:

  1. Google Lighthouse — I use Lighthouse at least daily to get a feel for not only a11y but performance as well.

  2. Wave — This is the WebAIM extension. What I like most about this is its ability to show you the exact line that has the issue.

  3. aXe — Becoming my new fav, this extension exists solely in the developer tools panel. The results are very in-depth. They also offer a command line version that can be hooked into your CI build script.

    Pro ⚡ Tips

    Install aXe CLI

    npm i @axe-core/cli
    

    💡 You can then run the A11Y audit from within VSCode terminal or automate via Gulp watch / Github Actions.

JavaScript Keyboard Trap

Keyboard Trap POC snippet - Prevent the user from tabbing outside of a modal dialog.

Circular Keyboard Trap
a55_reveal.aRevAct[i].setAttribute("aria-haspopup", "dialog");
a55_reveal.aRevAct[i].setAttribute("aria-controls", sDialogId);
if( !oIDs[ sDialogId ]){ // A11y update the dialog (inert content) only once
  var eDialog = document.getElementById( sDialogId );
  if( eDialog ){
    eDialog.tabIndex = -1;
    eDialog.setAttribute("role", "dialog");
    eDialog.setAttribute("aria-modal", "true");
    window.addEventListener("focus", function(ev){
        if( a55_reveal.isOpen && (typeof ev.target != "undefined") ){
          var eTarget = ev.target, bInside = false;
          if(typeof eTarget.tagName != "undefined"){
            while( eTarget.tagName !== "HTML" ){ 
                //  Tab within modal dialog
                if( eTarget.tagName == "A55-REVEAL" ){
                  bInside = true; break;
                }
              eTarget = eTarget.parentNode;
            }                    
          }
          if( !bInside ){
            a55_reveal.doFocus();
            ev.preventDefault(); ev.stopPropagation();
          }                  
        }
      }, true);
    }
  oIDs[ sDialogId ] = true;
}

Skip to Main Content

Skip to Main Content - CSS Focus rules that make the link visible when focused from the omnibox.

CSS Only Solution - Markup
<a class="js-skip__main--id skip__main"
href="#ltdc-skipmain">Skip to Main Content</a>
CSS Only Solution
a.skip__main:active, a.skip__main:focus {
    background-color: #fff;
    border-radius: 4px;
    border: 2px solid #000;
    color: #000;
    font-size: 1em;
    height: auto; width: 16%;
    left: auto;
    margin: 8px 42%;
    overflow: auto;
    padding: 4px;
    text-align: center;
    top: auto;
    z-index: 1024;
}
a.skip__main {
    left: -1024px;
    overflow: hidden;
    position: absolute;
    top: auto;
    width: 1px; height: 1px;
    z-index: -1024;
}

Achieving accessibility nirvana is not easy for an eCommerce site. If you are uninspired, try to think of it in terms of exposing your business to a new group of shoppers who would have trouble shopping at your competitor’s site.

Thanks for reading!

Javascript Generate and Download CSV
Javascript Generate and Download CSV
Produce CSV with client-side JS. Construct Blog and Download as CSV file.
// Desc: Produce CSV with client-side JS. Contruct Blob and Download as CSV file
/*    _________   _____________   ____ __________.__        ___.    
 *    \_   ___ \ /   _____/\   \ /   / \______   \  |   ____\_ |__  
 *    /    \  \/ \_____  \  \   Y   /   |    |  _/  |  /  _ \| __ \ 
 *    \     \____/        \  \     /    |    |   \  |_(  <_> ) \_\ \
 *     \______  /_______  /   \___/     |______  /____/\____/|___  /
 *            \/        \/                     \/                \/    CSV Report */

  The resulting CSV files will contain a header row deterministic column names
  The resulting CSV files will be quoted
  The file name is auto-generated timestamp
  Cell string data may contain a comma , however quotes will be removed
  Cell string data may contain only utf-8 characters

let nativeCSV = ( ( _d )=>{
    let oCnt, jnCSV, sCSV, blCSV, elCSV;  //  config, json, array, blob, and element
    let retObj = {
        "init": ( _oCnt )=>{
            oCnt = _oCnt;
            if( oCnt.fileName.indexOf("####") !== -1) {
                oCnt.fileName = oCnt.fileName.replace("####", Date.now() );}
            jnCSV = sCSV = blCSV = elCSV = "";
            return retObj;
        },
        "setArray": ( _jnCSV )=>{  //  An array (rows) of arrays (cols) !jagged
            jnCSV = _jnCSV;
            if( oCnt.header ) jnCSV.unshift( oCnt.header );
            jnCSV.forEach(( aRow )=>{
                aRow.forEach(( sCol )=>{
                    if( typeof sCol === "string"){
                        sCSV += oCnt.delimQuote + sCol
                            .split( oCnt.delimQuote ).join("");
                        sCSV += oCnt.delimQuote + oCnt.delimCol;                       
                    }
                });
                sCSV = sCSV.slice(0, -1) + oCnt.delimLine;
            });
            return retObj;
        },
        "getBlob": ()=>{
            blCSV = new Blob([ sCSV ], { type: "text/csv;charset=utf-8;" });
            return retObj;
        },
        "createLink": ()=>{
            elCSV = _d.createElement("a");
            elCSV.setAttribute("href", URL.createObjectURL( blCSV ));
            elCSV.setAttribute("download", oCnt.fileName );
            elCSV.style.visibility = 'hidden';
            _d.body.appendChild( elCSV );
            return retObj;
        },
        "clickLink": ()=>{
            elCSV.click();
            return retObj;
        },
        "removeLink": ()=>{
            _d.body.removeChild( elCSV );
            return retObj;
        }
    };
    return retObj;
})( document );

console.log( nativeCSV.init({  //  Usage: 
    "delimCol": ",",
    "delimQuote": '"',
    "delimLine": "\n",
    "fileName": "graph_nodes_####.csv",
    "header": ["id","name", "FQDN"]})
    .setArray( currentGraph2Array(jCurrentGraph) )
    .getBlob()
    .createLink()
    .clickLink()
    .removeLink()    
);

2021-02-27

PWA Add to Home Screen
PWA Add to Home Screen
Progressive Web App ⚡ Advanced Cache && Notification Patterns
/*     ______   __     __     ______    
      /\  == \ /\ \  _ \ \   /\  __ \   
      \ \  _-/ \ \ \/ ".\ \  \ \  __ \  
       \ \_\    \ \__/".~\_\  \ \_\ \_\ 
        \/_/     \/_/   \/_/   \/_/\/_/  ✨ Add to Home Screen
        chrome://serviceworker-internals/
 */
 if ("serviceWorker" in navigator) {
    window.addEventListener("load", () => {
      navigator.serviceWorker.register("sw.js");
    });
}

let eA2hs = oD.getElementsByClassName("js-a2hs")[0];
let eA2hsP = oD.getElementsByClassName("js-a2hs--post")[0];

eA2hs.addEventListener("click", (e) => {
    eA2hs.style.display = "none";
    eA2hsP.style.display = "block";
    evDefPrompt.prompt();
    evDefPrompt.userChoice
      .then((choiceResult) => {
        if (choiceResult.outcome === "accepted") {
          if( snck ) snck.q("Wow, Now I'm an App on your Desktop|How Convenient!");
          playAudioFile( 7 );  //  ggl tag event | User accepted the A2HS prompt
        } else {
          playAudioFile( 3 );  //  ggl tag event | User dismissed the A2HS prompt
        }
        evDefPrompt = null;
      });
  });

function displayMsg( sMsg ){
    //    System Tray Notification
    if (!("Notification" in window)) {
        console.log('Notification API not supported.');
        return;
    } else if (Notification.permission === "granted") {
        // If it's okay let's create a notification
        var notification = new Notification( Nowish(), {icon: "https://repository-images.githubusercontent.com/178555357/2b6ad880-7aa0-11ea-8dde-63e70187e3e9", body: sMsg} );
    } else if (Notification.permission !== "denied") {
        // Otherwise, we need to ask the user for permission
        Notification.requestPermission(function (permission) {
            // If the user accepts, let's create a notification
            if (permission === "granted") {
                var notification = new Notification( Nowish(), {icon: "https://repository-images.githubusercontent.com/178555357/2b6ad880-7aa0-11ea-8dde-63e70187e3e9", body: sMsg} );
            }
        });
    }
}

/*    ╔═╗┌─┐┬─┐┬  ┬┬┌─┐┌─┐
 *    ╚═╗├┤ ├┬┘└┐┌┘││  ├┤ 
 *    ╚═╝└─┘┴└─ └┘ ┴└─┘└─┘
 *    ╦ ╦┌─┐┬─┐┬┌─┌─┐┬─┐  
 *    ║║║│ │├┬┘├┴┐├┤ ├┬┘  
 *    ╚╩╝└─┘┴└─┴ ┴└─┘┴└─  Advanced Cache ⚡ Notifications
 */
importScripts('https://storage.googleapis.com/workbox-cdn/releases/4.0.0/workbox-sw.js');
workbox.LOG_LEVEL = "debug";
self.addEventListener("fetch", event => {
  event.respondWith(caches.match(event.request)
    .then(cachedResponse => {
        if (cachedResponse) {
          return cachedResponse;
        }
        return fetch(event.request);
      })
    );
});
workbox.routing.registerRoute(
  // Cache CSS files
  /.*\.css/,
  // Use cache but update in the background ASAP
  workbox.strategies.staleWhileRevalidate({
    cacheName: 'css-cache',
  })
);
workbox.routing.registerRoute(
  // Cache image files
  /\.(?:png|gif|jpg|jpeg|webp|svg|mp3|mp4|json|html|js)$/,
  // Use the cache if it's available
  workbox.strategies.cacheFirst({
    cacheName: 'image-cache',
    plugins: [
      new workbox.expiration.Plugin({
        maxEntries: 256, maxAgeSeconds: 172800,
      })
    ],
  })
);

2020-12-21

HTML data attrib to JavaScript camel-case dataset
HTML data attrib to JavaScript camel-case dataset
Convert an HTML formatted data attrib name to a JS formatted name.
// Desc: data-is-whatever will be converted to isWhatever
// Usage: element.dataset[ data2prop("data-is-whatever") ]
/*______ _____ __  __           _____           _       _   
 |  ____/ ____|  \/  |   /\    / ____|         (_)     | |  
 | |__ | |    | \  / |  /  \  | (___   ___ _ __ _ _ __ | |_ 
 |  __|| |    | |\/| | / /\ \  \___ \ / __| '__| | '_ \| __|
 | |___| |____| |  | |/ ____ \ ____) | (__| |  | | |_) | |_ 
 |______\_____|_|  |_/_/    \_\_____/ \___|_|  |_| .__/ \__|
                                                 | |        
                                                 |_|        ES2021*/

function data2prop( sDset ){  //  Convert HTML data attrib name to JS dataset name
    sDset = sDset.replace("data-", "").toLowerCase();
    let aDset = sDset.split(""), aDret = [], bUpper = false;
    aDset.forEach( ( sChar ) => {
        if( sChar == "-" ){
            bUpper = true;
        }else{
            aDret.push( ( bUpper ) ? sChar.toUpperCase() : sChar );
            bUpper = false;
        }
    });
    return aDret.join("");
}

2020-12-19

Oracle PL/SQL Stored Procedure
Oracle PL/SQL Stored Procedure
Vintage Stored Procedure to denormalize department codes
--  ███████  ██████  ██      
--  ██      ██    ██ ██      
--  ███████ ██    ██ ██      
--       ██ ██ ▄▄ ██ ██      
--  ███████  ██████  ███████  Relational ⚡ Transactional
--              ▀▀           
PROCEDURE post_stage
(
    in_rowid_job            cmxlb.cmx_rowid,
    in_ldg_table_name       cmxlb.cmx_table_name,
    in_stg_table_name       cmxlb.cmx_table_name,
    out_error_msg      OUT  cmxlb.cmx_message,
    out_return_code    OUT  int
)
AS
sql_stmt varchar2(2000);
t_party_acct_id varchar2(14);
t_txn_div_cd varchar2(20);
t_txn_div_display varchar2(50);
commit_count NUMBER := 0;
commit_inc NUMBER := 1000;
--
CURSOR C_PTAC_TXN IS
SELECT PARTY_ACCT_ID, TXN_DIV_CD, TXN_DIV_DISPLAY
FROM   C_STG_PTAC_TXN_DIV;
--
BEGIN
--
    commit_inc := to_number(GET_PARAMETER('post_stage_commit', commit_inc));
    IF in_ldg_table_name = 'C_LDG_PTAC_TXN_DIV' AND in_stg_table_name = 'C_STG_PTAC_TXN_DIV' THEN
    --    20130225 SCK Update the stage txn_div_display col with a denormalized string derived
    --    from an aggregate of both staging and base object. 
    --    🏄 SQL ⚡ ETL MDM ⚡ PL/SQL ORM
        cmxlog.debug ('ADDUE: Landing table name is ' || in_ldg_table_name || ' Staging table name is ' || in_stg_table_name);
        BEGIN
              FOR R_PTAC_TXN in C_PTAC_TXN LOOP
                    post_stage_concat(R_PTAC_TXN.PARTY_ACCT_ID, t_txn_div_display);
                    UPDATE C_STG_PTAC_TXN_DIV
                    SET txn_div_display = t_txn_div_display, create_date = sysdate WHERE TXN_DIV_CD = R_PTAC_TXN.TXN_DIV_CD AND
                    PARTY_ACCT_ID = R_PTAC_TXN.PARTY_ACCT_ID;  -- CURRENT OF C_PTAC_TXN;
                    commit_count := commit_count + commit_inc;
                    IF MOD(commit_count, 1000) = 0 THEN
                        cmxlog.debug ('ADDUE: post_stage_concat is: ' || commit_count || ':' || R_PTAC_TXN.PARTY_ACCT_ID || ' : ' || t_txn_div_display);
                        COMMIT;
                    END IF;
              END LOOP;
              COMMIT;
        END;
    ELSE
      CMXlog.debug ('ADDUE Post Stage - no action taken');
    END IF;
END post_stage;
END ADD_UE;

2020-12-19

Dark Mode and Reduced Motion
Dark Mode and Reduced Motion
Making Dark Mode work with both a UI switch && the OS preference.
//  Desc: Listen to the OS for user preference
//  but override with a UI toggle.
/*  ______                  __        ____    ____               __        
   |_   _ `.               [  |  _   |_   \  /   _|             |  ]       
     | | `. \ ,--.   _ .--. | | / ]    |   \/   |   .--.    .--.| | .---.  
     | |  | |`'_\ : [ `/'`\]| '' <     | |\  /| | / .'`\ \/ /'`\' |/ /__\\ 
    _| |_.' /// | |, | |    | |`\ \   _| |_\/_| |_| \__. || \__/  || \__., 
   |______.' \'-;__/[___]  [__|  \_] |_____||_____|'.__.'  '.__.;__]'.__.'  User Prefs */


let doPrefersReducedMotion = function( bMotion ){// Stop 3D rotation
    o3Config.controls.autoRotate = !bMotion; 
}
let doPrefersColorScheme = function( bScheme ){ // UI | OS Semaphore
    document.body.dataset.prefScheme = ((bScheme) ? "dark" : "light"); //  🌙  /  ☀️
}

// Capture the prefers media queries
const mqPrefReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const mqPrefColorScheme = window.matchMedia("(prefers-color-scheme: dark)");

doPrefersReducedMotion( (mqPrefReducedMotion && mqPrefReducedMotion.matches) );
doPrefersColorScheme( (mqPrefColorScheme && mqPrefColorScheme.matches) );

// listen to changes in the media query's value
mqPrefReducedMotion.addEventListener("change", () => {
    doPrefersReducedMotion( mqPrefReducedMotion.matches );
});
mqPrefColorScheme.addEventListener("change", () => {
    doPrefersColorScheme( mqPrefColorScheme.matches );
});

/* Dark Mode begin */
/*@media (prefers-color-scheme: dark) {*/

    body[data-pref-scheme='dark'] [role='main'] {
        background: linear-gradient(to right, #555, #c2c2c2, #555)
    }
    body[data-pref-scheme='dark'] .h-bg__stripe, body[data-pref-scheme='dark'] .l-caro-design > article, body[data-pref-scheme='dark'] article.l-caro-design {
        background: repeating-linear-gradient(45deg,#242424,#242424 24px,#444 24px,#444 48px);
    }
    body[data-pref-scheme='dark'] section.pfmf-grid > div > article {
        border: solid 1px #888;
        border-top: solid 2px #888;
        box-shadow: 0px 2px 6px -2px rgba(164,164,164,0.6);
        background-color: #242424;
    }
    body[data-pref-scheme='dark'] .readable__doc { color: #fff; }
    body[data-pref-scheme='dark'] .readable__caption { color: #fff; }
    body[data-pref-scheme='dark'] .h-vect-line-art { stroke: #fff;}
/*}*/
/* Dark Mode end */

2020-12-19

Vanilla JS Popover Microinteraction
Vanilla JS Popover Microinteraction
A popover is a transient view that shows on a content screen when a user clicks on a control button or within a defined area.
//  A popover is a transient view that shows on a content screen when
//  a user clicks on a control button or within a defined area.
/*   __  __     __           __  __     __  __    
    /\ \/\ \   /\ \         /\ \/\ \   /\_\_\_\   
    \ \ \_\ \  \ \ \        \ \ \_\ \  \/_/\_\/_  
     \ \_____\  \ \_\        \ \_____\   /\_\/\_\ 
      \/_____/   \/_/         \/_____/   \/_/\/_/   */

let oPopOver = ( ( _win, _doc, _qry ) => {
        let arPops = [], ePos, iOffTop=0, iOffLft=0;
        return {  // Popover UX pattern
            "init": function(){ // wire DOM events
                arPops= [].slice.call(_doc.querySelectorAll( _qry ));
                _win.addEventListener("resize", oPopOver.closeAll);
                _win.addEventListener("scroll", oPopOver.closeAll); 
                _doc.body.addEventListener("click", function( e ){ // 👁️ Outside Click
                    let eTarget = e.target, bInside = false;
                    while( eTarget.tagName !== "HTML" ){
                        if( eTarget.dataset.popover ){ bInside = true; break; }
                        eTarget = eTarget.parentNode;
                    }
                    if( !bInside ){ // Tapped Outside of Popover
                        oPopOver.closeAll();
                    }
                }, true);
            },
            "open": function(id, evPos){ // Open a single Popover
                if( arPops.length == 0) return false;
                oPopOver.closeAll();
                ePos = evPos.currentTarget;
                let elPop = arPops.filter(function(el){
                    return ( el.id == id );
                })[0];
                iOffTop = Number(elPop.dataset.popoverPos.split("|")[0]);
                iOffLft = Number(elPop.dataset.popoverPos.split("|")[1]);
                elPop.dataset.popover = "true"; // Open and Active
                elPop.style.left = oPopOver.position().left+"px";
                elPop.style.top = oPopOver.position().top+"px";
            },
            "closeAll": function(){ // Close all Popovers
                if( arPops.length == 0) return false;
                arPops.map(function(el){
                    el.dataset.popover = "false";
                });
            },
            "position": function(){ // Determine Popover position
                let rec = ePos.getBoundingClientRect(),
                pxLft = _win.pageXOffset || _doc.documentElement.scrollLeft,
                pxTop = _win.pageYOffset || _doc.documentElement.scrollTop;
                return { top: (rec.top + pxTop + iOffTop), left: (rec.left + pxLft + iOffLft) }
            }
        }
    })(window, document, "[data-popover]"); // Declarative implementation

2020-12-16

Vue.js double tap Microinteraction
Vue.js double tap Microinteraction
Firing both a tap and a double-tap on the same element
//  A Vue.js snippet that shows how to capture both a tap and
//  a double-tap on the same element within the template.
//
//  Canonical Use Case: Double-Tap to zoom into a hero image
//  and single-tap to zoom out.

/*    ____   ____                      __        
 *    \   \ /   /_ __   ____          |__| ______
 *     \   Y   /  |  \_/ __ \         |  |/  ___/
 *      \     /|  |  /\  ___/         |  |\___ \ 
 *       \___/ |____/  \___  > /\ /\__|  /____  >
 *                         \/  \/ \______|    \/ */

 methods: {
    "doHeroMobMouseUp": function( ev ){ // Double Tap
      var oHro = this.oHeroZmMob;
      if( oHro.isInit ){
        if( oHro.doubleTap ){ // Zoom In
          oHro.doubleTap = false;
          this.doHeroMobScale( .5 ); // Double Tap
        }else{
          oHro.doubleTap = true;
          setTimeout(function(){ this.doHeroMobMouseUp_expire() }, 380);
        }
      }
      this.oHeroZmMob.isDown = false;
    },
    "doHeroMobMouseUp_expire": function(){ // Single Tap
      var oHro = this.oHeroZmMob;
      if( oHro.isInit ){ // Zoom Out
        if( oHro.doubleTap ) this.doHeroMobScale( -.5 ); // Single Tap
        oHro.doubleTap = false;
      } 
    }
 }
 /*
 This is only part of a larger Vue gesture implementation supporting
 Pinch 🤏, Zoom, Pan, and Swipe. Reach out to me if you want to learn more.
 */

2020-12-15

CSS Advanced Accessibility
CSS Advanced Accessibility
Motion, theme, and skip A11Y CSS solutions
/* Skip to Main Content - CSS Focus rules that make the
link visible when focused from the omnibox.
========================================
====  ========  ========  =====  ====  =
===    =====    ======    =====   ==   =
==  ==  ======  ========  ======  ==  ==
=  ====  =====  ========  ======  ==  ==
=  ====  =====  ========  =======    ===
=        =====  ========  ========  ====
=  ====  =====  ========  ========  ====
=  ====  =====  ========  ========  ====
=  ====  ===      ====      ======  ====
======================================== */

a.skip__main:active, a.skip__main:focus {
    background-color: #fff;
    border-radius: 4px;
    border: 2px solid #000;
    color: #000;
    font-size: 1em;
    height: auto; width: 16%;
    left: auto;
    margin: 8px 42%;
    overflow: auto;
    padding: 4px;
    text-align: center;
    top: auto;
    z-index: 1024;
}
a.skip__main {
    left: -1024px;
    overflow: hidden;
    position: absolute;
    top: auto;
    width: 1px; height: 1px;
    z-index: -1024;
}
/* Dark Mode begin */
@media (prefers-color-scheme: dark) {
    body, [role='main'] {
        background: linear-gradient(to right, #555, #c2c2c2, #555)
    }
    .h-bg__stripe, .l-caro-design > article, article.l-caro-design {
        background: repeating-linear-gradient(45deg,#bbb,#bbb 24px,#ddd 24px,#ddd 48px);
    }
}
/* Dark Mode end */

/* Reduced Motion begin*/
@media (prefers-reduced-motion: reduce) {
    .hero__vect { animation: none; }
}
/* Reduced Motion end*/

<a class="js-skip__main--id skip__main"
href="#a11y-skipmain">Skip to Main Content</a>

2020-12-13

JS Airport Geo-Proximity Radius
JS Airport Geo-Proximity Radius
Airport geo-proximity logic that answers questions, like; “What are the three closest airports to me right now?”
// Desc: Get the closest airports by geolocation radius
// Usage: closestAirports.find(-99, -99, oAirports, 4); // 4 miles

/*       ___           _                 _   _             
 *      / _ \___  ___ | | ___   ___ __ _| |_(_) ___  _ __  
 *     / /_\/ _ \/ _ \| |/ _ \ / __/ _` | __| |/ _ \| '_ \ 
 *    / /_\\  __/ (_) | | (_) | (_| (_| | |_| | (_) | | | |
 *    \____/\___|\___/|_|\___/ \___\__,_|\__|_|\___/|_| |_|  ✈️ */

function getDistance(lat1, lon1, lat2, lon2) {
    let radlat1 = Math.PI * lat1/180;
    let radlat2 = Math.PI * lat2/180;
    let theta = lon1-lon2;
    let radtheta = Math.PI * theta/180;
    let dst = Math.sin(radlat1) * Math.sin(radlat2) + Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta);
    if (dst > 1) dst = 1;
    dst = Math.acos(dst);
    dst = dst * (180/Math.PI) * (60 * 1.1515); // miles
    return dst;
  }

let closestAirports = ((_d) => {
    return {
      "find": function(nLat, nLon, objPorts, nRadius){
        if(nLat && nLon && objPorts){
          let arrPorts = [];
          for (let prop in objPorts) { // Sort Object
            if (objPorts.hasOwnProperty(prop)) {
              let lat = objPorts[prop].geoCode.split(",")[0];
              let lon = objPorts[prop].geoCode.split(",")[1];
              arrPorts.push({
                'key': prop, 'lat': lat, 'lon': lon,
                "dist": getDistance(lat, lon, nLat, nLon),
                "formattedAirport": objPorts[prop].formattedAirport
              });
            }
          }
          arrPorts.sort(function(a, b){
            // Sort by Distance
            return a.dist - b.dist;
          });
          return arrPorts.filter(function(aP){
            return (aP.dist <= nRadius);
          });
        }
      }
    };
})(document);

2020-12-13

Cypress E2E Quality Assurance
Cypress E2E Quality Assurance
End to End testing 🚀 Headless browser automation
/*    _____                               
     /  __ \                              
     | /  \/_   _ _ __  _ __ ___  ___ ___ 
     | |   | | | | '_ \| '__/ _ \/ __/ __|
     | \__/\ |_| | |_) | | |  __/\__ \__ \
      \____/\__, | .__/|_|  \___||___/___/.io
             __/ | |                      
            |___/|_|                      E2E

✅ Automatically capture a video when a test fails
✅ Test values persisted in the Vuex (Vue.js Vuex specific) store
✅ Apply optional configuration files via the command line (requires v3.6)
✅ Create custom reusable, and chainable commands, such as cy.signIn() or cy.turnOnFeature()
✅ Test responsive layout & Local Storage
✅ Test A11y WCAG success criteria  */

describe('E2E test | Hotel navigation, selection, and discovery', () => {
  context('Admin Add Hotel to Event', function () { // ignore CORS
      Cypress.on('uncaught:exception', (err, runnable) => { return false });
      it('Success Login then Save Event', () => {
          cy.viewport(1066, 600)  //  large laptop 66.563em
          cy.log( JSON.stringify(Cypress.env()) )
          let event_url;  //  The URL of the first event (default)
          let dMessage = new Date();  //  Now
          dMessage = dMessage.toDateString() + " " + dMessage.toLocaleTimeString();

          cy.tt_SignIn(Cypress.env( "mock_email" ), Cypress.env( "mock_password" ))
          .then(() => {
              cy.window().then( $win => {
                  cy.wrap( $win.system_output ).should("exist")
              })
          })
          cy.url().should('not.include', 'login.')
          cy.visit( Cypress.env( "e2e_url_events" ) )
          cy.url().should('include', 'events.')
          Cypress.Cookies.debug(true, { verbose: false })
          cy.getCookie('logintoken').should('exist')
          cy.getCookie('role_id').should('exist')
          cy.getCookie('username').should('exist')
          cy.getCookie('otapi_token').should('exist')
          cy.get("a[href*='event-edit']" ).first().click()  //  Find the first matching link in the table.
          cy.get("#messages" ).type("{selectall}{backspace}E2E Test: " + dMessage )
          cy.get("#eventForm > div.border-top.d-flex.pt-3.row > div > input" ).first().click()  //  Save change

          cy.get("#airTab" ).click()  //  select tab
          cy.get("#activate_flights" ).check();
          cy.get("#flightForm > div.border-top.d-flex.pt-3.row > div > input" ).click();           

          cy.get("#vehicleTab" ).click()  //  select tab
          cy.get("#activate_vehicle" ).uncheck();
          cy.get("#vehicleForm > div.border-top.d-flex.pt-3.row > div > input" ).click();   

          cy.get("#hotelTab" ).click()  //  select tab
          cy.get("#activate_hotels" ).check();
          cy.get("#hotelForm > div.border-top.d-flex.pt-3.row > div > input" ).click();   

          //  Extract URL from INPUT
          cy.get('#siteURL').invoke('val')
              .then( value => { event_url = value; });
          cy.then(() => { return cy.visit(event_url); });
      })
  })
  context('Choose Flight', function () {
      Cypress.on('uncaught:exception', (err, runnable) => { return false }); // ignore CORS
      it('Success Flight added to cart', () => {
          cy.viewport(1066, 600)  //  large laptop 66.563em 
          cy.get("#from_airport" ).type( "ORD" )
          cy.get("#to_airport" ).type( "LGA" )
          cy.get("input[name='from_date']" ).click({ force: true })

          cy.server()
          cy.route("*").as( "checkout" )

          cy.get("div.vdp-datepicker.flex-fill > div:nth-child(2) > div > span:nth-child(39)" ).first().click()
          cy.get("#search-widget-btn" ).click()
          cy.wait("@checkout" ).its('status').should('eq', 200)

          cy.get("h5.modal-title").should("not.be.visible")
              .then( ($ModalMsg) => {
                  cy.get("div.align-self-center.col-6.col-md.col-sm.col-xl.order-12.p-xs-1.text-right > button" ).first().click() 
              } )
      })
  })
  context('Hotel LightBox', function () {
      Cypress.on('uncaught:exception', (err, runnable) => { return false }); // ignore CORS
      it('Success Hotel added to cart', () => {
          cy.viewport(1066, 600)  //  large laptop 66.563em
          cy.wait(2000)
          cy.get("picture > img" ).first()
              .then( ( $picture )=>{
                  cy.wrap( $picture  ).click()
                  cy.wait( 6000 )
              })
          cy.get(".l-ltbx__image" ).first().click()  //  Cycle photos forward
          cy.get(".l-ltbx__vect--right" )
              .then( ( $arrow_right ) => {
                  cy.wait( 1000 )
                  cy.wrap( $arrow_right ).click()
                  cy.wait( 1000 )
                  cy.wrap( $arrow_right ).click()
                  cy.wait( 1000 )
                  cy.wrap( $arrow_right ).click()
          })
          cy.wait( 1000 )
          cy.get(".l-ltbx__btn" ).first()  //  Cycle photos backward
              .then( ( $arrow_left ) => {
                  cy.wrap( $arrow_left ).click()
                  cy.wait( 1000 )
              })
          cy.get(".l-ltbx__figcap").invoke("text").should("include", "4 of")
              .then( () => {
                  cy.get(".l-ltbx__vect" ).first().click()  //  Close Modal
                  cy.get("OUTPUT BUTTON.l-button" ).first().click()  //  Book Room
                      .then( () => {
                          cy.get( "A.ttfont-semibold.tttext-gray-700").first().click()  //  Change Tab
                          cy.wait( 1000 )
                          cy.get( "A.ttfont-semibold.tttext-gray-700").first().click()  //  Change Tab
                          cy.wait( 1000 )
                          cy.get( "ARTICLE SECTION BUTTON.l-button").first().click()  //  Book Room
                              .then( ()=>{
                                  cy.wait( 4000 )
                                  cy.url().should('include', '/checkout')
                              })
                      })
              })
      })
  })
})

2020-12-07

Asynchronous eCom Nav Category Count
Asynchronous eCom Nav Category Count
Asynchronous recursive crawl reports the total number of products by category.
// Desc:  Asynchronous recursive crawl report the total number of products by category
// Usage: Console SNIPPET catCount.init();

/*   @@@@@@ @@@  @@@ @@@ @@@@@@@  @@@@@@@  @@@@@@@@ @@@@@@@ 
    !@@     @@!@!@@@ @@! @@!  @@@ @@!  @@@ @@!        @!!   
     !@@!!  @!@@!!@! !!@ @!@@!@!  @!@@!@!  @!!!:!     @!!   
        !:! !!:  !!! !!: !!:      !!:      !!:        !!:   
    ::.: :  ::    :  :    :        :       : :: ::     :     run in console */

var catCount = (function(_d,_q){
    "use strict";
    let aSub = [];
    console.clear();

    return {
        init: function(){
            // Get ref to all product categories in the left nav 🛒
            aSub = [ ... _d.querySelectorAll( _q ) ].filter( ( el ) => {
                return (( el.firstChild.nodeValue ) && ( el.href ));
            } );
            aSub.forEach( ( elLink ) => {
                if( elLink ) catCount.asyncTotal( elLink );
            } );
        },
        parse: function( _Name, _Contents ){
            let aTotl = _Contents.split("sizeTotalNumRecs");
            if( aTotl[1].split('"')[2] ){
                 console.log( _Name, aTotl[1].split('"')[2]);
            }
            return true;
        },
        asyncTotal: function( _elLink ){
            let oXhr = new XMLHttpRequest();
            oXhr.open("GET", _elLink.href, true);
            oXhr.onreadystatechange = () => {
                if( this.readyState!==4 || this.status!==200 ) return;
                catCount.parse( _elLink.firstChild.nodeValue, this.responseText );
            };
            oXhr.send();
        }
    }

})(document, "LI.item nav > a" );

2020-12-07

Color of the Year CSS Styles
Color of the Year CSS Styles
Color of the Year 2000 thru 2021 CSS Utility classes
/* Tailwind like CSS Utility classes for the
Pantone Color of the Years from 2000 thru 2021
/*      ____             _                   
 *     |  _ \ __ _ _ __ | |_ ___  _ __   ___ 
 *     | |_) / _` | '_ \| __/ _ \| '_ \ / _ \  2000- 2021
 *     |  __/ (_| | | | | || (_) | | | |  __/
 *     |_|   \__,_|_| |_|\__\___/|_| |_|\___|  🟥 🟩 🟦 🟪 🟨 */

 /* Color of the Year begin */
 .bg-coy_2000  {background-color: #9BB7D4;}  /* Cerulean */
 .bg-coy_2001  {background-color: #C74375;}  /* Fuchsia Rose */
 .bg-coy_2002  {background-color: #BF1932;}  /* True Red */
 .bg-coy_2003  {background-color: #7BC4C4;}  /* Aqua Sky */
 .bg-coy_2004  {background-color: #E2583E;}  /* Tigerlily */
 .bg-coy_2005  {background-color: #53B0AE;}  /* Blue Turquoise */
 .bg-coy_2006  {background-color: #DECDBE;}  /* Sand Dollar */
 .bg-coy_2007  {background-color: #9B1B30;}  /* Chili Pepper */
 .bg-coy_2008  {background-color: #5A5B9F;}  /* Blue Iris */
 .bg-coy_2009  {background-color: #F0C05A;}  /* Mimosa */
 .bg-coy_2010  {background-color: #45B5AA;}  /* Turquoise */
 .bg-coy_2011  {background-color: #D94F70;}  /* Honeysuckle */
 .bg-coy_2012  {background-color: #DD4124;}  /* Tangerine Tango */
 .bg-coy_2013  {background-color: #009473;}  /* Emerald */
 .bg-coy_2014  {background-color: #B163A3;}  /* Radiant Orchid */
 .bg-coy_2015  {background-color: #955251;}  /* Marsala */
 .bg-coy_2016  {background-color: #F7CAC9;}  /* Rose Quartz */
 .bg-coy_2016b {background-color: #92A8D1;}  /* Serenity */
 .bg-coy_2017  {background-color: #88B04B;}  /* Greenery */
 .bg-coy_2018  {background-color: #5F4B8B;}  /* Ultra Violet */
 .bg-coy_2019  {background-color: #FF6F61;}  /* Living Coral */
 .bg-coy_2020  {background-color: #0F4C81;}  /* Classic Blue */
 .bg-coy_2021  {background-color: #939597;}  /* Ultimate Gray */
 .bg-coy_2021b {background-color: #F5DF4D;}  /* Illuminating */

 .text-coy_2000  {color: #9BB7D4;}  /* Cerulean */
 .text-coy_2001  {color: #C74375;}  /* Fuchsia Rose */
 .text-coy_2002  {color: #BF1932;}  /* True Red */
 .text-coy_2003  {color: #7BC4C4;}  /* Aqua Sky */
 .text-coy_2004  {color: #E2583E;}  /* Tigerlily */
 .text-coy_2005  {color: #53B0AE;}  /* Blue Turquoise */
 .text-coy_2006  {color: #DECDBE;}  /* Sand Dollar */
 .text-coy_2007  {color: #9B1B30;}  /* Chili Pepper */
 .text-coy_2008  {color: #5A5B9F;}  /* Blue Iris */
 .text-coy_2009  {color: #F0C05A;}  /* Mimosa */
 .text-coy_2010  {color: #45B5AA;}  /* Turquoise */
 .text-coy_2011  {color: #D94F70;}  /* Honeysuckle */
 .text-coy_2012  {color: #DD4124;}  /* Tangerine Tango */
 .text-coy_2013  {color: #009473;}  /* Emerald */
 .text-coy_2014  {color: #B163A3;}  /* Radiant Orchid */
 .text-coy_2015  {color: #955251;}  /* Marsala */
 .text-coy_2016  {color: #F7CAC9;}  /* Rose Quartz */
 .text-coy_2016b {color: #92A8D1;}  /* Serenity */
 .text-coy_2017  {color: #88B04B;}  /* Greenery */
 .text-coy_2018  {color: #5F4B8B;}  /* Ultra Violet */
 .text-coy_2019  {color: #FF6F61;}  /* Living Coral */
 .text-coy_2020  {color: #0F4C81;}  /* Classic Blue */
 .text-coy_2021  {color: #939597;}  /* Ultimate Gray */
 .text-coy_2021b {color: #F5DF4D;}  /* Illuminating */
 /* Color of the Year end */

2020-12-07

Virtual Keyboard Extention Configuration
Virtual Keyboard Extention Configuration
TS Virtual Keyboard Chrome Extention
// TS Virtual Keyboard ⌨️ Chrome Extention | Configuration Class
/***
 *     _______                     _                _            
 *    (_______)                   | |              (_)      _    
 *     _      _   _ ____   ____    \ \   ____  ____ _ ____ | |_  
 *    | |    | | | |  _ \ / _  )    \ \ / ___)/ ___) |  _ \|  _) 
 *    | |____| |_| | | | ( (/ / _____) | (___| |   | | | | | |__ 
 *     \______)__  | ||_/ \____|______/ \____)_|   |_| ||_/ \___)
 *           (____/|_|                               |_|         
 npm install --save @types/chrome
 */

class AVKOptions {
    aOpts : Array<any>;
    constructor ( pAr : Array<any> = [] ) {
        this.aOpts = pAr;
    }
    setState (  sOpt : string, bState : boolean ) : boolean{
        this.aOpts = this.aOpts.filter( (e) => {
            if (e[0] === sOpt) e[1] = bState;
             return true;
        } );
        return bState;
    }
    getState ( sOpt : string ) : boolean {
        return this.aOpts.filter( (e) => {
            if (e[0] === sOpt) {
                return true;
            }            
        })[0][1];
    }
    getFeedback ( sOpt : string ) : string {
        return this.aOpts.filter( (e) => {
            if (e[0] === sOpt) {
                return true;
            }            
        })[0][2];
    }
}
export let options = new AVKOptions([["audio", false, "Click Sounds"],
["autohide", false, "Hide if not in use"], ["blur", false, "Blur Text"],
["hover", false, "Hover No Click"], ["opaque", false, "Cannot See Through"],
["scramble", false, "Rearrange Keys"], ["theme", false, "Daytime theme"]]);

2020-12-07

Web Music Ad Blocker Snippet
Web Music Ad Blocker Snippet
Automatically mute the Music player when Ads are playing and unmute when they are done (in Chrome).
/* Install: Open Chrome Dev Tools (Command+option+I on Mac). Menu > Sources > Snippets
   Install: Create a new Snippet named musicADify.js, Paste this script, Save (Command+S).
   Usage: Run the Snippet once each time you start the Music Web Player.
   Usage: Right-Click the snippet named musicADify.js and choose Run from the drop-down.
   Usage: Close Chrome Dev Tools. 🏖️ Play your Jams! 🎶

╔═╗┌─┐┌─┐┌┬┐┬┌─┐┬ ┬  ╔═╗┌┬┐┌─┐
╚═╗├─┘│ │ │ │├┤ └┬┘  ╠═╣ ││└─┐
╚═╝┴  └─┘ ┴ ┴└   ┴   ╩ ╩─┴┘└─┘ */

 let spotADify = ( (_d, _q, _t) => {
    let eS = _d.querySelector( _q ), bS = true;
    if( eS ){ // 🏖️ Play your Jams! 🎶
        bS = ( eS.getAttribute("aria-label") == "Mute" );
        setInterval( () => {spotADify.tick();}, _t);
        return {
        "tick":  () => {
          if((_d.title.indexOf("Adve") != -1) || (_d.title.indexOf("Spoti") != -1)){
              if(  bS ){ eS.click(); bS=!true; }
          }else{
              if( !bS ){ eS.click(); bS=true; }
          }
        }
      }
    }
  })( document, "[aria-label='Mute'],[aria-label='Unmute']", 256);

2020-12-07

Real-world Vetted Snippets

WCAG 508 ARIA JavaScript