mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-14 13:45:36 +03:00
feat(selectors): nth-match selector (#5081)
Introduces :nth-match(ul > li, 3) css extension, with one-based index.
This commit is contained in:
parent
8f06761ba1
commit
7a4b94e66c
@ -284,6 +284,59 @@ converts `'//html/body'` to `'xpath=//html/body'`.
|
|||||||
|
|
||||||
Attribute engines are selecting based on the corresponding attribute value. For example: `data-test-id=foo` is equivalent to `css=[data-test-id="foo"]`, and `id:light=foo` is equivalent to `css:light=[id="foo"]`.
|
Attribute engines are selecting based on the corresponding attribute value. For example: `data-test-id=foo` is equivalent to `css=[data-test-id="foo"]`, and `id:light=foo` is equivalent to `css:light=[id="foo"]`.
|
||||||
|
|
||||||
|
## Pick n-th match from the query result
|
||||||
|
|
||||||
|
Sometimes page contains a number of similar elements, and it is hard to select a particular one. For example:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section> <button>Buy</button> </section>
|
||||||
|
<article><div> <button>Buy</button> </div></article>
|
||||||
|
<div><div> <button>Buy</button> </div></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
In this case, `:nth-match(:text("Buy"), 3)` will select the third button from the snippet above. Note that index is one-based.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Click the third "Buy" button
|
||||||
|
await page.click(':nth-match(:text("Buy"), 3)');
|
||||||
|
```
|
||||||
|
|
||||||
|
```python async
|
||||||
|
# Click the third "Buy" button
|
||||||
|
await page.click(":nth-match(:text('Buy'), 3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
```python sync
|
||||||
|
# Click the third "Buy" button
|
||||||
|
page.click(":nth-match(:text('Buy'), 3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
`:nth-match()` is also useful to wait until a specified number of elements appear, using [`method: Page.waitForSelector`].
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Wait until all three buttons are visible
|
||||||
|
await page.waitForSelector(':nth-match(:text("Buy"), 3)');
|
||||||
|
```
|
||||||
|
|
||||||
|
```python async
|
||||||
|
# Wait until all three buttons are visible
|
||||||
|
await page.wait_for_selector(":nth-match(:text('Buy'), 3)")
|
||||||
|
```
|
||||||
|
|
||||||
|
```python sync
|
||||||
|
# Wait until all three buttons are visible
|
||||||
|
page.wait_for_selector(":nth-match(:text('Buy'), 3)")
|
||||||
|
```
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Unlike [`:nth-child()`](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-child), elements do not have to be siblings, they could be anywhere on the page. In the snippet above, all three buttons match `:text("Buy")` selector, and `:nth-match()` selects the third button.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::note
|
||||||
|
It is usually possible to distinguish elements by some attribute or text content. In this case,
|
||||||
|
prefer using [text] or [css] selectors over the `:nth-match()`.
|
||||||
|
:::
|
||||||
|
|
||||||
## Chaining selectors
|
## Chaining selectors
|
||||||
|
|
||||||
Selectors defined as `engine=body` or in short-form can be combined with the `>>` token, e.g. `selector1 >> selector2 >> selectors3`. When selectors are chained, next one is queried relative to the previous one's result.
|
Selectors defined as `engine=body` or in short-form can be combined with the `>>` token, e.g. `selector1 >> selector2 >> selectors3`. When selectors are chained, next one is queried relative to the previous one's result.
|
||||||
|
@ -26,7 +26,7 @@ export type ParsedSelector = {
|
|||||||
capture?: number,
|
capture?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'above', 'below', 'right-of', 'left-of', 'near']);
|
export const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'above', 'below', 'right-of', 'left-of', 'near', 'nth-match']);
|
||||||
|
|
||||||
export function parseSelector(selector: string): ParsedSelector {
|
export function parseSelector(selector: string): ParsedSelector {
|
||||||
const result = parseSelectorV1(selector);
|
const result = parseSelectorV1(selector);
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser';
|
import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser';
|
||||||
|
import { customCSSNames } from '../common/selectorParser';
|
||||||
|
|
||||||
export type QueryContext = {
|
export type QueryContext = {
|
||||||
scope: Element | Document;
|
scope: Element | Document;
|
||||||
@ -45,7 +46,6 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||||||
private _scoreMap: Map<Element, number> | undefined;
|
private _scoreMap: Map<Element, number> | undefined;
|
||||||
|
|
||||||
constructor(extraEngines: Map<string, SelectorEngine>) {
|
constructor(extraEngines: Map<string, SelectorEngine>) {
|
||||||
// Note: keep predefined names in sync with Selectors class.
|
|
||||||
for (const [name, engine] of extraEngines)
|
for (const [name, engine] of extraEngines)
|
||||||
this._engines.set(name, engine);
|
this._engines.set(name, engine);
|
||||||
this._engines.set('not', notEngine);
|
this._engines.set('not', notEngine);
|
||||||
@ -63,6 +63,14 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||||||
this._engines.set('above', createPositionEngine('above', boxAbove));
|
this._engines.set('above', createPositionEngine('above', boxAbove));
|
||||||
this._engines.set('below', createPositionEngine('below', boxBelow));
|
this._engines.set('below', createPositionEngine('below', boxBelow));
|
||||||
this._engines.set('near', createPositionEngine('near', boxNear));
|
this._engines.set('near', createPositionEngine('near', boxNear));
|
||||||
|
this._engines.set('nth-match', nthMatchEngine);
|
||||||
|
|
||||||
|
const allNames = Array.from(this._engines.keys());
|
||||||
|
allNames.sort();
|
||||||
|
const parserNames = Array.from(customCSSNames).slice();
|
||||||
|
parserNames.sort();
|
||||||
|
if (allNames.join('|') !== parserNames.join('|'))
|
||||||
|
throw new Error(`Please keep customCSSNames in sync with evaluator engines`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is the only function we should use for querying, because it does
|
// This is the only function we should use for querying, because it does
|
||||||
@ -513,6 +521,19 @@ function createPositionEngine(name: string, scorer: (box1: DOMRect, box2: DOMRec
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nthMatchEngine: SelectorEngine = {
|
||||||
|
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
|
||||||
|
let index = args[args.length - 1];
|
||||||
|
if (args.length < 2)
|
||||||
|
throw new Error(`"nth-match" engine expects non-empty selector list and an index argument`);
|
||||||
|
if (typeof index !== 'number' || index < 1)
|
||||||
|
throw new Error(`"nth-match" engine expects a one-based index as the last argument`);
|
||||||
|
const elements = isEngine.query!(context, args.slice(0, args.length - 1), evaluator);
|
||||||
|
index--; // one-based
|
||||||
|
return index < elements.length ? [elements[index]] : [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function parentElementOrShadowHost(element: Element): Element | undefined {
|
export function parentElementOrShadowHost(element: Element): Element | undefined {
|
||||||
if (element.parentElement)
|
if (element.parentElement)
|
||||||
return element.parentElement;
|
return element.parentElement;
|
||||||
|
@ -47,6 +47,41 @@ it('should work with :visible', async ({page}) => {
|
|||||||
expect(await page.$eval('div:visible', div => div.id)).toBe('target2');
|
expect(await page.$eval('div:visible', div => div.id)).toBe('target2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should work with :nth-match', async ({page}) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<section>
|
||||||
|
<div id=target1></div>
|
||||||
|
<div id=target2></div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
expect(await page.$(':nth-match(div, 3)')).toBe(null);
|
||||||
|
expect(await page.$eval(':nth-match(div, 1)', e => e.id)).toBe('target1');
|
||||||
|
expect(await page.$eval(':nth-match(div, 2)', e => e.id)).toBe('target2');
|
||||||
|
expect(await page.$eval(':nth-match(section > div, 2)', e => e.id)).toBe('target2');
|
||||||
|
expect(await page.$eval(':nth-match(section, div, 2)', e => e.id)).toBe('target1');
|
||||||
|
expect(await page.$eval(':nth-match(div, section, 3)', e => e.id)).toBe('target2');
|
||||||
|
expect(await page.$$eval(':is(:nth-match(div, 1), :nth-match(div, 2))', els => els.length)).toBe(2);
|
||||||
|
|
||||||
|
let error;
|
||||||
|
error = await page.$(':nth-match(div, bar, 0)').catch(e => e);
|
||||||
|
expect(error.message).toContain(`"nth-match" engine expects a one-based index as the last argument`);
|
||||||
|
|
||||||
|
error = await page.$(':nth-match(2)').catch(e => e);
|
||||||
|
expect(error.message).toContain(`"nth-match" engine expects non-empty selector list and an index argument`);
|
||||||
|
|
||||||
|
error = await page.$(':nth-match(div, bar, foo)').catch(e => e);
|
||||||
|
expect(error.message).toContain(`"nth-match" engine expects a one-based index as the last argument`);
|
||||||
|
|
||||||
|
const promise = page.waitForSelector(`:nth-match(div, 3)`, { state: 'attached' });
|
||||||
|
await page.$eval('section', section => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('id', 'target3');
|
||||||
|
section.appendChild(div);
|
||||||
|
});
|
||||||
|
const element = await promise;
|
||||||
|
expect(await element.evaluate(e => e.id)).toBe('target3');
|
||||||
|
});
|
||||||
|
|
||||||
it('should work with position selectors', async ({page}) => {
|
it('should work with position selectors', async ({page}) => {
|
||||||
/*
|
/*
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user