Given a root element and a space-separated chain of tag names like 'div span a', find every element matching the last tag in the chain that also has — somewhere above it — elements matching each earlier tag, in the same nesting order. This is the CSS descendant combinator (div p means "every p inside a div") reduced to tag names: the earlier tags must be ancestors of the candidate, but they need not be direct parents.
function getElementsByTagNameHierarchy(root: Element, selector: string): Element[];
selector is one or more tag names separated by whitespace, ordered ancestor to descendant. Matching is case-insensitive. Returns a plain array in document order; the root itself is never a candidate or an ancestor.
const root = document.createElement('div');
root.innerHTML = '<div><section><p>a</p></section></div>';
getElementsByTagNameHierarchy(root, 'div p');
// → [<p>a</p>] (the p has a div ancestor, even though section sits between)
root.innerHTML = '<div><p>a</p></div><section><p>b</p></section>';
getElementsByTagNameHierarchy(root, 'div p');
// → [<p>a</p>] (b has no div ancestor, so it is excluded)
root.innerHTML = '<ul><li><a>x</a></li></ul>';
getElementsByTagNameHierarchy(root, 'ul li a'); // → [<a>x</a>]
'div p' matches <div><section><p></p></section></div>.'p div' is a different query from 'div p' and generally returns different elements.'a b c', a candidate c needs a b ancestor that itself has an a ancestor — the tags must appear in that outward order, not merely all be present.root is never returned and never counts as one of the ancestors in the chain.'DIV P' and 'div p' behave identically; element tagName is upper-cased internally.You'll find every element matching the last tag in the chain, then keep only the ones whose ancestors spell out the earlier tags in the right nesting order.
Think of the selector 'div p' the way CSS does: "give me every <p> that lives inside a <div>." The <div> does not have to be the <p>'s direct parent — it can be a grandparent or higher, with any number of other elements in between. So the job has two halves. First, find every element whose tag matches the last word in the chain — those are your candidates. Second, for each candidate, confirm that the earlier words in the chain appear as ancestors, in the same outer-to-inner order. A <p> with no <div> above it is not a match, no matter how many other elements surround it.
A chain like 'div p' describes a vertical path through the tree: an outer tag, then (eventually) an inner tag below it. The last tag names the element you return; every earlier tag must be found by climbing upward from that element. Crucially, "inside" means descendant, not child — so you climb through every ancestor, not just the immediate parent, looking for each required tag.
The tempting shortcut is to match only the last tag and forget about the ancestors entirely:
function getElementsByTagNameHierarchy(root, selector) {
const tags = selector.trim().toUpperCase().split(/\s+/);
const last = tags[tags.length - 1];
const result = [];
function walk(node) {
for (const child of node.children) {
if (child.tagName === last) result.push(child);
walk(child);
}
}
walk(root);
return result;
}
This is just a plain descendant search for the last tag — it never reads the earlier words in the chain. So 'div p' returns every <p> in the tree, including ones that have no <div> ancestor at all. The query promised "a p inside a div," but this version delivers "any p." The fix is to verify, for each candidate, that the earlier tags really do appear above it.
function getElementsByTagNameHierarchy(root, selector) {
const tags = selector.trim().toUpperCase().split(/\s+/);
const last = tags[tags.length - 1];
const ancestors = tags.slice(0, -1); // earlier tags, ancestor → descendant
// Verify a candidate's ancestor chain satisfies the earlier tags IN ORDER.
// Walk from the chain's last ancestor backwards, climbing the candidate's
// parents (stopping at root). Each tag must be matched by some ancestor, and
// once matched we keep climbing for the NEXT-outer tag — so nesting order is
// enforced without requiring direct parent-child links.
function hasAncestorChain(candidate) {
let need = ancestors.length - 1; // index into `ancestors`, innermost first
let node = candidate.parentElement;
while (node && node !== root && need >= 0) {
if (node.tagName === ancestors[need]) need--;
node = node.parentElement;
}
return need < 0; // every ancestor tag was matched in order
}
const result = [];
function walk(node) {
for (const child of node.children) {
if (child.tagName === last && hasAncestorChain(child)) {
result.push(child);
}
walk(child); // descend AFTER recording, keeping pre-order / document order
}
}
walk(root); // start at root's children — the root itself is never a candidate
return result;
}
module.exports = { getElementsByTagNameHierarchy };
The key shift is the second condition on each candidate: hasAncestorChain. The walk still visits every descendant in pre-order (so the result is already in document order), but now an element is only pushed when it both matches the last tag and passes the ancestor check. Two design choices make hasAncestorChain correct. It matches the earlier tags innermost-first — it starts needing ancestors[last] and decrements need as it climbs — which is exactly the order you meet them going up from the candidate. And it skips any ancestor that doesn't match the tag it currently needs (it just keeps climbing) rather than requiring a direct parent, which is what makes the match a descendant relationship instead of a child one.
The match also normalizes case: selector.trim().toUpperCase() upper-cases the whole chain once, and node.tagName is already upper-case for HTML, so the comparisons line up. Starting walk(root) from the root's children (and stopping the climb at node !== root) keeps the root out of both the candidate set and the ancestor chain.
Take getElementsByTagNameHierarchy(root, 'div p') on <div><p>a</p></div><section><p>b</p></section>. The chain splits into last = 'P' and ancestors = ['DIV'].
walk(root) iterates the root's two children: the <div> and the <section>.<div> — 'DIV' !== 'P', so it's not a candidate, but we walk into it.<p>a</p> (inside the div) — 'P' === 'P', so run hasAncestorChain. need starts at 0 (we need 'DIV'). Climb: parent is the <div> — 'DIV' === ancestors[0], so need becomes -1. The loop stops, need < 0 is true → push <p>a</p>.<section> — 'SECTION' !== 'P', not a candidate; walk into it.<p>b</p> (inside the section) — matches 'P', so run hasAncestorChain. need starts at 0. Climb: parent is <section> — not 'DIV', keep climbing; next is root, so the loop stops at node === root. need is still 0, so need < 0 is false → not pushed.result = [<p>a</p>] — the <p> with a <div> ancestor, and not the one without.Because the last tag names the candidate and the earlier tags must be its ancestors, the direction of the chain changes the question entirely. On a tree where a <span> sits inside a <p>, 'p span' matches the span (it has a p ancestor) but 'span p' matches nothing (there is no p with a span above it).
hasAncestorChain required each step to be the immediate parent, 'div p' would miss <div><section><p></p></section></div> because section, not div, is the p's parent. Fix: climb all ancestors and skip the ones that don't match the currently-needed tag.<p>, including ones with no <div> above them. Fix: gate each candidate on hasAncestorChain before pushing it.root, a selector like 'div div' would treat the root <div> as the outer div and over-match. Fix: stop the loop on node === root so the root is neither a candidate nor a chain element.node.tagName is upper-case ('P', 'DIV'), so comparing against a lower-case 'p' always fails. Fix: upper-case the whole selector once with .toUpperCase() and compare against the upper-case tagName.root. Generalizing to start from document and respect :scope-style boundaries is the step toward a real querySelectorAll for descendant chains.> (direct child), + (adjacent sibling), and ~ (general sibling) means the per-step climb becomes a small state machine over combinator types rather than a uniform "skip non-matching ancestors" loop.tag.class#id[attr] turns hasAncestorChain's single tagName comparison into a predicate that tests several conditions per element — the leap from tag chains to a genuine selector engine.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Given a root element and a space-separated chain of tag names like 'div span a', find every element matching the last tag in the chain that also has — somewhere above it — elements matching each earlier tag, in the same nesting order. This is the CSS descendant combinator (div p means "every p inside a div") reduced to tag names: the earlier tags must be ancestors of the candidate, but they need not be direct parents.
function getElementsByTagNameHierarchy(root: Element, selector: string): Element[];
selector is one or more tag names separated by whitespace, ordered ancestor to descendant. Matching is case-insensitive. Returns a plain array in document order; the root itself is never a candidate or an ancestor.
const root = document.createElement('div');
root.innerHTML = '<div><section><p>a</p></section></div>';
getElementsByTagNameHierarchy(root, 'div p');
// → [<p>a</p>] (the p has a div ancestor, even though section sits between)
root.innerHTML = '<div><p>a</p></div><section><p>b</p></section>';
getElementsByTagNameHierarchy(root, 'div p');
// → [<p>a</p>] (b has no div ancestor, so it is excluded)
root.innerHTML = '<ul><li><a>x</a></li></ul>';
getElementsByTagNameHierarchy(root, 'ul li a'); // → [<a>x</a>]
'div p' matches <div><section><p></p></section></div>.'p div' is a different query from 'div p' and generally returns different elements.'a b c', a candidate c needs a b ancestor that itself has an a ancestor — the tags must appear in that outward order, not merely all be present.root is never returned and never counts as one of the ancestors in the chain.'DIV P' and 'div p' behave identically; element tagName is upper-cased internally.You'll find every element matching the last tag in the chain, then keep only the ones whose ancestors spell out the earlier tags in the right nesting order.
Think of the selector 'div p' the way CSS does: "give me every <p> that lives inside a <div>." The <div> does not have to be the <p>'s direct parent — it can be a grandparent or higher, with any number of other elements in between. So the job has two halves. First, find every element whose tag matches the last word in the chain — those are your candidates. Second, for each candidate, confirm that the earlier words in the chain appear as ancestors, in the same outer-to-inner order. A <p> with no <div> above it is not a match, no matter how many other elements surround it.
A chain like 'div p' describes a vertical path through the tree: an outer tag, then (eventually) an inner tag below it. The last tag names the element you return; every earlier tag must be found by climbing upward from that element. Crucially, "inside" means descendant, not child — so you climb through every ancestor, not just the immediate parent, looking for each required tag.
The tempting shortcut is to match only the last tag and forget about the ancestors entirely:
function getElementsByTagNameHierarchy(root, selector) {
const tags = selector.trim().toUpperCase().split(/\s+/);
const last = tags[tags.length - 1];
const result = [];
function walk(node) {
for (const child of node.children) {
if (child.tagName === last) result.push(child);
walk(child);
}
}
walk(root);
return result;
}
This is just a plain descendant search for the last tag — it never reads the earlier words in the chain. So 'div p' returns every <p> in the tree, including ones that have no <div> ancestor at all. The query promised "a p inside a div," but this version delivers "any p." The fix is to verify, for each candidate, that the earlier tags really do appear above it.
function getElementsByTagNameHierarchy(root, selector) {
const tags = selector.trim().toUpperCase().split(/\s+/);
const last = tags[tags.length - 1];
const ancestors = tags.slice(0, -1); // earlier tags, ancestor → descendant
// Verify a candidate's ancestor chain satisfies the earlier tags IN ORDER.
// Walk from the chain's last ancestor backwards, climbing the candidate's
// parents (stopping at root). Each tag must be matched by some ancestor, and
// once matched we keep climbing for the NEXT-outer tag — so nesting order is
// enforced without requiring direct parent-child links.
function hasAncestorChain(candidate) {
let need = ancestors.length - 1; // index into `ancestors`, innermost first
let node = candidate.parentElement;
while (node && node !== root && need >= 0) {
if (node.tagName === ancestors[need]) need--;
node = node.parentElement;
}
return need < 0; // every ancestor tag was matched in order
}
const result = [];
function walk(node) {
for (const child of node.children) {
if (child.tagName === last && hasAncestorChain(child)) {
result.push(child);
}
walk(child); // descend AFTER recording, keeping pre-order / document order
}
}
walk(root); // start at root's children — the root itself is never a candidate
return result;
}
module.exports = { getElementsByTagNameHierarchy };
The key shift is the second condition on each candidate: hasAncestorChain. The walk still visits every descendant in pre-order (so the result is already in document order), but now an element is only pushed when it both matches the last tag and passes the ancestor check. Two design choices make hasAncestorChain correct. It matches the earlier tags innermost-first — it starts needing ancestors[last] and decrements need as it climbs — which is exactly the order you meet them going up from the candidate. And it skips any ancestor that doesn't match the tag it currently needs (it just keeps climbing) rather than requiring a direct parent, which is what makes the match a descendant relationship instead of a child one.
The match also normalizes case: selector.trim().toUpperCase() upper-cases the whole chain once, and node.tagName is already upper-case for HTML, so the comparisons line up. Starting walk(root) from the root's children (and stopping the climb at node !== root) keeps the root out of both the candidate set and the ancestor chain.
Take getElementsByTagNameHierarchy(root, 'div p') on <div><p>a</p></div><section><p>b</p></section>. The chain splits into last = 'P' and ancestors = ['DIV'].
walk(root) iterates the root's two children: the <div> and the <section>.<div> — 'DIV' !== 'P', so it's not a candidate, but we walk into it.<p>a</p> (inside the div) — 'P' === 'P', so run hasAncestorChain. need starts at 0 (we need 'DIV'). Climb: parent is the <div> — 'DIV' === ancestors[0], so need becomes -1. The loop stops, need < 0 is true → push <p>a</p>.<section> — 'SECTION' !== 'P', not a candidate; walk into it.<p>b</p> (inside the section) — matches 'P', so run hasAncestorChain. need starts at 0. Climb: parent is <section> — not 'DIV', keep climbing; next is root, so the loop stops at node === root. need is still 0, so need < 0 is false → not pushed.result = [<p>a</p>] — the <p> with a <div> ancestor, and not the one without.Because the last tag names the candidate and the earlier tags must be its ancestors, the direction of the chain changes the question entirely. On a tree where a <span> sits inside a <p>, 'p span' matches the span (it has a p ancestor) but 'span p' matches nothing (there is no p with a span above it).
hasAncestorChain required each step to be the immediate parent, 'div p' would miss <div><section><p></p></section></div> because section, not div, is the p's parent. Fix: climb all ancestors and skip the ones that don't match the currently-needed tag.<p>, including ones with no <div> above them. Fix: gate each candidate on hasAncestorChain before pushing it.root, a selector like 'div div' would treat the root <div> as the outer div and over-match. Fix: stop the loop on node === root so the root is neither a candidate nor a chain element.node.tagName is upper-case ('P', 'DIV'), so comparing against a lower-case 'p' always fails. Fix: upper-case the whole selector once with .toUpperCase() and compare against the upper-case tagName.root. Generalizing to start from document and respect :scope-style boundaries is the step toward a real querySelectorAll for descendant chains.> (direct child), + (adjacent sibling), and ~ (general sibling) means the per-step climb becomes a small state machine over combinator types rather than a uniform "skip non-matching ancestors" loop.tag.class#id[attr] turns hasAncestorChain's single tagName comparison into a predicate that tests several conditions per element — the leap from tag chains to a genuine selector engine.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.