Thanks to visit codestin.com
Credit goes to github.com

Skip to content
61 changes: 57 additions & 4 deletions packages/usa-in-page-navigation/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,39 @@ const getSectionAnchors = () => {
return sectionAnchors;
};

/**
* Generates a unique ID for the given heading element.
*
* @param {HTMLHeadingElement} heading
*
* @return {string} - Unique ID
*/
const getHeadingId = (heading) => {
const baseId = heading.textContent
.toLowerCase()
// Replace non-alphanumeric characters with dashes
.replace(/[^a-z\d]/g, "-")
// Replace a sequence of two or more dashes with a single dash
.replace(/-{2,}/g, "-")
// Trim leading or trailing dash (there should only ever be one)
.replace(/^-|-$/g, "");

let id;
let suffix = 0;
do {
id = baseId;

// To avoid conflicts with existing IDs on the page, loop and append an
// incremented suffix until a unique ID is found.
suffix += 1;
if (suffix > 1) {
id += `-${suffix}`;
}
} while (document.getElementById(id));
Comment on lines +87 to +96
Copy link
Contributor

Choose a reason for hiding this comment

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

I really like this idea and could see it as its own utility in the future.

Only concern is it looks like we're looping over the headers twice? Since we're calling getHeadingId(el) inside of the sectionHeadings.forEach((el).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

While this is technically a loop, I'd expect it would only run once in the vast majority of cases, since it would only ever run more than once if there was already an ID somewhere in the page which was the same as our computed ID using the heading's text.


return id;
};

/**
* Return a section id/anchor hash without the number sign
*
Expand Down Expand Up @@ -98,6 +131,23 @@ const handleScrollToSection = (el) => {
top: el.offsetTop - inPageNavScrollOffset,
block: "start",
});

if (window.location.hash.slice(1) !== el.id) {
window.history.pushState(null, "", `#${el.id}`);
}
Comment on lines +135 to +137
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice feature! I was able to go forwards/back on previously clicked in-page nav items.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah this was the trick to solve the issue you identified earlier, since assigning the hash fragment this way appears to work the same as window.location.hash = el.id with the exception of scrolling, which is what we wanted to avoid.

};

/**
* Scrolls the page to the section corresponding to the current hash fragment, if one exists.
*/
const scrollToCurrentSection = () => {
const hashFragment = window.location.hash.slice(1);
if (hashFragment) {
const anchorTag = document.getElementById(hashFragment);
if (anchorTag) {
handleScrollToSection(anchorTag);
}
}
};

/**
Expand Down Expand Up @@ -140,7 +190,7 @@ const createInPageNav = (inPageNavEl) => {
inPageNavList.classList.add(IN_PAGE_NAV_LIST_CLASS);
inPageNav.appendChild(inPageNavList);

sectionHeadings.forEach((el, i) => {
sectionHeadings.forEach((el) => {
const listItem = document.createElement("li");
const navLinks = document.createElement("a");
const anchorTag = document.createElement("a");
Expand All @@ -152,11 +202,13 @@ const createInPageNav = (inPageNavEl) => {
listItem.classList.add(SUB_ITEM_CLASS);
}

navLinks.setAttribute("href", `#section_${i}`);
const headingId = getHeadingId(el);

navLinks.setAttribute("href", `#${headingId}`);
navLinks.setAttribute("class", IN_PAGE_NAV_LINK_CLASS);
navLinks.textContent = textContentOfLink;

anchorTag.setAttribute("id", `section_${i}`);
anchorTag.setAttribute("id", headingId);
anchorTag.setAttribute("class", IN_PAGE_NAV_ANCHOR_CLASS);
el.insertAdjacentElement("afterbegin", anchorTag);

Expand Down Expand Up @@ -206,7 +258,7 @@ const handleEnterFromLink = (event) => {
} else {
// throw an error?
}
handleScrollToSection(target);
handleScrollToSection(targetAnchor);
};

const inPageNavigation = behavior(
Expand All @@ -228,6 +280,7 @@ const inPageNavigation = behavior(
init(root) {
selectOrMatches(`.${IN_PAGE_NAV_CLASS}`, root).forEach((inPageNavEl) => {
createInPageNav(inPageNavEl);
scrollToCurrentSection();
});
},
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@use "uswds-core" as *;
@use "sass:color";
@use "sass:list";

.usa-in-page-nav-container {
align-items: flex-start;
Expand Down Expand Up @@ -72,6 +73,19 @@
$preferred-link-color: $theme-in-page-nav-link-color,
$context: "In-page-navigation link"
);

&:visited {
color: color(
list.nth(
get-link-tokens-from-bg(
$bg-color: $theme-in-page-nav-background-color,
$preferred-link-token: $theme-in-page-nav-link-color,
$context: "In-page-navigation link"
),
1
)
);
}
}

&.usa-current {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const sinon = require("sinon");
const behavior = require("../index");

const HIDE_MAX_WIDTH = 639;
const OFFSET_PER_SECTION = 100;
const TEMPLATE = fs.readFileSync(path.join(__dirname, "/template.html"));
const STYLES = fs.readFileSync(
`${__dirname}/../../../../dist/css/uswds.min.css`
Expand Down Expand Up @@ -48,26 +49,64 @@ tests.forEach(({ name, selector: containerSelector }) => {

let theNav;
let theList;
let originalOffsetTop;

before(() => {
originalOffsetTop = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetTop"
);
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
get() {
// Since JSDOM doesn't emulate positions, create a fake offset using
// the heading's index to be used to test scrolling behavior.
const heading = this.closest("h2,h3");

let index = 0;
let sibling = heading;
while (true) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What are we checking for here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This tries to simulate a vertical offset in order to test that offsetTop used for scrolling is behaving as expected, since JSDOM doesn't emulate that. The logic essentially considers computes the index of the current heading and multiplies that by a constant number. I'm open to other ideas for how to ensure the scrolling occurs as expected.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see, thanks. I couldn't find any alternative solutions to offsetTop in their issues queue.

I guess what threw me off initially was the while (true) inside of Object.defineProperty.

sibling = sibling.previousElementSibling;
if (sibling) {
index += 1;
} else {
break;
}
}

return index * OFFSET_PER_SECTION;
},
});
const observe = sinon.spy();
const mockIntersectionObserver = sinon.stub().returns({ observe });
window.IntersectionObserver = mockIntersectionObserver;
sinon.stub(window, "scroll");
});

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

behavior.on(containerSelector());

theNav = document.querySelector(THE_NAV);
theList = document.querySelector(PRIMARY_CONTENT_SELECTOR);

window.innerWidth = 1024;
behavior.on(containerSelector());
});

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

after(() => {
Object.defineProperty(
HTMLElement.prototype,
"offsetTop",
originalOffsetTop
);
sinon.restore();
});

it("defines a max width", () => {
Expand All @@ -84,5 +123,59 @@ tests.forEach(({ name, selector: containerSelector }) => {
resizeTo(1024);
assertHidden(theList, false);
});

it("assigns id to section headings", () => {
// Tests that new anchor children are created in the fixture template in
// the expected locations.
const ok = [
Copy link
Contributor

Choose a reason for hiding this comment

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

These are the test template headers we're testing, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, correct, this tests against the template.

"h2 > .usa-anchor#section-1",
"h2 ~ h3 > .usa-anchor#section-1-1",
"h2 ~ h3 ~ h3 > .usa-anchor#section-1-2-2",
"h2 ~ h3 ~ h3 ~ h3 > .usa-anchor#section-1-3",
].every((selector) => document.querySelector(selector));

assert(ok);
Copy link
Contributor

Choose a reason for hiding this comment

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

And here we're asserting that the template headers exist in this structure?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this is testing that the anchors are created correctly for each heading in the template.

});

it("scrolls to section", () => {
const firstLink = theNav.querySelector("a[href='#section-1']");
firstLink.click();

assert(window.scroll.calledOnceWith(sinon.match({ top: 80 })));
});

it("updates url when scrolling to section", () => {
// Activate by click
const firstLink = theNav.querySelector("a[href='#section-1']");
firstLink.click();

assert.equal(window.location.hash, "#section-1");

// Activate by Enter press
const secondLink = theNav.querySelector("a[href='#section-1-1']");
const event = new KeyboardEvent("keydown", {
bubbles: true,
key: "Enter",
keyCode: 13,
});
secondLink.dispatchEvent(event);

assert.equal(window.location.hash, "#section-1-1");
});

it("does not scroll to section on initialization", () => {
assert.equal(window.scroll.called, false);
});

context("with initial hash URL", () => {
before(() => {
sinon.stub(window, "location").value({ hash: undefined });
sinon.stub(window.location, "hash").get(() => "#section-1");
});

it("scrolls to section on initialization", () => {
assert(window.scroll.calledOnceWith(sinon.match({ top: 80 })));
});
});
});
});
31 changes: 8 additions & 23 deletions packages/usa-in-page-navigation/src/test/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,15 @@
<main id="main-content" class="main-content">
<h1>In page navigation test heading</h1>

<h2><a id="section_0" class="usa-anchor-tag"></a>Section 1</h2>
<h2>Section 1</h2>
<p></p>
<h3><a id="section_1" class="usa-anchor-tag"></a>Section 1.1</h3>
<h3>Section 1.1</h3>
<p>Section 1.1 content</p>
<h3><a id="section_2" class="usa-anchor-tag"></a>Section 1.2</h3>
<p>Section 1.2. content</p>
<h3>Section 1.2</h3>
<p id="section-1-2">Section 1.2 content</p>
<h3>? Section 1.3.</h3>
<p>Section 1.3 content</p>
</main>

<aside class="usa-in-page-nav" data-title-text="On this page" data-title-heading-level="h4" data-scroll-offset="20">
<nav aria-label="In-page navigation">
<h4 class="usa-in-page-nav__heading">On this page</h4>
<ul class="usa-in-page-nav__list">
<li class="usa-in-page-nav__item"><a href="#section_0" class="usa-in-page-nav__link usa-current">Section 1</a></li>
<li class="usa-in-page-nav__item usa-in-page-nav__item--sub-item"><a href="#section_1" class="usa-in-page-nav__link">Section 1.1</a></li>
<li class="usa-in-page-nav__item usa-in-page-nav__item--sub-item"><a href="#section_2" class="usa-in-page-nav__link">Section 1.2</a></li>
</ul>
</nav>
</aside>
<main id="main-content" class="main-content">
<h1>In page navigation test heading</h1>
<h2><a id="section_0" class="usa-anchor-tag"></a>Section 1</h2>
<h3><a id="section_1" class="usa-anchor-tag"></a>Section 1.1</h3>
<p>Section 1.1 content</p>
<h3><a id="section_2" class="usa-anchor-tag"></a>Section 1.2</h3>
<p>Section 1.2. content</p>
</main>
</div>
<aside class="usa-in-page-nav" data-title-text="On this page" data-title-heading-level="h4" data-scroll-offset="20"></aside>
</div>