Skip to content

Commit fb825e0

Browse files
committed
feat: support nth of
And fix :enabled
1 parent cd66254 commit fb825e0

4 files changed

Lines changed: 171 additions & 118 deletions

File tree

src/pseudo-selectors/aliases.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export const aliases: Record<string, string> = {
2424
optgroup[disabled] > option,
2525
fieldset[disabled]:not(fieldset[disabled] legend:first-of-type *)
2626
)`,
27-
enabled: ":not(:disabled)",
27+
enabled:
28+
":is(button, input, select, textarea, optgroup, option, fieldset):not(:disabled)",
2829
checked:
2930
":is(:is(input[type=radio], input[type=checkbox])[checked], :selected)",
3031
required: ":is(input, select, textarea)[required]",

src/pseudo-selectors/filters.ts

Lines changed: 70 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,153 +1,107 @@
11
import * as boolbase from "boolbase";
2+
import { parse } from "css-what";
23
import getNCheck from "nth-check";
34
import { cacheParentResults } from "../helpers/cache.js";
45
import { getElementParent } from "../helpers/querying.js";
5-
import type { CompiledQuery, InternalOptions } from "../types.js";
6+
import type { CompiledQuery, CompileToken, InternalOptions } from "../types.js";
67

7-
type Filter = <Node, ElementNode extends Node>(
8+
/** @see {@link https://www.w3.org/TR/selectors-4/#the-nth-child-pseudo} */
9+
const nthOfRegex = /^(.+?)\s+of\s+(.+)$/is;
10+
11+
/** A pre-compiled pseudo filter. */
12+
export type Filter = <Node, ElementNode extends Node>(
813
next: CompiledQuery<ElementNode>,
914
text: string,
1015
options: InternalOptions<Node, ElementNode>,
1116
context?: Node[],
17+
compileToken?: CompileToken<Node, ElementNode>,
1218
) => CompiledQuery<ElementNode>;
1319

14-
/**
15-
* Pre-compiled pseudo filters.
16-
*/
17-
export const filters: Record<string, Filter> = {
18-
contains(next, text, options) {
19-
const { getText } = options.adapter;
20+
function compileNth(reverse: boolean, ofType: boolean): Filter {
21+
return function nth(next, rule, options, _context, compileToken) {
22+
const { adapter, equals } = options;
23+
const ofMatch = ofType ? null : rule.match(nthOfRegex);
24+
const nthCheck = getNCheck(ofMatch ? ofMatch[1].trim() : rule);
2025

21-
return cacheParentResults(next, options, (element) =>
22-
getText(element).includes(text),
23-
);
24-
},
25-
icontains(next, text, options) {
26-
const itext = text.toLowerCase();
27-
const { getText } = options.adapter;
26+
if (nthCheck === boolbase.falseFunc) return boolbase.falseFunc;
2827

29-
return cacheParentResults(next, options, (element) =>
30-
getText(element).toLowerCase().includes(itext),
31-
);
32-
},
28+
const ofSelector =
29+
ofMatch && compileToken
30+
? compileToken(parse(ofMatch[2].trim()), options)
31+
: undefined;
3332

34-
// Location specific methods
35-
"nth-child"(next, rule, { adapter, equals }) {
36-
const nthCheck = getNCheck(rule);
33+
if (ofSelector === boolbase.falseFunc) return boolbase.falseFunc;
3734

38-
if (nthCheck === boolbase.falseFunc) {
39-
return boolbase.falseFunc;
40-
}
41-
if (nthCheck === boolbase.trueFunc) {
35+
if (nthCheck === boolbase.trueFunc && !ofSelector) {
4236
return (element) =>
4337
getElementParent(element, adapter) !== null && next(element);
4438
}
4539

46-
return function nthChild(element) {
47-
const siblings = adapter.getSiblings(element);
48-
let pos = 0;
49-
50-
for (const sibling of siblings) {
51-
if (equals(element, sibling)) {
52-
break;
53-
}
54-
if (adapter.isTag(sibling)) {
55-
pos++;
40+
type ElementNode = Parameters<typeof next>[0];
41+
42+
const shouldCount = ofSelector
43+
? (_element: ElementNode, sibling: ElementNode) =>
44+
ofSelector(sibling)
45+
: ofType
46+
? (element: ElementNode, sibling: ElementNode) =>
47+
adapter.getName(sibling) === adapter.getName(element)
48+
: boolbase.trueFunc;
49+
50+
if (reverse) {
51+
return function nthLast(element) {
52+
if (ofSelector && !ofSelector(element)) return false;
53+
const siblings = adapter.getSiblings(element);
54+
let pos = 0;
55+
for (let index = siblings.length - 1; index >= 0; index--) {
56+
const sibling = siblings[index];
57+
if (equals(element, sibling)) break;
58+
if (adapter.isTag(sibling) && shouldCount(element, sibling))
59+
pos++;
5660
}
57-
}
58-
59-
return nthCheck(pos) && next(element);
60-
};
61-
},
62-
"nth-last-child"(next, rule, { adapter, equals }) {
63-
const nthCheck = getNCheck(rule);
64-
65-
if (nthCheck === boolbase.falseFunc) {
66-
return boolbase.falseFunc;
67-
}
68-
if (nthCheck === boolbase.trueFunc) {
69-
return (element) =>
70-
getElementParent(element, adapter) !== null && next(element);
61+
return nthCheck(pos) && next(element);
62+
};
7163
}
7264

73-
return function nthLastChild(element) {
65+
return function nth(element) {
66+
if (ofSelector && !ofSelector(element)) return false;
7467
const siblings = adapter.getSiblings(element);
7568
let pos = 0;
76-
77-
for (let index = siblings.length - 1; index >= 0; index--) {
78-
if (equals(element, siblings[index])) {
79-
break;
80-
}
81-
if (adapter.isTag(siblings[index])) {
69+
for (const sibling of siblings) {
70+
if (equals(element, sibling)) break;
71+
if (adapter.isTag(sibling) && shouldCount(element, sibling))
8272
pos++;
83-
}
8473
}
85-
8674
return nthCheck(pos) && next(element);
8775
};
88-
},
89-
"nth-of-type"(next, rule, { adapter, equals }) {
90-
const nthCheck = getNCheck(rule);
91-
92-
if (nthCheck === boolbase.falseFunc) {
93-
return boolbase.falseFunc;
94-
}
95-
if (nthCheck === boolbase.trueFunc) {
96-
return (element) =>
97-
getElementParent(element, adapter) !== null && next(element);
98-
}
99-
100-
return function nthOfType(element) {
101-
const siblings = adapter.getSiblings(element);
102-
let pos = 0;
76+
};
77+
}
10378

104-
for (const currentSibling of siblings) {
105-
if (equals(element, currentSibling)) {
106-
break;
107-
}
108-
if (
109-
adapter.isTag(currentSibling) &&
110-
adapter.getName(currentSibling) === adapter.getName(element)
111-
) {
112-
pos++;
113-
}
114-
}
79+
/**
80+
* Pre-compiled pseudo filters.
81+
*/
82+
export const filters: Record<string, Filter> = {
83+
contains(next, text, options) {
84+
const { getText } = options.adapter;
11585

116-
return nthCheck(pos) && next(element);
117-
};
86+
return cacheParentResults(next, options, (element) =>
87+
getText(element).includes(text),
88+
);
11889
},
119-
"nth-last-of-type"(next, rule, { adapter, equals }) {
120-
const nthCheck = getNCheck(rule);
121-
122-
if (nthCheck === boolbase.falseFunc) {
123-
return boolbase.falseFunc;
124-
}
125-
if (nthCheck === boolbase.trueFunc) {
126-
return (element) =>
127-
getElementParent(element, adapter) !== null && next(element);
128-
}
129-
130-
return function nthLastOfType(element) {
131-
const siblings = adapter.getSiblings(element);
132-
let pos = 0;
133-
134-
for (let index = siblings.length - 1; index >= 0; index--) {
135-
const currentSibling = siblings[index];
136-
if (equals(element, currentSibling)) {
137-
break;
138-
}
139-
if (
140-
adapter.isTag(currentSibling) &&
141-
adapter.getName(currentSibling) === adapter.getName(element)
142-
) {
143-
pos++;
144-
}
145-
}
90+
icontains(next, text, options) {
91+
const itext = text.toLowerCase();
92+
const { getText } = options.adapter;
14693

147-
return nthCheck(pos) && next(element);
148-
};
94+
return cacheParentResults(next, options, (element) =>
95+
getText(element).toLowerCase().includes(itext),
96+
);
14997
},
15098

99+
// Location specific methods
100+
"nth-child": compileNth(false, false),
101+
"nth-last-child": compileNth(true, false),
102+
"nth-of-type": compileNth(false, true),
103+
"nth-last-of-type": compileNth(true, true),
104+
151105
// TODO determine the actual root element
152106
root(next, _rule, { adapter }) {
153107
return (element) =>

src/pseudo-selectors/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,13 @@ export function compilePseudoSelector<Node, ElementNode extends Node>(
6767
}
6868

6969
if (name in filters) {
70-
return filters[name](next, data as string, options, context);
70+
return filters[name](
71+
next,
72+
data as string,
73+
options,
74+
context,
75+
compileToken,
76+
);
7177
}
7278

7379
if (name in pseudos) {

test/pseudo-classes.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,98 @@ describe(":has", () => {
206206
});
207207
});
208208

209+
describe(":enabled", () => {
210+
it("should match form-associated elements", () => {
211+
const dom = parseDocument(`
212+
<div>
213+
<input type="text">
214+
<button>Click</button>
215+
<select><option>A</option></select>
216+
<textarea></textarea>
217+
<fieldset></fieldset>
218+
</div>
219+
`);
220+
const matches = CSSselect.selectAll(":enabled", dom);
221+
// Input, button, select, option, textarea, fieldset
222+
expect(matches).toHaveLength(6);
223+
});
224+
225+
it("should not match disabled elements", () => {
226+
const dom = parseDocument(
227+
"<input disabled><button disabled>X</button>",
228+
);
229+
expect(CSSselect.selectAll(":enabled", dom)).toHaveLength(0);
230+
});
231+
232+
it("should not match non-form elements", () => {
233+
const dom = parseDocument("<div></div><span></span><p></p>");
234+
expect(CSSselect.selectAll(":enabled", dom)).toHaveLength(0);
235+
});
236+
});
237+
238+
describe(":nth-child(An+B of S)", () => {
239+
it("should select odd elements matching selector", () => {
240+
const dom = parseDocument(`
241+
<div>
242+
<span class="a">1</span>
243+
<span>2</span>
244+
<span class="a">3</span>
245+
<span>4</span>
246+
<span class="a">5</span>
247+
</div>
248+
`);
249+
// Among .a elements (1st, 3rd, 5th spans), select odd (1st and 3rd .a)
250+
const matches = CSSselect.selectAll(":nth-child(odd of .a)", dom);
251+
expect(matches).toHaveLength(2);
252+
});
253+
254+
it("should select the 2nd element matching selector", () => {
255+
const dom = parseDocument(`
256+
<ul>
257+
<li class="important">A</li>
258+
<li>B</li>
259+
<li class="important">C</li>
260+
<li>D</li>
261+
<li class="important">E</li>
262+
</ul>
263+
`);
264+
const matches = CSSselect.selectAll(":nth-child(2 of .important)", dom);
265+
expect(matches).toHaveLength(1);
266+
});
267+
268+
it("should not match elements that don't match the selector", () => {
269+
const dom = parseDocument(`
270+
<div>
271+
<span class="a">1</span>
272+
<span class="b">2</span>
273+
</div>
274+
`);
275+
const matches = CSSselect.selectAll(":nth-child(1 of .b)", dom);
276+
expect(matches).toHaveLength(1);
277+
// .b is the 2nd span, but 1st among .b elements
278+
});
279+
280+
it("should work with :nth-last-child(An+B of S)", () => {
281+
const dom = parseDocument(`
282+
<div>
283+
<span class="a">1</span>
284+
<span>2</span>
285+
<span class="a">3</span>
286+
<span>4</span>
287+
<span class="a">5</span>
288+
</div>
289+
`);
290+
const matches = CSSselect.selectAll(":nth-last-child(1 of .a)", dom);
291+
expect(matches).toHaveLength(1);
292+
});
293+
294+
it("should fall back to normal nth-child without 'of' clause", () => {
295+
const dom = parseDocument("<div><p>a</p><p>b</p><p>c</p></div>");
296+
const matches = CSSselect.selectAll(":nth-child(2)", dom);
297+
expect(matches).toHaveLength(1);
298+
});
299+
});
300+
209301
describe(":read-only and :read-write", () => {
210302
it("should match", () => {
211303
const dom = parseDocument(`

0 commit comments

Comments
 (0)