Salesforce, Python, SQL, & other ways to put your data where you need it

Need event music? 🎸

Live and recorded jazz, pop, and meditative music for your virtual conference / Zoom wedding / yoga class / private party with quality sound and a smooth technical experience

Responsive, accessible navigation 8: ???

10 Feb 2021 🔖 jamstack web development
💬 EN

Maybe before more “shape” work I should start thinking about “visual accessibility” work.

I’m not 100% sure this is really necessary for what I’m about to do, but starting w/ making the buttons the same color as their background, rather than gray:

.topnav {
  button {
    background-color: inherit;
  }
}

And adding a new bit inside a.topnav__body__actionable so that it becomes light blue to indicate “clickability” when you hover over or tab past it.

a.topnav__body__actionable {
  display: block;
  &:hover, &:focus {
    background-color: #dcedff;
  }
}

I’m deliberately not turning the buttons blue when you tab/hover them into focus, since they’re not “clickable” as in taking you to another web page, even though the spans inside them are “actionable” (maybe I should reconsider that naming pattern). Buttons merely expose “clickable” things when focused/hovered.

(Real Python, by the way, DOES change the button color on hover, but probably because you actually have to click/Enter the button to get it to react. The dropdown items, then, match the dark-blue of the main navbar, with hovering over individual links doing the same light-blue color as the main link hover. They forgot to do “focus” altogether, although there is a faint outline visible.)

(Real Python’s forced-enter(not-space-not-sure-why-not)/click of their drop-down does have the advantage of not forcing a blind user to slog through the whole nav when tabbing … JS-dependent, though – I checked. Completely un-expandable by keyboard or mouse w/ JS off. That said, I was only going to do my “side-by-side” design w/ JS turned on, anyway, due to dropdown-size computation requirements, so imitating Real Python is definitely still a possibility.)

(W3C has a Real Python-like “must interact” thing that works w/ actually having the header also be a link – there’s a down-arrow next to the header – that also helps solve the reverse-navigability thing since there’s no implication that you can just tab through a submenu w/o interacting w/ it first.)

The reverse-shift-tab bug is really annoying on a small screen or noJS – the items get exposed, but you don’t notice your focus is already up above them, and then they disappear when you shift-tab again.

Maybe I’ll only control .topnav__dropdown__items visibility w/ .topnav__dropdown &:focus-within, .topnav__dropdown &:hover inside a html.no-js context, and otherwise control it w/ a JS-controlled button-click/enter.


Okay, I did it!

It’s not pretty, but it’s functional and I think I can move on once I put today’s work into GH.

“Stacked” is ready to style, I believe. It’s a good reason to style sub-menus a different background color (when inactive) than top-menus, by the way, so people notice the fly out/in action.

“Side-by-side” still needs work – the “float” bit (both CSS & JS).

Hmm – except it’s a little confusing to have “buttons” that don’t actually do anything? Maybe use Aria or something to help … could also set negative-tabindex in HTML and add it back w/ JS on page load.

TODO: Go back & fix my github repo & make a note in my blog that --middle --left makes for better alignment than --top --left on the navbar.


I rearranged the structure of nav-related SASS but haven’t done anything to really change its contents yet.

I added the following code to main.js, adding a .collapsed-if-appropriate class to the element already classed as .topnav__body and making the “hamburger” menu button able to toggle that class on/off.

// Start the body "collapsed-if-appropriate"
$(".topnav__body").classList.toggle("collapsed-if-appropriate");

// Make the hamburger toggle a "collapsed-if-appropriate" class on the nav body
// TO DO:  Add a 2nd key-based listener.  Space/Enter for .toggle() but also Esc for .remove()
$(".topnav__hamburger").addEventListener("click", function (event) {
  $(".topnav__body").classList.toggle("collapsed-if-appropriate");
});

Playing with the “hamburger” button, using both my mouse & my keyboard, I can see that my class comes and goes from the appropriate element of the DOM.

  1. Clicking or using space/enter on the button toggles the navbar body’s collapsed-if-appropriate class on and off.
  2. Hitting escape while focused on the hamburger button removes the navbar body’s collapsed-if-appropriate class.

Yay.

Screenshot, collapsed class toggled on in dev console

Screenshot, collapsed class toggled off in dev console

(See GitHub commit #1 and GitHub commit #2)


Then I did the same idea for dropdown-controlling buttons, making them toggle an exposed class attached to the container around their “2nd-level” menu items.

  1. Clicking or using space/enter on a .topnav__dropdown__button element toggles an exposed class on and off on its parent .topnav__dropdown DIV.
  2. Hitting escape while focused on such a button (although not on one of the seconary menu items it exposes – I wonder if I should change that or leave it as-is) removes the parent DIV’s exposed class.
  3. Navigating forward or back with the keyboard in a way that exits the .topnav__dropdown DIV removes that DIV’s exposed class, as does clicking a page element outside of the .topnav__dropdown, such as another button or the page body.
function toggleNavDropdown(event) {
  parent = this.closest(".topnav__dropdown");
  if (
    !this.classList.contains("topnav__dropdown__button") ||
    !parent ||
    (event.type !== "click" && event.type !== "keydown")
  ) {
    // Short-circuit; N/A
    return;
  }
  if (event.type === "click") {
    // Turns out I don't need to add 32/13 (space/enter) "keypress" -- it just fights w/ browser treating it as "click"
    parent.classList.toggle("exposed");
  } else if (event.type === "keydown") {
    let code = event.charCode || event.keyCode;
    if (code === 27) {
      // Escape key
      parent.classList.remove("exposed");
    }
  }
}

var skipFocusOutClear = false;
function clearNavDropdownWhenFocusOutside(event) {
  if (event.type === "mousedown") {
    skipFocusOutClear = true;
  } else {
    for (exposed_dropdown of $$(".topnav__dropdown.exposed")) {
      if (event.type === "focusout" && skipFocusOutClear) {
        skipFocusOutClear = false;
        continue;
      }
      if (
        !exposed_dropdown.contains(event.target) ||
        (!!event.relatedTarget &&
          !exposed_dropdown.contains(event.relatedTarget))
      ) {
        exposed_dropdown.classList.remove("exposed");
      }
      skipFocusOutClear = false;
    }
  }
}

// If you click/focus outside of a given "exposed dropdown," un-expose that dropdown. // Credit: https://laracasts.com/discuss/channels/vue/close-dropdown-when-click-another-element, https://jsfiddle.net/kym2rvyL/1/
window.addEventListener("mousedown", clearNavDropdownWhenFocusOutside);
window.addEventListener("click", clearNavDropdownWhenFocusOutside);
window.addEventListener("focusout", clearNavDropdownWhenFocusOutside);

// If you click/keystroke on a given "dropdown button", toggle "exposed" on its parent // Credit: https://flaviocopes.com/add-click-event-to-dom-list/, https://stackoverflow.com/a/59406548, https://gomakethings.com/listening-to-multiple-events-in-vanilla-js/ via @jemjam
for (let btn of $$(".topnav__dropdown__button")) {
  btn.addEventListener("click", toggleNavDropdown);
  btn.addEventListener("keydown", toggleNavDropdown);
}

Screenshot, exposed class toggled on in dev console

(See GitHub commit #1 and GitHub commit #2)

There are a few keystroke combinations that don’t result in a loss of exposed, such as Alt+D into the navigation bar (instead, it finally loses focus on about your third Tab back into the body of the page, which is the first full transition from one element to the next), but that seems to be about the way Real Python works as well (all right, somehow theirs clears when landing _on the first element of tabbing through the page, not the second)_. I’m going to call it good enough for now, and maybe come back to it another time.

I double-checked that everything works as intended even if I’ve manually messed w/ the DOM & added exposed to multiple “dropdown” DIVs. It does. Yay!


Now that my JavaScript user-interaction-event infrastructure is in place, it’s time to use CSS to style the various states my DOM can be in.

For lack of a better name, I’m putting all SASS that has to do with styling the navbar in reaction to the “state of the browser” (window size, the JavaScript-toggled classes, above, etc.) into a file called /src/_includes/scss/nav/_browser_tricks.scss.

I’ll move the mixin wide-nav-mixin() and the code that calls it into this new file. Plus, I’ll rename it stacked-nav-mixin() to avoid confusion between the notion of its elements taking up 100% of screen width apiece that I meant by “wide,” vs. “widescreen.”

I’ll also create 2 new mixins, hidden-dropdown-items and exposed-dropdown-items, which toggle the interactability of any .topnav__dropdown__items-classed HTML/DOM elements in their scope:

@mixin hidden-dropdown-items() {
  .topnav__dropdown__items {
    display: none;
  }
}

@mixin exposed-dropdown-items() {
  .topnav__dropdown__items {
    display: block;
  }
}

I won’t build mixins for the idea of a “collapsed” nav-body altogether … I’ll just style it directly where it’s relevant.

Another mixin I’ll want later is the idea of making .topnav__dropdown__items-classed HTML/DOM elements “floaty” (hovering over other things w/o displacing them):

@mixin floaty-dropdown-items() {
  .topnav__dropdown__items {
    position: absolute; // Make dropdown items float in the right spot
    z-index: 1; // Make sure dropdown items cover anything beneath them
  }
}

Time to take some action.

I know that, by default, I want to hide the “hamburger button.”

.topnav__hamburger {
  // By default, hide the hamburger
  display: none;
}

Screenshot, hamburger button gone

I gave up on finding a JavaScript-free, older-browser-friendly, keyboard-friendly way to selectively expose “dropdown items,” so I’ve decided to simply leave them all exposed at all times. I told you the No-JavaScript version of this navbar only needed to be functional, not pretty!

To make it not completely ugly, I’m simply using the “stacked” look and feel. If the navigation menu has a lot of items & sub-items in it, visitors w/o JavaScript enabled will just have to scroll a bit before they get to the main body of the page. Sorry.

html.no-js {
  // With JS disabled, use "stacked" layout & expose all 2nd-level items.  Ugly but functional.
  @include stacked-nav-mixin();
  @include exposed-dropdown-items();
}

Screenshot, "stacked & exposed" layout w/ JS disabled

Next comes the code for when JavaScript is enabled.

  1. Since we have a way of re-exposing “dropdown items,” we’ll hide them all using the hidden-dropdown-items() mixin.
  2. Inside any given .exposed-class dropdown DIV, we’ll expose its “items” using the exposed-dropdown-items() mixin.
  3. I might change the mechanism of how I do this … but let’s put a little symbol next to the text of buttons to make it more visually obvious that they expose “more stuff.”
  4. On small screens…
    • “Stack” the menu items using the stacked-nav-mixin() mixin.
    • Re-expose the “hamburger menu”
    • Hide the body of the navigation whenever it has a .collapsed-is-appropriate class.
  5. On big screens, style all “dropdown items” to be “floaty,” as previously described, using the floaty-dropdown-items() mixin.
html.js {
  // With JS turned on ...
  .topnav {
    // By default, hide all "dropdown items" when JavaScript is available
    @include hidden-dropdown-items();
  }

  .topnav__dropdown.exposed {
    // When a "dropdown" is toggled to "exposed" by JavaScript, expose its "items"
    @include exposed-dropdown-items();
  }

  .topnav__dropdown__button {
    &::after {
      content: "\00a0▾"; // Make dropdown buttons visually obvious with a down arrow
    }
  }

  @media (max-width: ($width-sm+60)) {
    // 503 sm, 783 md (460 / 740)
    @include stacked-nav-mixin();

    .topnav__hamburger {
      // On small screens w/ JS on, expose the hamburger
      display: block;
    }
    .topnav__body.collapsed-if-appropriate {
      // Hide "collapsed" things controlled by the hamburger
      display: none;
    }
  }

  @media (min-width: ($width-sm+61)) {
    @include floaty-dropdown-items();
  }

  // End of "With JS turned on ..." html.js selector
}

Let’s see how I did.

On a nice big screen, the dropdowns “float” right below their parents when expanded.

Screenshot, JS on, desktop dropdown

Maybe it shows up better if I add a little style="background-color: lavender;" w/ the browser’s developer console so you can see that it’s truly hiding the content behind it:

Screenshot, JS on, desktop dropdown, opaque flyout

On a midsize screen that’s still wide enough to result in “floaty” dropdowns but narrow enough that supercali... overflows it, I have a horizontal-scrollbar bug I’ll need to fix.

Screenshot, JS on, desktop dropdown, flyout overflow bug

Mobile seems fine – the menu toggle works both by keyboard & by mouse, and all my “interactivity controls” do what I expect w/o any text overflow on long words.

Animated GIF screencapture, JS on, mobile dropdown

Everything works as expected both widescreen & narrow-screen with my finger as a “pointer,” too. Nifty!

(See GitHub commit)


Let’s fix that “overflow text” bug.

Also, I’d like to make sure that if a content author enters extremely short labels for a 2nd-level navigation menu, it shows up clearly. .topnav__dropdown__items-classed DIVs should have a minimum width. Once upon a time, I used a premade theme that decided upon 160 pixels as a minimum width, and that’s always seemed sufficient to me.

Screenshot, JS on, desktop dropdown, flyout too narrow

Nevertheless, I need to make sure that introducing this minimum width doesn’t introduce overflow problems of its own.

To fix it, first I added 3 more helper functions to main.js:

// Helper:  getViewportWidth() // Credit: https://css-tricks.com/snippets/javascript/viewport-size-screen-resolution-mouse-postition/, Googled thanks to Jason Lengstorf, plus https://stackoverflow.com/questions/8339377/how-to-get-screen-width-without-minus-scrollbar
function getViewportWidth() {
  if (document.documentElement.clientWidth) {
    return document.documentElement.clientWidth;
  } else if (document.body && document.body.offsetWidth) {
    return document.body.offsetWidth;
  } else {
    return 0;
  }
}

// Helper:  set an element's width if it's overflowing
function set_width_to_not_overflow_right(elem) {
  let bounding = elem.getBoundingClientRect();
  let screenwidth = getViewportWidth();
  if (bounding.right > screenwidth) {
    let subtract_me = bounding.right - screenwidth;
    let current_width = bounding.right - bounding.left;
    let new_width = current_width - subtract_me;
    elem.style.minWidth = "auto";
    elem.style.width = new_width + "px";
  }
}

// Helper:  adjust all "dropdown items'" size for a given parent element
function adjustDropdownItemsSize(parent) {
  for (dropdown_items_elem of parent.querySelectorAll(
    ".topnav__dropdown__items"
  )) {
    // First, reset them to their natural size
    dropdown_items_elem.style.minWidth = "160px";
    dropdown_items_elem.style.width = "auto";
    // Then handle them if they're overflowing
    set_width_to_not_overflow_right(dropdown_items_elem);
  }
}

Then I called it upon window resize:

// Resize dropdown items if someone resizes a screen while a dropdown is exposed
window.onresize = function () {
  adjustDropdownItemsSize(document);
};

And also added it into the part of toggleNavDropdown() that’s capable of exposing a dropdown:

function toggleNavDropdown(event) {
  ...
  if (event.type === "click") {
    ...
    // Also do some sizing on the "items" within
    adjustDropdownItemsSize(parent);
  }
  ...
}

And voilà! No more overflows … and narrow dropdowns are as wide as the “don’t overflow the screen” rule will let them be.

Screenshot 1/4, JS on, desktop dropdown, fixed

Screenshot 2/4, JS on, desktop dropdown, fixed

Screenshot 3/4, JS on, desktop dropdown, fixed

Animated GIF screencapture, JS on, desktop dropdown, fixed

Screenshot 4/4, JS on, desktop dropdown, fixed

(See GitHub commit)


TODO: WRITE ME UP.

Also playing with incorporating some UL & LI structure into the nav (I suppose it could help assistive technology better indicate that 2nd-level navigational items are somehow “less than” first-level ones – although are they really?).

Perfect – the 2 pages look the same, but one has lists and the other doesn’t:

Side-by-side screenshot showing my 2 pages look the same but have different DOM structures

(See GitHub commit #1 and See GitHub commit #2)


Next up is visuals. Time to finally break out of Netscape Navigator styling.

Everything should look the same and look good no matter which of my navbars (with or without lists) I’m using.

--- ---