None of the answers submitted previously are acceptable for my situation. I don't know ahead of time which links exist on the page, and which links will necessitate expanding collapsed sections. I also have nested collapsible sections. Also, I want the expansion to occur if someone links into the page from another document. So I settled on a solution that detects dynamically what sections must be opened. When a click on a link I care about happens, the handler:
Finds all parents of the target which are collapsed sections. ($(target).parents(".collapse:not(.in)").toArray().reverse();
)
Starting with the outermost parent, it requests that the element be expanded. (These are the calls to next()
. The call $parent.collapse('show');
is what shows a parent.)
Once an element is expanded, it expands the next parent (closer to the link target). (if (parents.length)...
)
Until finally it requests that the target be scrolled into view. (target.scrollIntoView(true);
)
I've initially tried walking the DOM tree in the reverse order, from innermost parent to outermost, but I got strange results. Even compensating for event bubbling, the results were inconsistent. The request for scrolling to the target is necessary as it is likely that the browser will have scrolled the window before the sections are expanded.
Here's the code. win
and doc
are set to the Window
and Document
instance that actually hold the DOM tree being processed. Since frames may be used, just referring to window
and document
ain't okay. The root
variable is an Element
that holds the part of the DOM I care about.
function showTarget() {
var hash = win.location.hash;
if (!hash)
return;
var target = doc.getElementById(hash.slice(1));
if (!target)
return;
var parents =
$(target).parents(".collapse:not(.in)").toArray().reverse();
function next(parent) {
var $parent = $(parent);
$parent.one('shown.bs.collapse', function () {
if (parents.length) {
next(parents.shift());
return;
}
target.scrollIntoView(true);
});
$parent.collapse('show');
}
next(parents.shift());
}
win.addEventListener('popstate', showTarget);
$(root).on('click', 'a[href]:not([data-toggle], [href="#"])',
function (ev) {
setTimeout(showTarget, 0);
});
showTarget();
Notes:
The selector a[href]:not([data-toggle], [href="#"])
limits event listening only to those a
elements that are actually hyperlinks into the rest of the document. Sometimes a
is used for other purposes. For instance, those a
elements that have a data-toggle
attribute or have href="#"
are not used for navigating through the page.
setTimeout(showTarget, 0)
allows the default action for a click on a hyperlink to happen (i.e. the hash changes) first, and then showTarget
is called. This works everywhere except FF. A few tests show that showTarget
won't see the change on FF unless the timeout is raised. 0ms does not work, 1ms did not, and 10ms works. At any rate, I'd rather not hardcode some FF-specific value that may change in the future so we also listen on popstate` to catch those cases that would not be caught on FF.
The explicit call to showTarget()
is necessary for cases when the page is loaded with a URL that has a hash.
I've tried an implementation that listened only on popstate
but it proved unreliable due to how Chrome and FF differ in how they generate popstate
. (Chrome generates it whenever a link is clicked, even if the hash does not change. FF generates it only when the hash changes.)
The code above has been tested in Chrome (39, 38, 36), FF (31), and IE (10, 11).