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

USWDS - In-page navigation: Add custom headings attribute #5444

Merged
merged 29 commits into from Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
631e8b9
Add custom headings attribute
amyleadem Aug 16, 2023
288e9eb
Create story for custom header
amyleadem Aug 16, 2023
219c8a1
Remove attribute customizations from stories
amyleadem Aug 16, 2023
4406395
Update control type for custom header selector
amyleadem Aug 16, 2023
566bc0f
Add default, empty, and error states
amyleadem Aug 17, 2023
e9b099f
Run prettier
amyleadem Aug 17, 2023
7935c16
Run prettier
amyleadem Aug 17, 2023
44631cd
Add getTopLevelHeading()
amyleadem Aug 17, 2023
2edf4df
Update data-headings --> data-heading-selector
amyleadem Aug 17, 2023
3c042b2
Run prettier
amyleadem Aug 17, 2023
95d8970
Remove test code
amyleadem Aug 17, 2023
ca16ca3
Simplify steps in getSectionHeadings
amyleadem Aug 22, 2023
3e2626f
Rename contentHeadings --> sectionHeadings
amyleadem Aug 22, 2023
3e1cd94
Format code
amyleadem Aug 22, 2023
3014087
Add clarity to control names
amyleadem Aug 22, 2023
5d1a360
Create unit test
amyleadem Aug 22, 2023
35bf710
Update param type for selectedHeadingTypes
amyleadem Aug 23, 2023
41493cb
Update test names
amyleadem Aug 23, 2023
9d9492a
Merge branch 'develop' of https://github.com/uswds/uswds into al-in-p…
amyleadem Aug 23, 2023
9de0209
Update test descriptions
amyleadem Jan 5, 2024
03589a1
Define usa-in-page-nav__item with u-font-weight
amyleadem Jan 8, 2024
8eabccc
Clean up header twig; includeHeaders -> headingType
amyleadem Jan 8, 2024
d7d27a6
Add clarity to custom heading error message
amyleadem Jan 8, 2024
30a3b9e
Separate getSectionHeadings into two functions
amyleadem Jan 8, 2024
3467c74
Change data-heading-selector -> data-heading-elements
amyleadem Jan 8, 2024
25e3a06
Merge branch 'develop' of https://github.com/uswds/uswds into al-in-p…
amyleadem Jan 8, 2024
8282ace
Run prettier
amyleadem Jan 8, 2024
df47eab
Replace nav__item--sub-item -> nav__item--primary
amyleadem Jan 9, 2024
7a32ee8
Sanitize data-heading-elements
amyleadem Jan 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
89 changes: 75 additions & 14 deletions packages/usa-in-page-navigation/src/index.js
Expand Up @@ -7,6 +7,8 @@ const { CLICK } = require("../../uswds-core/src/js/events");
const Sanitizer = require("../../uswds-core/src/js/utils/sanitizer");

const CURRENT_CLASS = `${PREFIX}-current`;
const IN_PAGE_NAV_HEADINGS = "h2 h3";
const IN_PAGE_NAV_VALID_HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"];
const IN_PAGE_NAV_TITLE_TEXT = "On this page";
const IN_PAGE_NAV_TITLE_HEADING_LEVEL = "h4";
const IN_PAGE_NAV_SCROLL_OFFSET = 0;
Expand Down Expand Up @@ -42,23 +44,62 @@ const setActive = (el) => {
};

/**
* Return an array of all visible h2 and h3 headings from the designated main content region.
* These will be added to the component link list.
* Return an array of the designated heading types found in the designated content region.
* Throw an error if an invalid header element is designated.
*
* @param {HTMLElement} mainContentSelector The designated main content region
* @param {HTMLElement} selectedContentRegion The content region the component should pull headers from
* @param {String} selectedHeadingTypes The list of heading types that should be included in the nav list
*
* @return {Array} - An array of visible headings from the designated content region
* @return {Array} - An array of designated heading types from the designated content region
*/
const getSectionHeadings = (mainContentSelector) => {
const sectionHeadings = document.querySelectorAll(
`${mainContentSelector} h2, ${mainContentSelector} h3`
const createSectionHeadingsArray = (
selectedContentRegion,
selectedHeadingTypes
) => {
// Convert designated headings list to an array
const selectedHeadingTypesArray = selectedHeadingTypes.indexOf(" ")
? selectedHeadingTypes.split(" ")
: selectedHeadingTypes;
const contentRegion = document.querySelector(selectedContentRegion);

selectedHeadingTypesArray.forEach((headingType) => {
if (!IN_PAGE_NAV_VALID_HEADINGS.includes(headingType)) {
throw new Error(
`In-page navigation: data-header-selector attribute defined with an invalid heading type: "${headingType}".
Define the attribute with one or more of the following: "${IN_PAGE_NAV_VALID_HEADINGS}".
Do not use commas or other punctuation in the attribute definition.`
);
}
});

const sectionHeadingsArray = Array.from(
contentRegion.querySelectorAll(selectedHeadingTypesArray)
);

// Convert nodeList to an array to allow for filtering
const headingArray = Array.from(sectionHeadings);
return sectionHeadingsArray;
};

/**
* Return an array of the visible headings from sectionHeadingsArray.
* This function removes headings that are hidden with display:none or visibility:none style rules.
* These items will be added to the component nav list.
*
* @param {HTMLElement} selectedContentRegion The content region the component should pull headers from
* @param {String} selectedHeadingTypes The list of heading types that should be included in the nav list
*
* @return {Array} - An array of visible headings from the designated content region
*/
const getVisibleSectionHeadings = (
selectedContentRegion,
selectedHeadingTypes
) => {
const sectionHeadings = createSectionHeadingsArray(
selectedContentRegion,
selectedHeadingTypes
);

// Find all headings with hidden styling and remove them from the array
const visibleHeadingArray = headingArray.filter((heading) => {
const visibleSectionHeadings = sectionHeadings.filter((heading) => {
const headingStyle = window.getComputedStyle(heading);
const visibleHeading =
headingStyle.getPropertyValue("display") !== "none" &&
Expand All @@ -67,7 +108,20 @@ const getSectionHeadings = (mainContentSelector) => {
return visibleHeading;
});

return visibleHeadingArray;
return visibleSectionHeadings;
};

/**
* Return the highest-level header tag included in the link list
*
* @param {HTMLElement} sectionHeadings The array of headings selected for inclusion in the link list
*
* @return {tagName} - The tag name for the highest level of header in the link list
*/

const getTopLevelHeading = (sectionHeadings) => {
const topHeading = sectionHeadings[0].tagName.toLowerCase();
return topHeading;
};

/**
Expand Down Expand Up @@ -188,14 +242,19 @@ const createInPageNav = (inPageNavEl) => {
const inPageNavContentSelector = Sanitizer.escapeHTML`${
inPageNavEl.dataset.mainContentSelector || MAIN_ELEMENT
}`;
const inPageNavHeadingSelector =
inPageNavEl.dataset.headingElements || IN_PAGE_NAV_HEADINGS;

mejiaj marked this conversation as resolved.
Show resolved Hide resolved
const options = {
root: null,
rootMargin: inPageNavRootMargin,
threshold: [inPageNavThreshold],
};

const sectionHeadings = getSectionHeadings(inPageNavContentSelector);
const sectionHeadings = getVisibleSectionHeadings(
inPageNavContentSelector,
inPageNavHeadingSelector
);
const inPageNav = document.createElement("nav");
mejiaj marked this conversation as resolved.
Show resolved Hide resolved
inPageNav.setAttribute("aria-label", inPageNavTitleText);
inPageNav.classList.add(IN_PAGE_NAV_NAV_CLASS);
Expand All @@ -216,9 +275,11 @@ const createInPageNav = (inPageNavEl) => {
const anchorTag = document.createElement("a");
const textContentOfLink = el.textContent;
const tag = el.tagName.toLowerCase();
const topHeadingLevel = getTopLevelHeading(sectionHeadings);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noting this runs when there's only a single heading passed. This seems okay for the small amount of headers available.


listItem.classList.add(IN_PAGE_NAV_ITEM_CLASS);
if (tag === "h3") {
if (tag === topHeadingLevel) {
listItem.classList.add(IN_PAGE_NAV_ITEM_CLASS);
} else {
listItem.classList.add(SUB_ITEM_CLASS);
}

Expand Down
Expand Up @@ -123,13 +123,13 @@
}
}

.usa-in-page-nav__item {
.usa-in-page-nav__item,
.usa-in-page-nav__item--sub-item {
@include typeset($theme-in-page-nav-font-family, "2xs", 2);
border: none;
font-weight: bold;
position: relative;
}

&.usa-in-page-nav__item--sub-item {
font-weight: normal;
}
.usa-in-page-nav__item {
@include u-font-weight("bold");
mejiaj marked this conversation as resolved.
Show resolved Hide resolved
}
@@ -0,0 +1,70 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const sinon = require("sinon");
const behavior = require("../index");

const TEMPLATE = fs.readFileSync(
path.join(__dirname, "/in-page-navigation-custom-heading.template.html")
);
const THE_NAV = ".usa-in-page-nav";
const PRIMARY_CONTENT_SELECTOR =
".usa-in-page-nav-container .usa-in-page-nav .usa-in-page-nav__list";

const tests = [
{ name: "document.body", selector: () => document.body },
{
name: "in page nav",
selector: () => document.querySelector(".usa-in-page-nav"),
},
];

tests.forEach(({ name, selector: containerSelector }) => {
describe(`in-page navigation pulls from custom header list in ${name}`, () => {
const { body } = document;

let theNav;
let navList;
let navListLinks;
let dataHeadingSelector;
let selectedHeadingList;

before(() => {
const observe = sinon.spy();
const mockIntersectionObserver = sinon.stub().returns({ observe });
window.IntersectionObserver = mockIntersectionObserver;
});

beforeEach(() => {
body.innerHTML = TEMPLATE;

behavior.on(containerSelector());

theNav = document.querySelector(THE_NAV);
dataHeadingSelector = theNav.getAttribute("data-heading-elements");

navList = document.querySelector(PRIMARY_CONTENT_SELECTOR);
navListLinks = Array.from(navList.getElementsByTagName("a"));
selectedHeadingList = document
.querySelector("main")
.querySelectorAll(dataHeadingSelector);
});

afterEach(() => {
behavior.off(containerSelector(body));
body.innerHTML = "";
window.location.hash = "";
});

it("creates links in the nav list for the heading level listed in data-heading-elements", () => {
assert.equal(selectedHeadingList.length === navListLinks.length, true);
});

it("creates a link in the nav list specifically for the designated header", () => {
const selectedHeadingLink = navListLinks.filter((link) =>
link.href.includes(`#${dataHeadingSelector}-heading`)
);
assert.equal(selectedHeadingLink.length === 1, true);
});
});
});
@@ -0,0 +1,13 @@
<div class="usa-in-page-nav-container">
<aside class="usa-in-page-nav"
data-heading-elements="h2">
</aside>
<main>
<h1>H1 heading</h1>
<h2>H2 heading</h2>
<h3>H3 heading</h3>
<h4>H4 heading</h4>
<h5>H5 heading</h5>
<h6>H6 heading</h6>
</main>
</div>
@@ -1,10 +1,5 @@
<div class="usa-in-page-nav-container">
<aside class="usa-in-page-nav"
data-title-text="On this page"
data-title-heading-level="h4"
data-scroll-offset="0"
data-root-margin="0px 0px 0px 0px"
data-threshold="1"
Comment on lines -3 to -7
Copy link
Contributor Author

@amyleadem amyleadem Aug 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed these attributes so that the component uses defaults. The aim was to improve clarity in testing.

{% if customContentSelector %}
data-main-content-selector=".main-content"
{% endif %}
Expand Down
amyleadem marked this conversation as resolved.
Show resolved Hide resolved
@@ -0,0 +1,37 @@
<div class="usa-prose measure-5 padding-2 border-1px border-gray-10 margin-bottom-4">
<p class="font-body-lg text-bold margin-top-0">Test if the data-heading-elements attribute pulls the correct headers into the component</p>
<p>Use the story controls to select which headers should be pulled into the component. For example:
<ul>
<li>Selecting "Default" should pull h2 and h3 headers into the in-page navigation.</li>
<li>Selecting "All" should pull all headers (h1-h6) into the in-page navigation.</li>
<li>Selecting "h4" should pull only h4 headers into the in-page navigation.</li>
<li>
Selecting "Error - Invalid heading type" should prevent the in-page navigation component from building
and return an error message in the console.
</li>
</ul>
</div>

{% set all_headers = "h1 h2 h3 h4 h5 h6" %}
{% if headingType != "Default" %}
{% if headingType == "All" %}
{% set headerValue = all_headers %}
{% elseif headingType == "Error - Invalid heading type" %}
{% set headerValue = "j1" %}
{% else %}
{% set headerValue = headingType %}
{% endif %}
{% endif %}

<div class="usa-in-page-nav-container">
<aside class="usa-in-page-nav" data-heading-elements="{{ headerValue | default("") }}">
</aside>
<main class="usa-prose">
<h1>H1 heading</h1>
<h2>H2 heading</h2>
<h3>H3 heading</h3>
<h4>H4 heading</h4>
<h5>H5 heading</h5>
<h6>H6 heading</h6>
</main>
</div>
amyleadem marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Standardized the visual presentation of the expectations/instructions in this test to match the custom header test.

@@ -1,27 +1,18 @@
<div class="usa-prose measure-5 padding-2 border-1px border-gray-20 margin-bottom-4">
<p class="font-body-lg text-bold margin-top-0">Test if in-page nav excludes headers that are hidden with visibility:hidden or display:none</p>
<p>The in-page nav link list should show the following three headers:</p>
<ul>
<li>This heading is visible</li>
<li>This heading is visible</li>
<li>This heading is hidden with opacity:0</li>
</ul>
</div>

<div class="usa-in-page-nav-container">
<aside
class="usa-in-page-nav"
data-title-text="On this page"
data-title-heading-level="h4"
data-scroll-offset="0"
data-root-margin="0px 0px 0px 0px"
data-threshold="1">
<aside class="usa-in-page-nav">
</aside>

<main class="usa-prose">
<div class="usa-summary-box" role="region" aria-labelledby="summary-box-key-information">
<div class="usa-summary-box__body">
<h1 class="usa-summary-box__heading" id="summary-box-key-information">
Test if in-page nav excludes hidden headers
</h1>
<div class="usa-summary-box__text">
<p>
The in-page nav link list should show only two "This heading is
visible" headers.
</p>
</div>
</div>
</div>
<h2>This heading is visible</h2>
<p>
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Morbi commodo,
Expand Down
@@ -1,5 +1,6 @@
import Component from "./usa-in-page-navigation.twig";
import TestCustomContentComponent from "./test/test-patterns/test-custom-content-selector.twig";
import TestCustomHeaderComponent from "./test/test-patterns/test-custom-header-selector.twig";
import TestHiddenHeaderComponent from "./test/test-patterns/test-hidden-headers.twig";
import Content from "./usa-in-page-navigation.json";

Expand All @@ -9,6 +10,7 @@ export default {

const Template = (args) => Component(args);
const TestCustomContentTemplate = (args) => TestCustomContentComponent(args);
const TestCustomHeaderTemplate = (args) => TestCustomHeaderComponent(args);
const TestHiddenHeaderTemplate = (args) => TestHiddenHeaderComponent(args);

export const Default = Template.bind({});
Expand All @@ -18,4 +20,25 @@ export const TestCustomContentSelector = TestCustomContentTemplate.bind();
TestCustomContentSelector.args = {
customContentSelector: true,
};

export const TestCustomHeaderSelector = TestCustomHeaderTemplate.bind();
TestCustomHeaderSelector.argTypes = {
headingType: {
defaultValue: "All",
name: "Include these headers in link list",
options: [
"Default",
"All",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"Error - Invalid heading type",
],
control: { type: "select" },
},
};

export const TestHiddenHeaders = TestHiddenHeaderTemplate.bind();