Commit af39ea1d authored by David Sveningsson's avatar David Sveningsson

feat(dom): support pseudo-classes `:first-child`, `:last-child` and `:nth-child`

parent 60910501
import { firstChild } from "./first-child";
import { HtmlElement } from "../htmlelement";
it("should return true if element is first child", () => {
expect.assertions(2);
const parent = new HtmlElement("parent");
const a = new HtmlElement("a", parent);
const b = new HtmlElement("b", parent);
expect(firstChild(a)).toBeTruthy();
expect(firstChild(b)).toBeFalsy();
});
import { HtmlElement } from "../htmlelement";
export function firstChild(node: HtmlElement): boolean {
return node.previousSibling === null;
}
import { HtmlElement } from "../htmlelement";
import { firstChild } from "./first-child";
import { lastChild } from "./last-child";
import { nthChild } from "./nth-child";
type PseudoClassFunction = (node: HtmlElement, args?: string) => boolean;
type PseudoClassTable = Record<string, PseudoClassFunction>;
const table: PseudoClassTable = {
"first-child": firstChild,
"last-child": lastChild,
"nth-child": nthChild,
};
export function factory(name: string): PseudoClassFunction {
const fn = table[name];
if (fn) {
return fn;
} else {
throw new Error(`Pseudo-class "${name}" is not implemented`);
}
}
import { lastChild } from "./last-child";
import { HtmlElement } from "../htmlelement";
it("should return true if element is last child", () => {
expect.assertions(2);
const parent = new HtmlElement("parent");
const a = new HtmlElement("a", parent);
const b = new HtmlElement("b", parent);
expect(lastChild(a)).toBeFalsy();
expect(lastChild(b)).toBeTruthy();
});
import { HtmlElement } from "../htmlelement";
export function lastChild(node: HtmlElement): boolean {
return node.nextSibling === null;
}
import { HtmlElement } from "../htmlelement";
import { nthChild } from "./nth-child";
it("should return true if :nth-child matches", () => {
expect.assertions(2);
const parent = new HtmlElement("parent");
const el = new HtmlElement("a", parent);
expect(nthChild(el, "1")).toBeTruthy();
expect(nthChild(el, "2")).toBeFalsy();
});
it("should not count text nodes", () => {
expect.assertions(2);
const parent = new HtmlElement("parent");
const a = new HtmlElement("a", parent);
parent.appendText("text");
const b = new HtmlElement("b", parent);
expect(nthChild(a, "1")).toBeTruthy();
expect(nthChild(b, "2")).toBeTruthy();
});
it("should handle missing parent", () => {
expect.assertions(1);
const el = new HtmlElement("a");
expect(nthChild(el, "1")).toBeFalsy();
});
import { HtmlElement } from "../htmlelement";
import { DOMInternalID } from "../domnode";
const cache: Record<DOMInternalID, number> = {};
function getNthChild(node: HtmlElement): number {
if (!node.parent) {
return -1;
}
if (!cache[node.unique]) {
const parent = node.parent;
const index = parent.childElements.findIndex(cur => {
return cur.unique === node.unique;
});
cache[node.unique] = index + 1; /* nthChild starts at 1 */
}
return cache[node.unique];
}
export function nthChild(node: HtmlElement, args: string): boolean {
const n = parseInt(args.trim(), 10);
const cur = getNthChild(node);
return cur === n;
}
......@@ -153,4 +153,30 @@ describe("Selector", () => {
expect.objectContaining({ tagName: "foo", testId: "foo-4" }),
]);
});
it("should match pseudo-classes", () => {
expect.assertions(1);
const selector = new Selector("foo:first-child");
expect(fetch(selector.match(doc))).toEqual([
expect.objectContaining({ tagName: "foo", testId: "foo-1" }),
expect.objectContaining({ tagName: "foo", testId: "foo-3" }),
]);
});
it("should match pseudo-classes with arguments", () => {
expect.assertions(1);
const selector = new Selector("foo:nth-child(1)");
expect(fetch(selector.match(doc))).toEqual([
expect.objectContaining({ tagName: "foo", testId: "foo-1" }),
expect.objectContaining({ tagName: "foo", testId: "foo-3" }),
]);
});
it("should throw error for invalid pseudo-classes", () => {
expect.assertions(1);
const selector = new Selector("foo:missing");
expect(() => fetch(selector.match(doc))).toThrow(
'Pseudo-class "missing" is not implemented'
);
});
});
import { Attribute } from "./attribute";
import { Combinator, parseCombinator } from "./combinator";
import { HtmlElement } from "./htmlelement";
import { factory as pseudoClassFunction } from "./pseudoclass";
class Matcher {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
......@@ -66,6 +67,23 @@ class AttrMatcher extends Matcher {
}
}
class PseudoClassMatcher extends Matcher {
private readonly name: string;
private readonly args: string;
public constructor(pseudoclass: string) {
super();
const [, name, args] = pseudoclass.match(/^([^(]+)(?:\((.*)\))?$/);
this.name = name;
this.args = args;
}
public match(node: HtmlElement): boolean {
const fn = pseudoClassFunction(this.name);
return fn(node, this.args);
}
}
class Pattern {
public readonly combinator: Combinator;
public readonly tagName: string;
......@@ -73,12 +91,12 @@ class Pattern {
private readonly pattern: Matcher[];
public constructor(pattern: string) {
const match = pattern.match(/^([~+\->]?)((?:[*]|[^.#[]+)?)(.*)$/);
const match = pattern.match(/^([~+\->]?)((?:[*]|[^.#[:]+)?)(.*)$/);
match.shift(); /* remove full matched string */
this.selector = pattern;
this.combinator = parseCombinator(match.shift());
this.tagName = match.shift() || "*";
const p = match[0] ? match[0].split(/(?=[.#[])/) : [];
const p = match[0] ? match[0].split(/(?=[.#[:])/) : [];
this.pattern = p.map((cur: string) => Pattern.createMatcher(cur));
}
......@@ -97,6 +115,8 @@ class Pattern {
return new IdMatcher(pattern.slice(1));
case "[":
return new AttrMatcher(pattern.slice(1, -1));
case ":":
return new PseudoClassMatcher(pattern.slice(1));
default:
/* istanbul ignore next: fallback solution, the switch cases should cover
* everything and there is no known way to trigger this fallback */
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment