feat(selectors): proximity selectors (#4614)

This includes 'left-of', 'right-of', 'above', 'below', 'near' and 'within'.
This commit is contained in:
Dmitry Gozman 2020-12-07 16:07:47 -08:00 committed by GitHub
parent c36f5fa33a
commit 1e754a4d80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 171 additions and 1 deletions

View File

@ -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;

View File

@ -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();

View File

@ -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');
});