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,10 +119,37 @@ 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], () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -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