-
Notifications
You must be signed in to change notification settings - Fork 1.1k
In-Page Navigation: Update page URL when scrolling to section #5068
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
Changes from all commits
95256fb
0b78a24
d82568b
66e0232
e0d704e
cf87c95
74f658c
45fdaaf
a820446
b051d44
3163fd1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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)); | ||
|
||
return id; | ||
}; | ||
|
||
/** | ||
* Return a section id/anchor hash without the number sign | ||
* | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
}; | ||
|
||
/** | ||
* 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); | ||
} | ||
} | ||
}; | ||
|
||
/** | ||
|
@@ -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"); | ||
|
@@ -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); | ||
|
||
|
@@ -206,7 +258,7 @@ const handleEnterFromLink = (event) => { | |
} else { | ||
// throw an error? | ||
} | ||
handleScrollToSection(target); | ||
handleScrollToSection(targetAnchor); | ||
}; | ||
|
||
const inPageNavigation = behavior( | ||
|
@@ -228,6 +280,7 @@ const inPageNavigation = behavior( | |
init(root) { | ||
selectOrMatches(`.${IN_PAGE_NAV_CLASS}`, root).forEach((inPageNavEl) => { | ||
createInPageNav(inPageNavEl); | ||
scrollToCurrentSection(); | ||
}); | ||
}, | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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` | ||
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What are we checking for here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This tries to simulate a vertical offset in order to test that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see, thanks. I couldn't find any alternative solutions to I guess what threw me off initially was the |
||
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", () => { | ||
|
@@ -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 = [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are the test template headers we're testing, right? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }))); | ||
}); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
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 thesectionHeadings.forEach((el)
.There was a problem hiding this comment.
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.