Skip to content

Commit

Permalink
Merge pull request #728 from weather-gov/eg-681-tabbed-nav
Browse files Browse the repository at this point in the history
Tabbed Navigation
  • Loading branch information
eric-gade authored Feb 2, 2024
2 parents cb75740 + 286e35d commit 45d7455
Show file tree
Hide file tree
Showing 12 changed files with 392 additions and 23 deletions.
6 changes: 6 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ module.exports = {
"no-console": 0,
},
},
{
files: ["web/themes/new_weather_theme/assets/js/components/**/*.js"],
rules: {
"class-methods-use-this": 0,
},
},
{
files: [
"tests/a11y/**/*.js",
Expand Down
159 changes: 159 additions & 0 deletions tests/e2e/cypress/e2e/tabbed-nav.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/* eslint no-unused-expressions: off */
describe("<tabbed-nav> component tests", () => {
describe("Alert link interaction", () => {
beforeEach(() => {
cy.visit("/local/TST/10/10");
});

describe("Basic tabbed nav tests", () => {
let tabbedNav;
it("Can find the tabbed-nav element on the page", () => {
tabbedNav = cy.get("tabbed-nav").then((element) => {
tabbedNav = element;
expect(tabbedNav).to.exist;
});
});

it("Knows the tabbed-nav is a defined custom element", () => {
cy.window().then((win) => {
const customEl = win.customElements.get("tabbed-nav");
expect(customEl).to.exist;
expect(tabbedNav.get(0).isConnected).to.be.true;
});
});

it("There is a default tab selected and its content is visible", () => {
cy.get('tabbed-nav .tab-button[aria-expanded="true"]')
.as("selectedButton")
.should("exist")
.get("@selectedButton")
.invoke("attr", "data-selected")
.should("exist")
.get("@selectedButton")
.then((btn) => {
const tabName = btn.attr("data-tab-name");
cy.get(`#${tabName}`).should("exist").should("be.visible");
});
});

it("The content of un-selected tabs should not be visible", () => {
cy.get(`tabbed-nav .tab-button:not([data-selected])`)
.as("unselectedBtn")
.should("exist")
.get("@unselectedBtn")
.then((btn) => {
const tabName = btn.attr("data-tab-name");
cy.get(`#${tabName}`).should("exist").should("not.be.visible");
});
});

it("Clicking an unselected tab should show it", () => {
cy.get("tabbed-nav .tab-button:last").as("lastBtn").click();
cy.get("@lastBtn").then((btn) => {
const tabName = btn.attr("data-tab-name");
cy.get(`#${tabName}`)
.should("be.visible")
.invoke("attr", "data-selected")
.should("exist");
});
cy.get("@lastBtn")
.invoke("attr", "data-selected")
.should("exist")
.get("@lastBtn")
.invoke("attr", "aria-expanded")
.should("eq", "true");
});

it("Clicking an unselected tab hides the other tabs", () => {
cy.get("tabbed-nav .tab-button:last").click();
cy.get("tabbed-nav .tab-button:not(:last)").each((btn) => {
const tabName = btn.attr("data-tab-name");
cy.get(`#${tabName}`)
.as("tabContent")
.should("not.be.visible")
.invoke("attr", "data-selected")
.should("not.exist");
});
});
});

describe("Intercepts click events on above-the-fold alert links", () => {
it("Clicking an alert link opens the accordion for that link and scrolls to it", () => {
cy.get("weathergov-alert-list a").each((anchorEl) => {
const alertId = anchorEl.attr("href").split("#")[1];
cy.wrap(anchorEl).click();
cy.get(`#${alertId}`).as("alertEl")
.find(".usa-accordion__content")
.invoke("attr", "hidden")
.should("not.exist")
.get("@alertEl")
.find("button.usa-accordion__button")
.invoke("attr", "aria-expanded")
.should("eq", "true")
.get("@alertEl")
.should("be.visible");
});
});

it("Opens the alerts tab (when not selected) when an alert link is clicked", () => {
cy
// Click on another tab that is not the alerts
// tab button
.get('tabbed-nav .tab-button:not([data-tab-name="alerts"]):first')
.click();
// Get the third alert link and click it
cy.get("weathergov-alert-list a:last").click();
// Get the alerts tab button and make sure it's now
// selected
cy.get('tabbed-nav .tab-button[data-tab-name="alerts"]')
.as("alertsTabBtn")
.invoke("attr", "aria-expanded")
.should("eq", "true")
.get("@alertsTabBtn")
.invoke("attr", "data-selected")
.should("exist")
// Get the alerts tab content area and make sure
// it is showing
.get("#alerts")
.should("be.visible")
.invoke("attr", "data-selected")
.should("exist");
});
});
});

describe("Initial page load with hash", () => {
it("Navigates to the correct alert accordion and opens it if hash present", () => {
const alertId = "alert_2";
cy.visit(`/local/TST/10/10#${alertId}`);
cy
.get(`#${alertId}`).as("alertEl")
.find(".usa-accordion__content")
.invoke("attr", "hidden")
.should("not.exist")
.get("@alertEl")
.find("button.usa-accordion__button")
.invoke("attr", "aria-expanded")
.should("eq", "true")
.get("@alertEl")
.should("be.visible");
});

["hourly", "daily"].forEach(tabName => {
it(`Acticates the ${tabName} tab if the hash for it is present`, () => {
cy.visit(`/local/TST/10/10#${tabName}`);
cy
.get(`.tab-button[data-tab-name="${tabName}"]`).as("tabButton")
.invoke("attr", "data-selected")
.should("exist")
.get("@tabButton")
.invoke("attr", "aria-expanded")
.should("eq", "true")
.get(`#${tabName}`)
.should("be.visible")
.invoke("attr", "data-selected")
.should("exist");
});
});
});
});
2 changes: 1 addition & 1 deletion web/themes/new_weather_theme/assets/css/styles.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion web/themes/new_weather_theme/assets/css/styles.css.map

Large diffs are not rendered by default.

146 changes: 146 additions & 0 deletions web/themes/new_weather_theme/assets/js/components/TabbedNavigator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
class TabbedNavigator extends HTMLElement {
constructor() {
super();

// Bind this context to methods that need it
this.handleAlertAnchorClick = this.handleAlertAnchorClick.bind(this);
this.handleTabButtonClick = this.handleTabButtonClick.bind(this);
this.switchToTab = this.switchToTab.bind(this);
this.scrollToAccordion = this.scrollToAccordion.bind(this);
}

connectedCallback() {
// If no tabs are selected by default, then select the first one
const selected = Array.from(
this.querySelectorAll(".tab-button[data-selected]"),
);
if (!selected.length) {
this.switchToTab(this.querySelector("button").dataset.tabName);
}

// The initial page load might contain a hash fragment
// referring either to content within a given tab
// (such as an alert) or a tab itself. We should handle
// these two cases.
this.navigateWithInitialHash();

// Intercept click events on Alert links at the
// top of the page and handle them in this component
Array.from(document.querySelectorAll("weathergov-alert-list a")).forEach(
(alertAnchor) => {
alertAnchor.addEventListener("click", this.handleAlertAnchorClick);
},
);

// Add needed event listeners
Array.from(this.querySelectorAll("button.tab-button")).forEach((button) => {
button.addEventListener("click", this.handleTabButtonClick);
});
}

disconnectedCallback() {
// Remove any event listeners
Array.from(this.querySelectorAll("button.tab-button")).forEach((button) => {
button.removeEventListener("click", this.handleTabButtonClick);
});
}

navigateWithInitialHash() {
const hash = new URL(window.location).hash;
if (!hash || hash === "") {
return;
}

const matchedTabButton = this.querySelector(
`[data-tab-name="${hash.replace("#", "")}"]`,
);
if (matchedTabButton) {
this.switchToTab(matchedTabButton.dataset.tabName);
matchedTabButton.parentElement.scrollIntoView();
return;
}

const childElement = this.querySelector(`${hash},tab-container, .tab-container ${hash}`);
if (childElement) {
const tabContainer = childElement.closest(".tab-container");
this.switchToTab(tabContainer.id);
if (childElement.matches(".usa-accordion")) {
this.toggleAccordion(childElement, true);
document.addEventListener("DOMContentLoaded", () => {
this.scrollToAccordion(childElement);
});
}
}
}

switchToTab(tabId) {
const activeElements = this.querySelectorAll("[data-selected]");
Array.from(activeElements).forEach((activeElement) => {
activeElement.removeAttribute("data-selected");
if (activeElement.hasAttribute("aria-expanded")) {
activeElement.setAttribute("aria-expanded", "false");
}
});
const tabButton = this.querySelector(`[data-tab-name="${tabId}"]`);
tabButton.setAttribute("data-selected", "");
tabButton.setAttribute("aria-expanded", "true");
const tabContainer = this.querySelector(`#${tabId}`);
tabContainer.setAttribute("data-selected", "");
}

handleTabButtonClick(event) {
this.switchToTab(event.target.dataset.tabName);
// Since this was an actual click, update the hash
// of the site to the tab button's id
window.history.replaceState(null, null, `#${event.target.dataset.tabName}`);
}

handleAlertAnchorClick(event) {
const hash = new URL(event.target.href).hash;
const accordionEl = this.querySelector(`${hash}.usa-accordion`);
if (accordionEl) {
// If we get here, then the element referred
// to by the href is a child of this tabbed
// navigator.
// We need to toggle to the correct tab pane
// to properly display and scroll to the element.
const tabContainer = accordionEl.closest(".tab-container");
this.switchToTab(tabContainer.id);
this.toggleAccordion(accordionEl, true);

// Because we use a sticky position on
// the tab button area, the normal browser
// scrolling will not display the proper position
// to the user. Instead, we have to roll our
// own scrolling method
this.scrollToAccordion(accordionEl);
event.preventDefault();
window.history.replaceState(null, null, hash);
}
}

toggleAccordion(accordionElement, on = true) {
const button = accordionElement.querySelector(
"button.usa-accordion__button",
);
const content = accordionElement.querySelector(".usa-accordion__content");

if (on) {
button.setAttribute("aria-expanded", "true");
content.removeAttribute("hidden");
} else {
button.setAttribute("aria-expanded", "false");
content.addAttribute("hidden", "");
}
}

scrollToAccordion(accordionElement) {
const accordionTop = accordionElement.getBoundingClientRect().top;
const buttonArea = this.querySelector(".tab-buttons");
const buttonAreaHeight = buttonArea.getBoundingClientRect().height;
const scrollY = accordionTop + window.scrollY - buttonAreaHeight;
window.scrollTo(0, scrollY);
}
}

window.customElements.define("tabbed-nav", TabbedNavigator);
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,22 @@ body {
height: 100%;
}

body {
/* the normalize.css we use sets
* this value to 'hidden', which will
* prevent the use of position sticky
* anywhere in the body
*/
overflow-x: clip;
}

/* Current Conditions
-------------------------------------
*/
.current-conditions-temp {
font-size: 56px;
}

/* Hourly Forecast Block Overrides
--------------------------------------
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
@use "uswds-core" as *;

tabbed-nav {
button.tab-button {
outline: none;
background-color: white;
border: none;
border-bottom: 2px solid transparent;
display: inline-block;
padding: 0;
color: rgb(0 133 202 / 100%);

@include u-font("body", "md");

&[data-selected] {
border-bottom: 2px solid rgb(0 133 202 / 100%);
}

&:focus {
outline-offset: units(1);
}
}

.tab-container {
display: none;

&[data-selected] {
display: block;
}
}
}
1 change: 1 addition & 0 deletions web/themes/new_weather_theme/assets/sass/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
@use "components/beta-banner";
@use "components/current-conditions";
@use "components/weather-story";
@use "components/tabbed-nav";
6 changes: 5 additions & 1 deletion web/themes/new_weather_theme/new_weather_theme.libraries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ localize-timestamps:
js:
assets/js/localizeTimestamps.js:
attributes:
async: true
async: true
tabbed_nav:
version: VERSION
js:
assets/js/components/TabbedNavigator.js: { preprocess: false }
Loading

0 comments on commit 45d7455

Please sign in to comment.