feat(selectors): proximity selectors (#4923)
This commit is contained in:
parent
ffa169ba92
commit
eb9ea20511
|
|
@ -17,9 +17,7 @@ Playwright also supports the following CSS extensions:
|
|||
* `:text("string")` - Matches elements that contain specific text node. Learn more about [text selector](./selectors.md#css-extension-text).
|
||||
* `:visible` - Matches only visible elements. Learn more about [visible selector](./selectors.md#css-extension-visible).
|
||||
* `:light(selector)` - Matches in the light DOM only as opposite to piercing open shadow roots. Learn more about [shadow piercing](./selectors.md#shadow-piercing).
|
||||
<!--
|
||||
* `:right-of(selector)`, `:left-of(selector)`, `:above(selector)`, `:below(selector)`, `:near(selector)`, `:within(selector)` - Match elements based on their relative position to another element. Learn more about [proximity selectors](./selectors.md#css-extension-proximity).
|
||||
-->
|
||||
* `:right-of(selector)`, `:left-of(selector)`, `:above(selector)`, `:below(selector)`, `:near(selector)` - Match elements based on their relative position to another element. Learn more about [proximity selectors](./selectors.md#css-extension-proximity).
|
||||
|
||||
For convenience, selectors in the wrong format are heuristically converted to the right format:
|
||||
- selector starting with `//` or `..` is assumed to be `xpath=selector`;
|
||||
|
|
@ -285,19 +283,18 @@ await page.click('button:text("Sign in")');
|
|||
await page.click(':light(.article > .header)');
|
||||
```
|
||||
|
||||
<!--
|
||||
#### CSS extension: proximity
|
||||
|
||||
Playwright provides a few proximity selectors based on the page layout. These can be combined with regular CSS for better results, for example `input:right-of(:text("Password"))` matches an input field that is to the right of text "Password".
|
||||
|
||||
Note that Playwright uses some heuristics to determine whether one element should be considered to the left/right/above/below/near/within another. Therefore, using proximity selectors may produce unpredictable results. For example, selector could stop matching when element moves by one pixel.
|
||||
Note that proximity selectors depend on the page layout and may produce unexpected results. For example, a different element could be matched when layout changes by one pixel.
|
||||
|
||||
* `:right-of(css > selector)` - Matches elements that are to the right of any element matching the inner selector.
|
||||
* `:left-of(css > selector)` - Matches elements that are to the left of any element matching the inner selector.
|
||||
* `:above(css > selector)` - Matches elements that are above any of the elements matching the inner selector.
|
||||
* `:below(css > selector)` - Matches elements that are below any of the elements matching the inner selector.
|
||||
* `:near(css > selector)` - Matches elements that are near any of the elements matching the inner selector.
|
||||
* `:within(css > selector)` - Matches elements that are within any of the elements matching the inner selector.
|
||||
Proximity selectors use [bounding client rect](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) to compute distance and relative position of the elements.
|
||||
* `:right-of(inner > selector)` - Matches elements that are to the right of any element matching the inner selector.
|
||||
* `:left-of(inner > selector)` - Matches elements that are to the left of any element matching the inner selector.
|
||||
* `:above(inner > selector)` - Matches elements that are above any of the elements matching the inner selector.
|
||||
* `:below(inner > selector)` - Matches elements that are below any of the elements matching the inner selector.
|
||||
* `:near(inner > selector)` - Matches elements that are near (within 50 CSS pixels) any of the elements matching the inner selector.
|
||||
|
||||
```js
|
||||
// Fill an input to the right of "Username".
|
||||
|
|
@ -306,7 +303,6 @@ await page.fill('input:right-of(:text("Username"))');
|
|||
// Click a button near the promo card.
|
||||
await page.click('button:near(.promo-card)');
|
||||
```
|
||||
-->
|
||||
|
||||
### xpath
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export type ParsedSelector = {
|
|||
capture?: number,
|
||||
};
|
||||
|
||||
const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is']);
|
||||
const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'above', 'below', 'right-of', 'left-of', 'near']);
|
||||
|
||||
export function parseSelector(selector: string): ParsedSelector {
|
||||
const result = parseSelectorV1(selector);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||
private _cacheCallMatches: QueryCache = new Map();
|
||||
private _cacheCallQuery: QueryCache = new Map();
|
||||
private _cacheQuerySimple: QueryCache = new Map();
|
||||
private _scoreMap: Map<Element, number> | undefined;
|
||||
|
||||
constructor(extraEngines: Map<string, SelectorEngine>) {
|
||||
// Note: keep predefined names in sync with Selectors class.
|
||||
|
|
@ -58,6 +59,11 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||
this._engines.set('text-is', textIsEngine);
|
||||
this._engines.set('text-matches', textMatchesEngine);
|
||||
this._engines.set('xpath', xpathEngine);
|
||||
this._engines.set('right-of', createProximityEngine('right-of', boxRightOf));
|
||||
this._engines.set('left-of', createProximityEngine('left-of', boxLeftOf));
|
||||
this._engines.set('above', createProximityEngine('above', boxAbove));
|
||||
this._engines.set('below', createProximityEngine('below', boxBelow));
|
||||
this._engines.set('near', createProximityEngine('near', boxNear));
|
||||
for (const attr of ['id', 'data-testid', 'data-test-id', 'data-test'])
|
||||
this._engines.set(attr, createAttributeEngine(attr));
|
||||
}
|
||||
|
|
@ -113,11 +119,38 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||
return this._cached<Element[]>(this._cacheQuery, selector, [context], () => {
|
||||
if (Array.isArray(selector))
|
||||
return this._queryEngine(isEngine, context, selector);
|
||||
const elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector);
|
||||
return elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context));
|
||||
|
||||
// query() recursively calls itself, so we set up a new map for this particular query() call.
|
||||
const previousScoreMap = this._scoreMap;
|
||||
this._scoreMap = new Map();
|
||||
let elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector);
|
||||
elements = elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context));
|
||||
if (this._scoreMap.size) {
|
||||
elements.sort((a, b) => {
|
||||
const aScore = this._scoreMap!.get(a);
|
||||
const bScore = this._scoreMap!.get(b);
|
||||
if (aScore === bScore)
|
||||
return 0;
|
||||
if (aScore === undefined)
|
||||
return 1;
|
||||
if (bScore === undefined)
|
||||
return -1;
|
||||
return aScore - bScore;
|
||||
});
|
||||
}
|
||||
this._scoreMap = previousScoreMap;
|
||||
|
||||
return elements;
|
||||
});
|
||||
}
|
||||
|
||||
_markScore(element: Element, score: number) {
|
||||
// HACK ALERT: temporary marks an element with a score, to be used
|
||||
// for sorting at the end of the query().
|
||||
if (this._scoreMap)
|
||||
this._scoreMap.set(element, score);
|
||||
}
|
||||
|
||||
private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean {
|
||||
return this._cached<boolean>(this._cacheMatchesSimple, element, [simple, context], () => {
|
||||
const isPossiblyScopeClause = simple.functions.some(f => f.name === 'scope' || f.name === 'is');
|
||||
|
|
@ -455,6 +488,68 @@ function createAttributeEngine(attr: string): SelectorEngine {
|
|||
};
|
||||
}
|
||||
|
||||
function boxRightOf(box1: DOMRect, box2: DOMRect): number | undefined {
|
||||
if (box1.left < box2.right)
|
||||
return;
|
||||
return (box1.left - box2.right) + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0);
|
||||
}
|
||||
|
||||
function boxLeftOf(box1: DOMRect, box2: DOMRect): number | undefined {
|
||||
if (box1.right > box2.left)
|
||||
return;
|
||||
return (box2.left - box1.right) + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0);
|
||||
}
|
||||
|
||||
function boxAbove(box1: DOMRect, box2: DOMRect): number | undefined {
|
||||
if (box1.bottom > box2.top)
|
||||
return;
|
||||
return (box2.top - box1.bottom) + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0);
|
||||
}
|
||||
|
||||
function boxBelow(box1: DOMRect, box2: DOMRect): number | undefined {
|
||||
if (box1.top < box2.bottom)
|
||||
return;
|
||||
return (box1.top - box2.bottom) + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0);
|
||||
}
|
||||
|
||||
function boxNear(box1: DOMRect, box2: DOMRect): number | undefined {
|
||||
const kThreshold = 50;
|
||||
let score = 0;
|
||||
if (box1.left - box2.right >= 0)
|
||||
score += box1.left - box2.right;
|
||||
if (box2.left - box1.right >= 0)
|
||||
score += box2.left - box1.right;
|
||||
if (box2.top - box1.bottom >= 0)
|
||||
score += box2.top - box1.bottom;
|
||||
if (box1.top - box2.bottom >= 0)
|
||||
score += box1.top - box2.bottom;
|
||||
return score > kThreshold ? undefined : score;
|
||||
}
|
||||
|
||||
function createProximityEngine(name: string, scorer: (box1: DOMRect, box2: DOMRect) => number | undefined): SelectorEngine {
|
||||
return {
|
||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||
if (!args.length)
|
||||
throw new Error(`"${name}" engine expects a selector list`);
|
||||
const box = element.getBoundingClientRect();
|
||||
let bestScore: number | undefined;
|
||||
for (const e of evaluator.query(context, args)) {
|
||||
if (e === element)
|
||||
continue;
|
||||
const score = scorer(box, e.getBoundingClientRect());
|
||||
if (score === undefined)
|
||||
continue;
|
||||
if (bestScore === undefined || score < bestScore)
|
||||
bestScore = score;
|
||||
}
|
||||
if (bestScore === undefined)
|
||||
return false;
|
||||
(evaluator as SelectorEvaluatorImpl)._markScore(element, bestScore);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function parentElementOrShadowHost(element: Element): Element | undefined {
|
||||
if (element.parentElement)
|
||||
return element.parentElement;
|
||||
|
|
|
|||
|
|
@ -47,9 +47,7 @@ it('should work with :visible', async ({page}) => {
|
|||
expect(await page.$eval('div:visible', div => div.id)).toBe('target2');
|
||||
});
|
||||
|
||||
it('should work with proximity selectors', test => {
|
||||
test.skip('Not ready yet');
|
||||
}, async ({page}) => {
|
||||
it('should work with proximity selectors', async ({page}) => {
|
||||
/*
|
||||
|
||||
+--+ +--+
|
||||
|
|
@ -104,46 +102,47 @@ it('should work with proximity selectors', test => {
|
|||
}
|
||||
}, boxes);
|
||||
|
||||
expect(await page.$eval('div:within(#id0)', e => e.id)).toBe('id6');
|
||||
expect(await page.$eval('div:within(div)', e => e.id)).toBe('id6');
|
||||
expect(await page.$('div:within(#id6)')).toBe(null);
|
||||
expect(await page.$$eval('div:within(#id0)', els => els.map(e => e.id).join(','))).toBe('id6');
|
||||
|
||||
expect(await page.$eval('div:right-of(#id6)', e => e.id)).toBe('id7');
|
||||
expect(await page.$eval('div:right-of(#id1)', e => e.id)).toBe('id2');
|
||||
expect(await page.$eval('div:right-of(#id3)', e => e.id)).toBe('id2');
|
||||
expect(await page.$eval('div:right-of(#id3)', e => e.id)).toBe('id4');
|
||||
expect(await page.$('div:right-of(#id4)')).toBe(null);
|
||||
expect(await page.$eval('div:right-of(#id0)', e => e.id)).toBe('id4');
|
||||
expect(await page.$eval('div:right-of(#id0)', e => e.id)).toBe('id7');
|
||||
expect(await page.$eval('div:right-of(#id8)', e => e.id)).toBe('id9');
|
||||
expect(await page.$$eval('div:right-of(#id3)', els => els.map(e => e.id).join(','))).toBe('id2,id5');
|
||||
expect(await page.$$eval('div:right-of(#id3)', els => els.map(e => e.id).join(','))).toBe('id4,id2,id5,id7,id8,id9');
|
||||
|
||||
expect(await page.$eval('div:left-of(#id2)', e => e.id)).toBe('id1');
|
||||
expect(await page.$('div:left-of(#id0)')).toBe(null);
|
||||
expect(await page.$eval('div:left-of(#id5)', e => e.id)).toBe('id0');
|
||||
expect(await page.$eval('div:left-of(#id9)', e => e.id)).toBe('id8');
|
||||
expect(await page.$eval('div:left-of(#id4)', e => e.id)).toBe('id0');
|
||||
expect(await page.$$eval('div:left-of(#id5)', els => els.map(e => e.id).join(','))).toBe('id0,id3,id7');
|
||||
expect(await page.$eval('div:left-of(#id4)', e => e.id)).toBe('id3');
|
||||
expect(await page.$$eval('div:left-of(#id5)', els => els.map(e => e.id).join(','))).toBe('id0,id7,id3,id1,id6,id8');
|
||||
|
||||
expect(await page.$eval('div:above(#id0)', e => e.id)).toBe('id1');
|
||||
expect(await page.$eval('div:above(#id5)', e => e.id)).toBe('id2');
|
||||
expect(await page.$eval('div:above(#id7)', e => e.id)).toBe('id3');
|
||||
expect(await page.$eval('div:above(#id0)', e => e.id)).toBe('id3');
|
||||
expect(await page.$eval('div:above(#id5)', e => e.id)).toBe('id4');
|
||||
expect(await page.$eval('div:above(#id7)', e => e.id)).toBe('id5');
|
||||
expect(await page.$eval('div:above(#id8)', e => e.id)).toBe('id0');
|
||||
expect(await page.$eval('div:above(#id9)', e => e.id)).toBe('id8');
|
||||
expect(await page.$('div:above(#id2)')).toBe(null);
|
||||
expect(await page.$('div:above(#id9)')).toBe(null);
|
||||
expect(await page.$$eval('div:above(#id5)', els => els.map(e => e.id).join(','))).toBe('id2,id4');
|
||||
expect(await page.$$eval('div:above(#id5)', els => els.map(e => e.id).join(','))).toBe('id4,id2,id3,id1');
|
||||
|
||||
expect(await page.$eval('div:below(#id4)', e => e.id)).toBe('id5');
|
||||
expect(await page.$eval('div:below(#id3)', e => e.id)).toBe('id0');
|
||||
expect(await page.$eval('div:below(#id2)', e => e.id)).toBe('id4');
|
||||
expect(await page.$eval('div:below(#id6)', e => e.id)).toBe('id8');
|
||||
expect(await page.$eval('div:below(#id7)', e => e.id)).toBe('id8');
|
||||
expect(await page.$eval('div:below(#id8)', e => e.id)).toBe('id9');
|
||||
expect(await page.$('div:below(#id9)')).toBe(null);
|
||||
expect(await page.$('div:below(#id7)')).toBe(null);
|
||||
expect(await page.$('div:below(#id8)')).toBe(null);
|
||||
expect(await page.$('div:below(#id6)')).toBe(null);
|
||||
expect(await page.$$eval('div:below(#id3)', els => els.map(e => e.id).join(','))).toBe('id0,id6,id7');
|
||||
expect(await page.$$eval('div:below(#id3)', els => els.map(e => e.id).join(','))).toBe('id0,id5,id6,id7,id8,id9');
|
||||
|
||||
expect(await page.$eval('div:near(#id0)', e => e.id)).toBe('id1');
|
||||
expect(await page.$$eval('div:near(#id7)', els => els.map(e => e.id).join(','))).toBe('id0,id3,id4,id5,id6');
|
||||
expect(await page.$$eval('div:near(#id0)', els => els.map(e => e.id).join(','))).toBe('id1,id2,id3,id4,id5,id7,id8,id9');
|
||||
expect(await page.$eval('div:near(#id0)', e => e.id)).toBe('id3');
|
||||
expect(await page.$$eval('div:near(#id7)', els => els.map(e => e.id).join(','))).toBe('id0,id5,id3,id6');
|
||||
expect(await page.$$eval('div:near(#id0)', els => els.map(e => e.id).join(','))).toBe('id3,id6,id7,id8,id1,id5');
|
||||
expect(await page.$$eval('div:near(#id6)', els => els.map(e => e.id).join(','))).toBe('id0,id3,id7');
|
||||
|
||||
expect(await page.$$eval('div:below(#id5):above(#id8)', els => els.map(e => e.id).join(','))).toBe('id7,id6');
|
||||
expect(await page.$eval('div:below(#id5):above(#id8)', e => e.id)).toBe('id7');
|
||||
|
||||
expect(await page.$$eval('div:right-of(#id0) + div:above(#id8)', els => els.map(e => e.id).join(','))).toBe('id5,id6,id3');
|
||||
});
|
||||
|
||||
it('should escape the scope with >>', async ({ page }) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue