diff --git a/aria-practices.html b/aria-practices.html index 486d525334..12ea97c3fc 100644 --- a/aria-practices.html +++ b/aria-practices.html @@ -1228,7 +1228,8 @@

Examples

diff --git a/examples/disclosure/css/disclosure-navigation.css b/examples/disclosure/css/disclosure-navigation.css index 750a208f2c..2a79a9aee2 100644 --- a/examples/disclosure/css/disclosure-navigation.css +++ b/examples/disclosure/css/disclosure-navigation.css @@ -3,6 +3,7 @@ display: flex; list-style-type: none; padding: 0; + margin: 0; } .disclosure-nav ul { @@ -16,12 +17,18 @@ min-width: 200px; padding: 0; position: absolute; + top: 100%; } .disclosure-nav li { margin: 0; } +.disclosure-nav > li { + display: flex; + position: relative; +} + .disclosure-nav ul a { border: 0; color: #000; @@ -43,14 +50,20 @@ position: relative; } -.disclosure-nav button { +.disclosure-nav button, +.disclosure-nav .main-link { align-items: center; + background-color: transparent; border: 1px solid transparent; border-right-color: #ccc; display: flex; padding: 1em; } +.disclosure-nav .main-link { + border-right-color: transparent; +} + .disclosure-nav button::after { content: ""; border-bottom: 1px solid #000; @@ -61,7 +74,12 @@ transform: rotate(45deg); } -.disclosure-nav button:focus { +.disclosure-nav .main-link + button::after { + margin-left: 0; +} + +.disclosure-nav button:focus, +.disclosure-nav .main-link:focus { border-color: #005a9c; outline: 5px solid rgba(0, 90, 156, 0.75); position: relative; @@ -87,3 +105,29 @@ .disclosure-page-content h3 { margin-top: 0.5em; } + +.sample-header { + border: #005a9c solid 2px; + background: #005a9c; + color: white; + text-align: center; +} + +.sample-header .title { + font-size: 2.5em; + font-weight: bold; + font-family: serif; +} + +.sample-header .tagline { + font-style: italic; +} + +.sample-footer { + border: #005a9c solid 2px; + background: #005a9c; + font-family: serif; + color: white; + font-style: italic; + padding-left: 1em; +} diff --git a/examples/disclosure/disclosure-faq.html b/examples/disclosure/disclosure-faq.html index 91a6ea95b3..6e325980d9 100644 --- a/examples/disclosure/disclosure-faq.html +++ b/examples/disclosure/disclosure-faq.html @@ -38,8 +38,9 @@

Example Disclosure (Show/Hide) for Answers to Frequently Asked Questions

Example Disclosure (Show/Hide) for an Image Description
  • - Example Disclosure for Navigation Menus + Example Disclosure Navigation Menu
  • +
  • Example Disclosure Navigation Menu with Top-Level Links
  • diff --git a/examples/disclosure/disclosure-img-long-description.html b/examples/disclosure/disclosure-img-long-description.html index 71e978b13f..fbf5a456a0 100644 --- a/examples/disclosure/disclosure-img-long-description.html +++ b/examples/disclosure/disclosure-img-long-description.html @@ -37,8 +37,9 @@

    Example Disclosure (Show/Hide) for Image Description

    Example Disclosure (Show/Hide) for Answers to Frequently Asked Questions
  • - Example Disclosure for Navigation Menus + Example Disclosure Navigation Menu
  • +
  • Example Disclosure Navigation Menu with Top-Level Links
  • diff --git a/examples/disclosure/disclosure-navigation-hybrid.html b/examples/disclosure/disclosure-navigation-hybrid.html new file mode 100644 index 0000000000..55a92343a2 --- /dev/null +++ b/examples/disclosure/disclosure-navigation-hybrid.html @@ -0,0 +1,392 @@ + + + + +Example Disclosure Navigation Menu with Top-Level Links | WAI-ARIA Authoring Practices 1.2 + + + + + + + + + + + + + + + +
    +

    Example Disclosure Navigation Menu with Top-Level Links

    +
    +

    + Although this example uses the word "menu" in the colloquial sense to refer to a set of navigation links, it does not use the WAI-ARIA menu role. + That is because the menu and menubar roles require complex functionality, such as composite widget focus management and first-character navigation, that is unnecessary for typical site navigation. +

    +
    + +

    + The following example demonstrates using the + disclosure design pattern + to show and hide dropdown lists of links in a navigation bar for a mythical university web site. + Unlike the other disclosure navigation menu example, this example includes top-level links alongside the disclosure buttons. +

    +

    Similar examples include:

    + + +

    Example Usage Options

    +

    + This example demonstrates two different ways of implementing keyboard support. + Toggle between them with the following checkbox. +

    + + +
    +
    +

    Example

    +
    + +
    +
    +
    Mythical University
    +
    Using a disclosure + link pattern for navigation
    +
    + +
    +

    Home

    +

    Sample content section. Activating a link above will update and navigate to this region.

    +
    +
    + Mythical University footer information +
    +
    + +
    + +
    +

    Accessibility Features

    +
      +
    1. Since this set of links and disclosure buttons provides navigation for the mythical university web site, the list that contains them is wrapped in a navigation landmark named Mythical University.
    2. +
    3. + The semantics of the list structure communicate the hierarchy of the navigation system to assistive technology users. + The top-level list has three items where each item contains a top-level link and an associated disclosure button. + The set of links controlled by a disclosure button is in a list nested inside the list item that contains the button. +
    4. +
    5. + If a dropdown is open and focus is inside the navigation region, pressing Esc will close the dropdown. + Moving focus out of the navigation region also closes an open dropdown. +
    6. +
    7. Optional navigation keys (Arrow keys, Home, and End): +
        +
      1. Enabling the optional arrow key support prevents the default page scroll behavior when focus is contained within the navigation region.
      2. +
      3. + Optional navigation key support is primarily for the benefit of keyboard users who are not running a screen reader. + Screen readers that have both reading and interaction modes intercept these navigation keys and do not pass them to the page when the screen reader is in reading mode. + When interacting with this example, such screen readers are typically in reading mode because this example does not use a widget role that triggers screen reader interaction mode. +
      4. +
      5. + If implemented, the optional navigation keys supplement, but do not replace, tabbing among buttons and links. + This is because the buttons and links are not contained by an element with a widget role, such as grid, that is expected to occupy only one stop in the page Tab sequence and manage focus for all its descendants. +
      6. +
      +
    8. +
    +
    + +
    +

    Keyboard Support

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    KeyFunction
    + Tab
    + Shift + Tab +
    Move keyboard focus among top-level links and buttons, and if a dropdown is open, through links in the dropdown.
    + Space or
    + Enter +
    +
      +
    • If focus is on a disclosure button, activates the button, which toggles the visibility of the dropdown.
    • +
    • If focus is on a link: +
        +
      • If any link has aria-current set, removes it.
      • +
      • Sets aria-current="page" on the focused link.
      • +
      • Activates the focused link.
      • +
      +
    • +
    +
    + Escape + If a dropdown is open, closes it and sets focus on the button that controls that dropdown.
    + Down Arrow or
    + Right Arrow
    + (Optional) +
    +
      +
    • If focus is on a top-level link or button with a collapsed dropdown, and it is not the last top-level item, moves focus to the next top-level link or button.
    • +
    • if focus is on a top-level button and its dropdown is expanded, moves focus to the first link in the dropdown.
    • +
    • If focus is on a link within an expanded dropdown, and it is not the last link, moves focus to the next link.
    • +
    +
    + Up Arrow or
    + Left Arrow
    + (Optional) +
    +
      +
    • If focus is on a top-level link or button, and it is not the first item, moves focus to the previous link or button.
    • +
    • If focus is on a link within an expanded dropdown, and it is not the first link, moves focus to the previous link.
    • +
    +
    + Home (Optional) + +
      +
    • If focus is on a top-level link button, and it is not the first item, moves focus to the first item.
    • +
    • If focus is on a link within an expanded dropdown, and it is not the first link, moves focus to the first link.
    • +
    +
    + End (Optional) + +
      +
    • If focus is on a top-level link or button, and it is not the last item, moves focus to the last item.
    • +
    • If focus is on a link within an expanded dropdown, and it is not the last link, moves focus to the last link.
    • +
    +
    +
    + +
    +

    Role, Property, State, and Tabindex Attributes

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    RoleAttributeElementUsage
    + aria-controls="IDREF" + + button + + Indicates that the disclosure button controls visibility of the container identified by the IDREF value. +
    + aria-expanded="false" + + button + +
      +
    • + Indicates that the container controlled by the disclosure button is hidden. +
    • +
    • + CSS attribute selectors (e.g. [aria-expanded="false"]) are + used to synchronize the visual states with the value of the aria-expanded + attribute. +
    • +
    +
    + aria-expanded="true" + + button + +
      +
    • + Indicates that the container controlled by the disclosure button is visible. +
    • +
    • + CSS attribute selectors (e.g. [aria-expanded="true"]) are + used to synchronize the visual states with the value of the aria-expanded + attribute. +
    • +
    +
    + aria-current="page" + + a + Indicates that the page referenced by the link is currently displayed.
    +
    + +
    +

    Javascript and CSS Source Code

    + + +
    + +
    +

    HTML Source Code

    + +
    + + +
    +
    + + + diff --git a/examples/disclosure/disclosure-navigation.html b/examples/disclosure/disclosure-navigation.html index 6b4040a532..e4ce2c5253 100644 --- a/examples/disclosure/disclosure-navigation.html +++ b/examples/disclosure/disclosure-navigation.html @@ -2,7 +2,7 @@ -Example Disclosure for Navigation Menus | WAI-ARIA Authoring Practices 1.2 +Example Disclosure Navigation Menu | WAI-ARIA Authoring Practices 1.2 @@ -26,18 +26,24 @@
    -

    Example Disclosure for Navigation Menus

    -

    - The following example demonstrates using the - disclosure design pattern - to show and hide dropdown lists of links in a navigation bar for a mythical university web site. - Each disclosure button represents a section of the web site, and expanding it shows a list of links to pages within that section. -

    -

    Note: Although this example uses the word menu in the colloquial sense to refer to a set of navigation links, it does not use the WAI-ARIA menu role.

    -

    Similar examples include:

    +

    Example Disclosure Navigation Menu

    +
    +

    + Although this example uses the word "menu" in the colloquial sense to refer to a set of navigation links, it does not use the WAI-ARIA menu role. + That is because the menu and menubar roles require complex functionality, such as composite widget focus management and first-character navigation, that is unnecessary for typical site navigation. +

    +
    +

    + The following example demonstrates using the + disclosure design pattern + to show and hide dropdown lists of links in a navigation bar for a mythical university web site. + Each disclosure button represents a section of the web site, and expanding it shows a list of links to pages within that section. +

    +

    Similar examples include:

    Example Usage Options

    diff --git a/examples/disclosure/js/disclosureMenu.js b/examples/disclosure/js/disclosureMenu.js index d6ec32bae6..106b75a318 100644 --- a/examples/disclosure/js/disclosureMenu.js +++ b/examples/disclosure/js/disclosureMenu.js @@ -7,158 +7,176 @@ 'use strict'; -var DisclosureNav = function (domNode) { - this.rootNode = domNode; - this.triggerNodes = []; - this.controlledNodes = []; - this.openIndex = null; - this.useArrowKeys = true; -}; - -DisclosureNav.prototype.init = function () { - var buttons = this.rootNode.querySelectorAll( - 'button[aria-expanded][aria-controls]' - ); - for (var i = 0; i < buttons.length; i++) { - var button = buttons[i]; - var menu = button.parentNode.querySelector('ul'); - if (menu) { - // save ref to button and controlled menu - this.triggerNodes.push(button); - this.controlledNodes.push(menu); - - // collapse menus - button.setAttribute('aria-expanded', 'false'); - this.toggleMenu(menu, false); - - // attach event listeners - menu.addEventListener('keydown', this.handleMenuKeyDown.bind(this)); - button.addEventListener('click', this.handleButtonClick.bind(this)); - button.addEventListener('keydown', this.handleButtonKeyDown.bind(this)); - } - } +class DisclosureNav { + constructor(domNode) { + this.rootNode = domNode; + this.controlledNodes = []; + this.openIndex = null; + this.useArrowKeys = true; + this.topLevelNodes = [ + ...this.rootNode.querySelectorAll( + '.main-link, button[aria-expanded][aria-controls]' + ), + ]; + + this.topLevelNodes.forEach((node) => { + // handle button + menu + if ( + node.tagName.toLowerCase() === 'button' && + node.hasAttribute('aria-controls') + ) { + const menu = node.parentNode.querySelector('ul'); + if (menu) { + // save ref controlled menu + this.controlledNodes.push(menu); + + // collapse menus + node.setAttribute('aria-expanded', 'false'); + this.toggleMenu(menu, false); + + // attach event listeners + menu.addEventListener('keydown', this.onMenuKeyDown.bind(this)); + node.addEventListener('click', this.onButtonClick.bind(this)); + node.addEventListener('keydown', this.onButtonKeyDown.bind(this)); + } + } + // handle links + else { + this.controlledNodes.push(null); + node.addEventListener('keydown', this.onLinkKeyDown.bind(this)); + } + }); - this.rootNode.addEventListener('focusout', this.handleBlur.bind(this)); -}; + this.rootNode.addEventListener('focusout', this.onBlur.bind(this)); + } -DisclosureNav.prototype.toggleMenu = function (domNode, show) { - if (domNode) { - domNode.style.display = show ? 'block' : 'none'; + controlFocusByKey(keyboardEvent, nodeList, currentIndex) { + switch (keyboardEvent.key) { + case 'ArrowUp': + case 'ArrowLeft': + keyboardEvent.preventDefault(); + if (currentIndex > -1) { + var prevIndex = Math.max(0, currentIndex - 1); + nodeList[prevIndex].focus(); + } + break; + case 'ArrowDown': + case 'ArrowRight': + keyboardEvent.preventDefault(); + if (currentIndex > -1) { + var nextIndex = Math.min(nodeList.length - 1, currentIndex + 1); + nodeList[nextIndex].focus(); + } + break; + case 'Home': + keyboardEvent.preventDefault(); + nodeList[0].focus(); + break; + case 'End': + keyboardEvent.preventDefault(); + nodeList[nodeList.length - 1].focus(); + break; + } } -}; -DisclosureNav.prototype.toggleExpand = function (index, expanded) { - // close open menu, if applicable - if (this.openIndex !== index) { + // public function to close open menu + close() { this.toggleExpand(this.openIndex, false); } - // handle menu at called index - if (this.triggerNodes[index]) { - this.openIndex = expanded ? index : null; - this.triggerNodes[index].setAttribute('aria-expanded', expanded); - this.toggleMenu(this.controlledNodes[index], expanded); - } -}; - -DisclosureNav.prototype.controlFocusByKey = function ( - keyboardEvent, - nodeList, - currentIndex -) { - switch (keyboardEvent.key) { - case 'ArrowUp': - case 'ArrowLeft': - keyboardEvent.preventDefault(); - if (currentIndex > -1) { - var prevIndex = Math.max(0, currentIndex - 1); - nodeList[prevIndex].focus(); - } - break; - case 'ArrowDown': - case 'ArrowRight': - keyboardEvent.preventDefault(); - if (currentIndex > -1) { - var nextIndex = Math.min(nodeList.length - 1, currentIndex + 1); - nodeList[nextIndex].focus(); - } - break; - case 'Home': - keyboardEvent.preventDefault(); - nodeList[0].focus(); - break; - case 'End': - keyboardEvent.preventDefault(); - nodeList[nodeList.length - 1].focus(); - break; + onBlur(event) { + var menuContainsFocus = this.rootNode.contains(event.relatedTarget); + if (!menuContainsFocus && this.openIndex !== null) { + this.toggleExpand(this.openIndex, false); + } } -}; -/* Event Handlers */ -DisclosureNav.prototype.handleBlur = function (event) { - var menuContainsFocus = this.rootNode.contains(event.relatedTarget); - if (!menuContainsFocus && this.openIndex !== null) { - this.toggleExpand(this.openIndex, false); + onButtonClick(event) { + var button = event.target; + var buttonIndex = this.topLevelNodes.indexOf(button); + var buttonExpanded = button.getAttribute('aria-expanded') === 'true'; + this.toggleExpand(buttonIndex, !buttonExpanded); } -}; -DisclosureNav.prototype.handleButtonKeyDown = function (event) { - var targetButtonIndex = this.triggerNodes.indexOf(document.activeElement); + onButtonKeyDown(event) { + var targetButtonIndex = this.topLevelNodes.indexOf(document.activeElement); - // close on escape - if (event.key === 'Escape') { - this.toggleExpand(this.openIndex, false); - } + // close on escape + if (event.key === 'Escape') { + this.toggleExpand(this.openIndex, false); + } + + // move focus into the open menu if the current menu is open + else if ( + this.useArrowKeys && + this.openIndex === targetButtonIndex && + event.key === 'ArrowDown' + ) { + event.preventDefault(); + this.controlledNodes[this.openIndex].querySelector('a').focus(); + } - // move focus into the open menu if the current menu is open - else if ( - this.useArrowKeys && - this.openIndex === targetButtonIndex && - event.key === 'ArrowDown' - ) { - event.preventDefault(); - this.controlledNodes[this.openIndex].querySelector('a').focus(); + // handle arrow key navigation between top-level buttons, if set + else if (this.useArrowKeys) { + this.controlFocusByKey(event, this.topLevelNodes, targetButtonIndex); + } } - // handle arrow key navigation between top-level buttons, if set - else if (this.useArrowKeys) { - this.controlFocusByKey(event, this.triggerNodes, targetButtonIndex); + onLinkKeyDown(event) { + var targetLinkIndex = this.topLevelNodes.indexOf(document.activeElement); + + // handle arrow key navigation between top-level buttons, if set + if (this.useArrowKeys) { + this.controlFocusByKey(event, this.topLevelNodes, targetLinkIndex); + } } -}; - -DisclosureNav.prototype.handleButtonClick = function (event) { - var button = event.target; - var buttonIndex = this.triggerNodes.indexOf(button); - var buttonExpanded = button.getAttribute('aria-expanded') === 'true'; - this.toggleExpand(buttonIndex, !buttonExpanded); -}; - -DisclosureNav.prototype.handleMenuKeyDown = function (event) { - if (this.openIndex === null) { - return; + + onMenuKeyDown(event) { + if (this.openIndex === null) { + return; + } + + var menuLinks = Array.prototype.slice.call( + this.controlledNodes[this.openIndex].querySelectorAll('a') + ); + var currentIndex = menuLinks.indexOf(document.activeElement); + + // close on escape + if (event.key === 'Escape') { + this.topLevelNodes[this.openIndex].focus(); + this.toggleExpand(this.openIndex, false); + } + + // handle arrow key navigation within menu links, if set + else if (this.useArrowKeys) { + this.controlFocusByKey(event, menuLinks, currentIndex); + } } - var menuLinks = Array.prototype.slice.call( - this.controlledNodes[this.openIndex].querySelectorAll('a') - ); - var currentIndex = menuLinks.indexOf(document.activeElement); + toggleExpand(index, expanded) { + // close open menu, if applicable + if (this.openIndex !== index) { + this.toggleExpand(this.openIndex, false); + } - // close on escape - if (event.key === 'Escape') { - this.triggerNodes[this.openIndex].focus(); - this.toggleExpand(this.openIndex, false); + // handle menu at called index + if (this.topLevelNodes[index]) { + this.openIndex = expanded ? index : null; + this.topLevelNodes[index].setAttribute('aria-expanded', expanded); + this.toggleMenu(this.controlledNodes[index], expanded); + } } - // handle arrow key navigation within menu links, if set - else if (this.useArrowKeys) { - this.controlFocusByKey(event, menuLinks, currentIndex); + toggleMenu(domNode, show) { + if (domNode) { + domNode.style.display = show ? 'block' : 'none'; + } } -}; -// switch on/off arrow key navigation -DisclosureNav.prototype.updateKeyControls = function (useArrowKeys) { - this.useArrowKeys = useArrowKeys; -}; + updateKeyControls(useArrowKeys) { + this.useArrowKeys = useArrowKeys; + } +} /* Initialize Disclosure Menus */ @@ -170,33 +188,41 @@ window.addEventListener( for (var i = 0; i < menus.length; i++) { disclosureMenus[i] = new DisclosureNav(menus[i]); - disclosureMenus[i].init(); } // listen to arrow key checkbox var arrowKeySwitch = document.getElementById('arrow-behavior-switch'); - arrowKeySwitch.addEventListener('change', function () { - var checked = arrowKeySwitch.checked; - for (var i = 0; i < disclosureMenus.length; i++) { - disclosureMenus[i].updateKeyControls(checked); - } - }); - - // fake link behavior - var links = document.querySelectorAll('[href="#mythical-page-content"]'); - var examplePageHeading = document.getElementById('mythical-page-heading'); - for (var k = 0; k < links.length; k++) { - links[k].addEventListener('click', function (event) { - var pageTitle = event.target.innerText; - examplePageHeading.innerText = pageTitle; - - // handle aria-current - for (var n = 0; n < links.length; n++) { - links[n].removeAttribute('aria-current'); + if (arrowKeySwitch) { + arrowKeySwitch.addEventListener('change', function () { + var checked = arrowKeySwitch.checked; + for (var i = 0; i < disclosureMenus.length; i++) { + disclosureMenus[i].updateKeyControls(checked); } - this.setAttribute('aria-current', 'page'); }); } + + // fake link behavior + disclosureMenus.forEach((disclosureNav, i) => { + var links = menus[i].querySelectorAll('[href="#mythical-page-content"]'); + var examplePageHeading = document.getElementById('mythical-page-heading'); + for (var k = 0; k < links.length; k++) { + // The codepen export script updates the internal link href with a full URL + // we're just manually fixing that behavior here + links[k].href = '#mythical-page-content'; + + links[k].addEventListener('click', (event) => { + // change the heading text to fake a page change + var pageTitle = event.target.innerText; + examplePageHeading.innerText = pageTitle; + + // handle aria-current + for (var n = 0; n < links.length; n++) { + links[n].removeAttribute('aria-current'); + } + event.target.setAttribute('aria-current', 'page'); + }); + } + }); }, false ); diff --git a/examples/index.html b/examples/index.html index 727de014bb..c0df04b1e9 100644 --- a/examples/index.html +++ b/examples/index.html @@ -456,7 +456,8 @@

    Examples By Properties and States

  • Editable Combobox with Grid Popup
  • Disclosure (Show/Hide) for Answers to Frequently Asked Questions
  • Disclosure (Show/Hide) for Image Description
  • -
  • Disclosure for Navigation Menus
  • +
  • Disclosure Navigation Menu with Top-Level Links
  • +
  • Disclosure Navigation Menu
  • Actions Menu Button Using aria-activedescendant
  • Actions Menu Button Using element.focus()
  • Navigation Menu Button
  • @@ -470,7 +471,8 @@

    Examples By Properties and States

    aria-current @@ -512,7 +514,8 @@

    Examples By Properties and States

  • Editable Combobox with Grid Popup
  • Disclosure (Show/Hide) for Answers to Frequently Asked Questions
  • Disclosure (Show/Hide) for Image Description
  • -
  • Disclosure for Navigation Menus
  • +
  • Disclosure Navigation Menu with Top-Level Links
  • +
  • Disclosure Navigation Menu
  • Collapsible Dropdown Listbox
  • Actions Menu Button Using aria-activedescendant
  • Actions Menu Button Using element.focus()
  • diff --git a/examples/menubar/menubar-navigation.html b/examples/menubar/menubar-navigation.html index c18d0f249b..c075206aab 100644 --- a/examples/menubar/menubar-navigation.html +++ b/examples/menubar/menubar-navigation.html @@ -35,7 +35,7 @@

    Navigation Menubar Example

  • The menubar pattern requires complex functionality that is unnecessary for typical site navigation that is styled to look like a menubar with expandable sections or fly outs.
  • A pattern more suited for typical site navigation with expandable groups of links is the disclosure pattern. - For an example, see Example Disclosure for Navigation Menus + For an example, see Example Disclosure Navigation Menu.
  • @@ -50,7 +50,8 @@

    Navigation Menubar Example

    Similar examples include:

    diff --git a/examples/treeview/treeview-navigation.html b/examples/treeview/treeview-navigation.html index a50bc80e23..5973b5430e 100644 --- a/examples/treeview/treeview-navigation.html +++ b/examples/treeview/treeview-navigation.html @@ -36,7 +36,7 @@

    Navigation Treeview Example

    The below example demonstrates how the Treeview Design Pattern can be used to build a navigation tree for a set of hierarchically organized web pages. - It illustrates navigation of a mythical university web site that is comparable to the navigation illustrated in the Example of Disclosure for Navigation Menus. + It illustrates navigation of a mythical university web site that is comparable to the navigation illustrated in the Example Disclosure Navigation Menu. As noted above, the disclosure pattern is better suited for most web sites because few sites need the additional keyboard functionality required to support the ARIA tree role.

    diff --git a/test/tests/disclosure_navigation_hybrid.js b/test/tests/disclosure_navigation_hybrid.js new file mode 100644 index 0000000000..c3b63830f8 --- /dev/null +++ b/test/tests/disclosure_navigation_hybrid.js @@ -0,0 +1,429 @@ +const { ariaTest } = require('..'); +const { By, Key } = require('selenium-webdriver'); +const assertAriaControls = require('../util/assertAriaControls'); +const assertAttributeValues = require('../util/assertAttributeValues'); +const assertTabOrder = require('../util/assertTabOrder'); +const assertHasFocus = require('../util/assertHasFocus'); + +const exampleFile = 'disclosure/disclosure-navigation-hybrid.html'; + +const ex = { + buttonSelector: '#exTest button', + menuSelector: '#exTest > li > ul', + linkSelector: '#exTest > li a', + topItemSelector: '#exTest > li > a, #exTest button', + buttonSelectors: [ + '#exTest > li:nth-child(1) button', + '#exTest > li:nth-child(2) button', + '#exTest > li:nth-child(3) button', + ], + topItemSelectors: [ + '#exTest > li:nth-child(1) > a', + '#exTest > li:nth-child(1) button', + '#exTest > li:nth-child(2) > a', + '#exTest > li:nth-child(2) button', + '#exTest > li:nth-child(3) > a', + '#exTest > li:nth-child(3) button', + ], + menuSelectors: [ + '#exTest > li:nth-child(1) ul', + '#exTest > li:nth-child(2) ul', + '#exTest > li:nth-child(3) ul', + ], +}; + +// Attributes + +ariaTest( + '"aria-controls" attribute on button', + exampleFile, + 'button-aria-controls', + async (t) => { + await assertAriaControls(t, ex.buttonSelector); + } +); + +ariaTest( + '"aria-expanded" attribute on button', + exampleFile, + 'button-aria-expanded', + async (t) => { + await assertAttributeValues(t, ex.buttonSelector, 'aria-expanded', 'false'); + + let buttons = await t.context.queryElements(t, ex.buttonSelector); + let menus = await t.context.queryElements(t, ex.menuSelector); + for (let i = buttons.length - 1; i >= 0; i--) { + await buttons[i].click(); + t.true( + await menus[i].isDisplayed(), + 'Each dropdown menu should display after clicking its trigger' + ); + await assertAttributeValues( + t, + ex.buttonSelectors[i], + 'aria-expanded', + 'true' + ); + } + } +); + +ariaTest( + '"aria-current" attribute on links', + exampleFile, + 'link-aria-current', + async (t) => { + const buttons = await t.context.queryElements(t, ex.buttonSelector); + const menus = await t.context.queryElements(t, ex.menuSelector); + + for (let b = 0; b < buttons.length; b++) { + const links = await t.context.queryElements(t, 'a', menus[b]); + + if (links.length > 0) { + await buttons[b].click(); + await links[0].click(); + + t.is( + await links[0].getAttribute('aria-current'), + 'page', + `after clicking link at index 0 on menu ${b}, aria-current should be set to page` + ); + + let ariaCurrentLinks = await t.context.queryElements( + t, + `${ex.linkSelector}[aria-current="page"]` + ); + + t.is( + ariaCurrentLinks.length, + 1, + `after clicking link at index 0 on menu ${b}, only one link should have aria-current set` + ); + } + } + } +); + +// Keys + +ariaTest( + 'TAB should move focus between top-level items', + exampleFile, + 'key-tab', + async (t) => { + await assertTabOrder(t, ex.topItemSelectors); + } +); + +ariaTest( + 'key ENTER expands dropdown', + exampleFile, + 'key-enter-space', + async (t) => { + const buttons = await t.context.queryElements(t, ex.buttonSelector); + const menus = await t.context.queryElements(t, ex.menuSelector); + + for (let i = buttons.length - 1; i >= 0; i--) { + await buttons[i].sendKeys(Key.ENTER); + await assertAttributeValues( + t, + ex.buttonSelectors[i], + 'aria-expanded', + 'true' + ); + t.true( + await menus[i].isDisplayed(), + 'Dropdown menu should display sending ENTER to its trigger' + ); + + await buttons[i].sendKeys(Key.ENTER); + await assertAttributeValues( + t, + ex.buttonSelectors[i], + 'aria-expanded', + 'false' + ); + t.false( + await menus[i].isDisplayed(), + 'Dropdown menu should close after sending ENTER twice to its trigger' + ); + } + } +); + +ariaTest( + 'key SPACE expands dropdown', + exampleFile, + 'key-enter-space', + async (t) => { + const buttons = await t.context.queryElements(t, ex.buttonSelector); + const menus = await t.context.queryElements(t, ex.menuSelector); + + for (let i = buttons.length - 1; i >= 0; i--) { + await buttons[i].sendKeys(Key.SPACE); + await assertAttributeValues( + t, + ex.buttonSelectors[i], + 'aria-expanded', + 'true' + ); + t.true( + await menus[i].isDisplayed(), + 'Dropdown menu should display sending SPACE to its trigger' + ); + + await buttons[i].sendKeys(Key.SPACE); + await assertAttributeValues( + t, + ex.buttonSelectors[i], + 'aria-expanded', + 'false' + ); + t.false( + await menus[i].isDisplayed(), + 'Dropdown menu should close after sending SPACE twice to its trigger' + ); + } + } +); + +ariaTest('key ESCAPE closes dropdown', exampleFile, 'key-escape', async (t) => { + const button = await t.context.session.findElement( + By.css(ex.buttonSelectors[0]) + ); + const menu = await t.context.session.findElement(By.css(ex.menuSelectors[0])); + const firstLink = await t.context.session.findElement( + By.css(`${ex.menuSelectors[0]} a`) + ); + + await button.click(); + t.true(await menu.isDisplayed(), 'Dropdown menu is displayed on click'); + + await firstLink.sendKeys(Key.ESCAPE); + await assertAttributeValues( + t, + ex.buttonSelectors[0], + 'aria-expanded', + 'false' + ); + t.false( + await menu.isDisplayed(), + 'Dropdown menu should close after sending ESCAPE to the menu' + ); +}); + +ariaTest( + 'arrow keys move focus between top-level items', + exampleFile, + 'key-arrows', + async (t) => { + const items = await t.context.queryElements(t, ex.topItemSelector); + + await items[0].sendKeys(Key.ARROW_RIGHT); + await assertHasFocus( + t, + ex.topItemSelectors[1], + 'right arrow moves focus from first to second item' + ); + + await items[0].sendKeys(Key.ARROW_DOWN); + await assertHasFocus( + t, + ex.topItemSelectors[1], + 'down arrow moves focus from first to second item' + ); + + await items[1].sendKeys(Key.ARROW_RIGHT); + await assertHasFocus( + t, + ex.topItemSelectors[2], + 'right arrow moves focus from second to third item' + ); + + await items[5].sendKeys(Key.ARROW_RIGHT); + await assertHasFocus( + t, + ex.topItemSelectors[5], + 'right arrow does not move focus from last item' + ); + + await items[0].sendKeys(Key.ARROW_LEFT); + await assertHasFocus( + t, + ex.topItemSelectors[0], + 'left arrow does not move focus from first item' + ); + + await items[1].sendKeys(Key.ARROW_LEFT); + await assertHasFocus( + t, + ex.topItemSelectors[0], + 'left arrow moves focus from second to first item' + ); + + await items[2].sendKeys(Key.ARROW_UP); + await assertHasFocus( + t, + ex.topItemSelectors[1], + 'up arrow moves focus from third to second item' + ); + + await items[5].sendKeys(Key.ARROW_LEFT); + await assertHasFocus( + t, + ex.topItemSelectors[4], + 'left arrow moves focus from last to second-to-last item' + ); + } +); + +ariaTest( + 'down arrow moves focus from button to open menu', + exampleFile, + 'key-arrows', + async (t) => { + const buttons = await t.context.queryElements(t, ex.buttonSelector); + const menu = await t.context.session.findElement( + By.css(ex.menuSelectors[0]) + ); + + // open menu + await buttons[0].click(); + await menu.isDisplayed(); + + await buttons[0].sendKeys(Key.ARROW_DOWN); + await assertHasFocus( + t, + `${ex.menuSelectors[0]} li:first-child a`, + 'down arrow moves focus to open menu' + ); + + await buttons[1].sendKeys(Key.ARROW_DOWN); + await assertHasFocus( + t, + ex.topItemSelectors[4], + "down arrow moves focus to next item if active button's menu is closed" + ); + } +); + +ariaTest( + 'home and end move focus to first and last items', + exampleFile, + 'key-home-end', + async (t) => { + const items = await t.context.queryElements(t, ex.topItemSelector); + + await items[3].sendKeys(Key.HOME); + await assertHasFocus( + t, + ex.topItemSelectors[0], + 'home key moves focus to first item' + ); + + await items[0].sendKeys(Key.END); + await assertHasFocus( + t, + ex.topItemSelectors[ex.topItemSelectors.length - 1], + 'end key moves focus to last item' + ); + } +); + +ariaTest( + 'arrow keys move focus between open menu links', + exampleFile, + 'key-arrows', + async (t) => { + const button = await t.context.session.findElement( + By.css(ex.buttonSelectors[0]) + ); + const menu = await t.context.session.findElement( + By.css(ex.menuSelectors[0]) + ); + const menuLinks = await t.context.queryElements( + t, + `${ex.menuSelectors[0]} a` + ); + + await button.click(); + await menu.isDisplayed(); + + await menuLinks[0].sendKeys(Key.ARROW_DOWN); + await assertHasFocus( + t, + `${ex.menuSelectors[0]} li:nth-child(2) a`, + 'down arrow moves focus from first to second link' + ); + + await menuLinks[0].sendKeys(Key.ARROW_RIGHT); + await assertHasFocus( + t, + `${ex.menuSelectors[0]} li:nth-child(2) a`, + 'right arrow moves focus from first to second link' + ); + + await menuLinks[2].sendKeys(Key.ARROW_DOWN); + await assertHasFocus( + t, + `${ex.menuSelectors[0]} li:last-child a`, + 'down arrow does not move focus from last link' + ); + + await menuLinks[0].sendKeys(Key.ARROW_UP); + await assertHasFocus( + t, + `${ex.menuSelectors[0]} li:nth-child(1) a`, + 'up arrow does not move focus from first link' + ); + + await menuLinks[1].sendKeys(Key.ARROW_LEFT); + await assertHasFocus( + t, + `${ex.menuSelectors[0]} li:nth-child(1) a`, + 'left arrow moves focus from second to first link' + ); + + await menuLinks[1].sendKeys(Key.ARROW_UP); + await assertHasFocus( + t, + `${ex.menuSelectors[0]} li:nth-child(1) a`, + 'up arrow moves focus from second to first link' + ); + } +); + +ariaTest( + 'home and end move focus to first and last open menu link', + exampleFile, + 'key-home-end', + async (t) => { + const button = await t.context.session.findElement( + By.css(ex.buttonSelectors[0]) + ); + const menu = await t.context.session.findElement( + By.css(ex.menuSelectors[0]) + ); + const menuLinks = await t.context.queryElements( + t, + `${ex.menuSelectors[0]} a` + ); + + await button.click(); + await menu.isDisplayed(); + + await menuLinks[1].sendKeys(Key.HOME); + await assertHasFocus( + t, + `${ex.menuSelectors[0]} li:nth-child(1) a`, + 'home key moves focus to first link' + ); + + await menuLinks[0].sendKeys(Key.END); + await assertHasFocus( + t, + `${ex.menuSelectors[0]} li:last-child a`, + 'end key moves focus to last link' + ); + } +);