mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-14 21:53:35 +03:00
feat(selectors): proximity selectors (#4614)
This includes 'left-of', 'right-of', 'above', 'below', 'near' and 'within'.
This commit is contained in:
parent
c36f5fa33a
commit
1e754a4d80
@ -60,6 +60,12 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||
this._engines.set('xpath', xpathEngine);
|
||||
for (const attr of ['id', 'data-testid', 'data-test-id', 'data-test'])
|
||||
this._engines.set(attr, createAttributeEngine(attr));
|
||||
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));
|
||||
this._engines.set('within', createProximityEngine('within', boxWithin));
|
||||
}
|
||||
|
||||
// This is the only function we should use for querying, because it does
|
||||
@ -454,6 +460,70 @@ function createAttributeEngine(attr: string): SelectorEngine {
|
||||
};
|
||||
}
|
||||
|
||||
function areCloseRanges(from1: number, to1: number, from2: number, to2: number, threshold: number) {
|
||||
return to1 >= from2 - threshold && to2 >= from1 - threshold;
|
||||
}
|
||||
|
||||
function boxSize(box: DOMRect) {
|
||||
return Math.sqrt(box.width * box.height);
|
||||
}
|
||||
|
||||
function boxesProximityThreshold(box1: DOMRect, box2: DOMRect) {
|
||||
return (boxSize(box1) + boxSize(box2)) / 2;
|
||||
}
|
||||
|
||||
function boxRightOf(box1: DOMRect, box2: DOMRect): boolean {
|
||||
// To the right, but not too far, and vertically intersects.
|
||||
const distance = box1.left - box2.right;
|
||||
return distance >= 0 && distance <= boxesProximityThreshold(box1, box2) &&
|
||||
areCloseRanges(box1.top, box1.bottom, box2.top, box2.bottom, 0);
|
||||
}
|
||||
|
||||
function boxLeftOf(box1: DOMRect, box2: DOMRect): boolean {
|
||||
// To the left, but not too far, and vertically intersects.
|
||||
const distance = box2.left - box1.right;
|
||||
return distance >= 0 && distance <= boxesProximityThreshold(box1, box2) &&
|
||||
areCloseRanges(box1.top, box1.bottom, box2.top, box2.bottom, 0);
|
||||
}
|
||||
|
||||
function boxAbove(box1: DOMRect, box2: DOMRect): boolean {
|
||||
// Above, but not too far, and horizontally intersects.
|
||||
const distance = box2.top - box1.bottom;
|
||||
return distance >= 0 && distance <= boxesProximityThreshold(box1, box2) &&
|
||||
areCloseRanges(box1.left, box1.right, box2.left, box2.right, 0);
|
||||
}
|
||||
|
||||
function boxBelow(box1: DOMRect, box2: DOMRect): boolean {
|
||||
// Below, but not too far, and horizontally intersects.
|
||||
const distance = box1.top - box2.bottom;
|
||||
return distance >= 0 && distance <= boxesProximityThreshold(box1, box2) &&
|
||||
areCloseRanges(box1.left, box1.right, box2.left, box2.right, 0);
|
||||
}
|
||||
|
||||
function boxWithin(box1: DOMRect, box2: DOMRect): boolean {
|
||||
return box1.left >= box2.left && box1.right <= box2.right && box1.top >= box2.top && box1.bottom <= box2.bottom;
|
||||
}
|
||||
|
||||
function boxNear(box1: DOMRect, box2: DOMRect): boolean {
|
||||
const intersects = !(box1.left >= box2.right || box2.left >= box1.right || box1.top >= box2.bottom || box2.top >= box1.bottom);
|
||||
if (intersects)
|
||||
return false;
|
||||
const threshold = boxesProximityThreshold(box1, box2);
|
||||
return areCloseRanges(box1.left, box1.right, box2.left, box2.right, threshold) &&
|
||||
areCloseRanges(box1.top, box1.bottom, box2.top, box2.bottom, threshold);
|
||||
}
|
||||
|
||||
function createProximityEngine(name: string, predicate: (box1: DOMRect, box2: DOMRect) => boolean): 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();
|
||||
return evaluator.query(context, args).some(e => e !== element && predicate(box, e.getBoundingClientRect()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parentElementOrShadowHost(element: Element): Element | undefined {
|
||||
if (element.parentElement)
|
||||
return element.parentElement;
|
||||
|
@ -42,7 +42,7 @@ export class Selectors {
|
||||
'data-test', 'data-test:light',
|
||||
]);
|
||||
if (selectorsV2Enabled()) {
|
||||
for (const name of ['not', 'is', 'where', 'has', 'scope', 'light', 'index', 'visible', 'matches-text'])
|
||||
for (const name of ['not', 'is', 'where', 'has', 'scope', 'light', 'index', 'visible', 'matches-text', 'above', 'below', 'right-of', 'left-of', 'near', 'within'])
|
||||
this._builtinEngines.add(name);
|
||||
}
|
||||
this._engines = new Map();
|
||||
|
@ -75,3 +75,103 @@ it('should work with :visible', async ({page}) => {
|
||||
|
||||
expect(await page.$eval('div:visible', div => div.id)).toBe('target2');
|
||||
});
|
||||
|
||||
it('should work with proximity selectors', async ({page}) => {
|
||||
if (!selectorsV2Enabled())
|
||||
return; // Selectors v1 do not support this.
|
||||
|
||||
/*
|
||||
|
||||
+--+ +--+
|
||||
| 1| | 2|
|
||||
+--+ ++-++
|
||||
| 3| | 4|
|
||||
+-------+ ++-++
|
||||
| 0 | | 5|
|
||||
| +--+ +--+--+
|
||||
| | 6| | 7|
|
||||
| +--+ +--+
|
||||
| |
|
||||
O-------+
|
||||
+--+
|
||||
| 8|
|
||||
+--++--+
|
||||
| 9|
|
||||
+--+
|
||||
|
||||
*/
|
||||
|
||||
const boxes = [
|
||||
// x, y, width, height
|
||||
[0, 0, 150, 150],
|
||||
[100, 200, 50, 50],
|
||||
[200, 200, 50, 50],
|
||||
[100, 150, 50, 50],
|
||||
[201, 150, 50, 50],
|
||||
[200, 100, 50, 50],
|
||||
[50, 50, 50, 50],
|
||||
[150, 50, 50, 50],
|
||||
[150, -51, 50, 50],
|
||||
[201, -101, 50, 50],
|
||||
];
|
||||
await page.setContent(`<container style="width: 500px; height: 500px; position: relative;"></container>`);
|
||||
await page.$eval('container', (container, boxes) => {
|
||||
for (let i = 0; i < boxes.length; i++) {
|
||||
const div = document.createElement('div');
|
||||
div.style.position = 'absolute';
|
||||
div.style.overflow = 'hidden';
|
||||
div.style.boxSizing = 'border-box';
|
||||
div.style.border = '1px solid black';
|
||||
div.id = 'id' + i;
|
||||
div.textContent = 'id' + i;
|
||||
const box = boxes[i];
|
||||
div.style.left = box[0] + 'px';
|
||||
// Note that top is a flipped y coordinate.
|
||||
div.style.top = (250 - box[1] - box[3]) + 'px';
|
||||
div.style.width = box[2] + 'px';
|
||||
div.style.height = box[3] + 'px';
|
||||
container.appendChild(div);
|
||||
}
|
||||
}, 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.$('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(#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: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: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(#id8)', e => e.id)).toBe('id0');
|
||||
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: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.$('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: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');
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user