mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-14 05:37:20 +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"]`.
|
||||
|
||||
## 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
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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 {
|
||||
const result = parseSelectorV1(selector);
|
||||
|
@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser';
|
||||
import { customCSSNames } from '../common/selectorParser';
|
||||
|
||||
export type QueryContext = {
|
||||
scope: Element | Document;
|
||||
@ -45,7 +46,6 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||
private _scoreMap: Map<Element, number> | undefined;
|
||||
|
||||
constructor(extraEngines: Map<string, SelectorEngine>) {
|
||||
// Note: keep predefined names in sync with Selectors class.
|
||||
for (const [name, engine] of extraEngines)
|
||||
this._engines.set(name, engine);
|
||||
this._engines.set('not', notEngine);
|
||||
@ -63,6 +63,14 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||
this._engines.set('above', createPositionEngine('above', boxAbove));
|
||||
this._engines.set('below', createPositionEngine('below', boxBelow));
|
||||
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
|
||||
@ -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 {
|
||||
if (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');
|
||||
});
|
||||
|
||||
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}) => {
|
||||
/*
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user