Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(navigation-secondary): add roving tabindex controller #1225

Merged
merged 9 commits into from
Oct 16, 2023
5 changes: 5 additions & 0 deletions .changeset/fast-waves-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rhds/elements": patch
---

`<rh-navigation-secondary>`: improved keyboard navigation
92 changes: 82 additions & 10 deletions elements/rh-navigation-secondary/rh-navigation-secondary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators/custom-element.js';
import { property } from 'lit/decorators/property.js';
import { classMap } from 'lit/directives/class-map.js';
import { state } from 'lit/decorators/state.js';
import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js';

import { ComposedEvent } from '@patternfly/pfe-core';
import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js';
import { Logger } from '@patternfly/pfe-core/controllers/logger.js';

import '../../lib/elements/rh-context-provider/rh-context-provider.js';

import './rh-navigation-secondary-menu-section.js';
import './rh-navigation-secondary-overlay.js';

import { ComposedEvent } from '@patternfly/pfe-core';

import { RhNavigationSecondaryDropdown, SecondaryNavDropdownExpandEvent } from './rh-navigation-secondary-dropdown.js';

import { DirController } from '../../lib/DirController.js';
Expand All @@ -32,7 +35,6 @@ export type NavPalette = Extract<ColorPalette, (
)>;

import styles from './rh-navigation-secondary.css';
import { state } from 'lit/decorators/state.js';

/**
* The Secondary navigation is used to connect a series of pages together. It displays wayfinding content and links relevant to the page it is placed on. It should be used in conjunction with the [primary navigation](../navigation-primary).
Expand Down Expand Up @@ -67,6 +69,8 @@ export class RhNavigationSecondary extends LitElement {
@colorContextProvider()
@property({ reflect: true, attribute: 'color-palette' }) colorPalette: NavPalette = 'lighter';

@queryAssignedElements({ slot: 'nav' }) private _nav?: HTMLElement[];
zeroedin marked this conversation as resolved.
Show resolved Hide resolved

#logger = new Logger(this);

#logoCopy: HTMLElement | null = null;
Expand All @@ -82,6 +86,13 @@ export class RhNavigationSecondary extends LitElement {
/** Compact mode */
#compact = false;

#tabindex = new RovingTabindexController(this);

#rtiInit = false;

/** Navigation Items that should be initialized by Roving Tabindex */
#navItems: HTMLElement[] | undefined;

/**
* `mobileMenuExpanded` property is toggled when the mobile menu button is clicked,
* a focusout event occurs, or on an overlay click event. It also switches state
Expand Down Expand Up @@ -141,7 +152,7 @@ export class RhNavigationSecondary extends LitElement {
aria-expanded="${String(expanded) as 'true' | 'false'}"
@click="${this.#toggleMobileMenu}"><slot name="mobile-menu">Menu</slot></button>
<rh-context-provider color-palette="${dropdownPalette}">
<slot name="nav"></slot>
<slot name="nav" @slotchange="${this.#onSlotchange}"></slot>
<div id="cta" part="cta">
<slot name="cta"></slot>
</div>
Expand Down Expand Up @@ -216,24 +227,81 @@ export class RhNavigationSecondary extends LitElement {
*/
#onKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Escape':
if (this.#screenSize.matches.has('md')) {
case 'Escape': {
if (!this.#screenSize.matches.has('md')) {
this.mobileMenuExpanded = false;
this.shadowRoot?.querySelector('button')?.focus?.();
} else {
this.#allDropdowns()
.find(x => x.expanded)
?.querySelector('a')
?.focus();
this.#tabindex.activeItem?.focus();
}
this.close();
this.overlayOpen = false;
break;
}
case 'Tab':
this.#onTabEvent(event);
break;
default:
break;
}
}

#onTabEvent(event: KeyboardEvent) {
// target is the element we are leaving with tab press
const target = event.target as HTMLElement;
// get target parent dropdown
const dropdowns = this.#allDropdowns();
const dropdownParent = dropdowns.find(dropdown => dropdown.contains(target));
if (!dropdownParent) {
return;
}
const focusableChildren = this.#focusableChildElements(dropdownParent);
if (!focusableChildren) {
return;
}
if (event.shiftKey) {
const firstFocusable = focusableChildren[0] === target;
if (!firstFocusable) {
return;
} else {
this.close();
this.overlayOpen = false;
}
} else {
// is the target the last focusableChildren element in the dropdown
const lastFocusable = focusableChildren[focusableChildren.length - 1] === target;
if (!lastFocusable) {
return;
}
event.preventDefault();
this.close();
this.overlayOpen = false;
this.#tabindex.updateActiveItem(this.#tabindex.nextItem);
this.#tabindex.activeItem?.focus();
}
}

#onSlotchange() {
this._nav?.forEach(nav => {
this.#navItems = Array.from(
nav.querySelectorAll(':is(rh-navigation-secondary-dropdown, rh-secondary-nav-dropdown) > a, [slot="nav"] > li > a')
);
});
if (this.#rtiInit) {
this.#tabindex.updateItems(this.#navItems ?? []);
} else {
this.#tabindex.initItems(this.#navItems ?? []);
this.#rtiInit = true;
}
}

/* TODO: Abstract this out to a shareable function, should RTI handle something similar? */
#focusableChildElements(parent: HTMLElement): NodeListOf<HTMLElement> {
return parent.querySelectorAll(
'a, button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), details:not([disabled]), summary:not(:disabled)'
);
}

/**
* Gets all dropdowns and finds the element given and returns its index
*/
Expand Down Expand Up @@ -268,6 +336,10 @@ export class RhNavigationSecondary extends LitElement {
}
const dropdown = this.#dropdownByIndex(index);
if (dropdown && RhNavigationSecondary.isDropdown(dropdown)) {
const link = dropdown.querySelector('a');
if (link) {
this.#tabindex.updateActiveItem(link);
}
this.#openDropdown(dropdown);
}
}
Expand Down