Skip to content

Commit

Permalink
feat: Allow type() and click() to select an element inside contentedi…
Browse files Browse the repository at this point in the history
…table. (#9066)

Co-authored-by: Ben Kucera <[email protected]>
  • Loading branch information
sainthkh and kuceb authored Nov 17, 2020
1 parent e2a5de0 commit c385273
Show file tree
Hide file tree
Showing 4 changed files with 59 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1766,20 +1766,14 @@ describe('src/cy/commands/actions/type - #type', () => {
cy.state('document').documentElement.focus()
cy.get('div.item:first')
.type('111')

cy.get('body').then(expectTextEndsWith('111'))
.then(expectTextEndsWith('111'))
})

// TODO[breaking]: we should edit div.item:first text content instead of
// moving to the end of the host contenteditable. This will allow targeting
// specific elements to simplify testing rich editors
it('can type in body[contenteditable]', () => {
cy.state('document').body.setAttribute('contenteditable', true)
cy.state('document').documentElement.focus()
cy.get('div.item:first')
.type('111')

cy.get('body')
.then(expectTextEndsWith('111'))
})

Expand Down
11 changes: 10 additions & 1 deletion packages/driver/src/cy/actionability.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const Promise = require('bluebird')
const debug = require('debug')('cypress:driver:actionability')

const $dom = require('../dom')
const $elements = require('../dom/elements')
const $errUtils = require('../cypress/error_utils')

const delay = 50
Expand Down Expand Up @@ -360,8 +361,16 @@ const verify = function (cy, $el, options, callbacks) {
}

// pass our final object into onReady
const finalEl = $elAtCoords != null ? $elAtCoords : $el
const finalCoords = getCoordinatesForEl(cy, $el, options)
let finalEl

// When a contenteditable element is selected, we don't go deeper,
// because it is treated as a rich text field to users.
if ($elements.hasContenteditableAttr($el.get(0))) {
finalEl = $el
} else {
finalEl = $elAtCoords != null ? $elAtCoords : $el
}

return onReady(finalEl, finalCoords)
}
Expand Down
7 changes: 7 additions & 0 deletions packages/driver/src/dom/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,12 @@ const findShadowRoots = (root: Node): Node[] => {
return collectRoots(roots)
}

const hasContenteditableAttr = (el: HTMLElement) => {
const attr = tryCallNativeMethod(el, 'getAttribute', 'contenteditable')

return attr !== undefined && attr !== null && attr !== 'false'
}

export {
elementFromPoint,
isElement,
Expand Down Expand Up @@ -1372,4 +1378,5 @@ export {
getParentNode,
getAllParents,
getShadowRoot,
hasContenteditableAttr,
}
72 changes: 41 additions & 31 deletions packages/driver/src/dom/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,22 +127,16 @@ const insertSubstring = (curText, newText, [start, end]) => {
return curText.substring(0, start) + newText + curText.substring(end)
}

const _hasContenteditableAttr = (el) => {
const attr = $elements.tryCallNativeMethod(el, 'getAttribute', 'contenteditable')

return attr !== undefined && attr !== null && attr !== 'false'
}

const getHostContenteditable = function (el: HTMLElement) {
let curEl = el

while (curEl.parentElement && !_hasContenteditableAttr(curEl)) {
while (curEl.parentElement && !$elements.hasContenteditableAttr(curEl)) {
curEl = curEl.parentElement
}

// if there's no host contenteditable, we must be in designMode
// so act as if the documentElement (html element) is the host contenteditable
if (!_hasContenteditableAttr(curEl)) {
if (!$elements.hasContenteditableAttr(curEl)) {
return $document.getDocumentFromElement(el).documentElement
}

Expand Down Expand Up @@ -569,35 +563,51 @@ const _moveSelectionTo = function (toStart: boolean, el: HTMLElement, options =
return
}

if (Cypress.isBrowser({ family: 'firefox' })) {
// FireFox doesn't treat a selectAll+arrow the same as clicking the start/end of a contenteditable
// so we need to select the specific nodes inside the contenteditable.
const root = getHostContenteditable(el)

let elToSelect = root.childNodes[toStart ? 0 : root.childNodes.length - 1]

// in firefox, when an empty contenteditable is a single <br> element or <div><br/></div>
// its innerText will be '\n' (maybe find a more efficient measure)
if (!elToSelect || root.innerText === '\n') {
// we must be in an empty contenteditable, so we're already at both the start and end
return
}

// if we're on a <br> but the text isn't empty, we need to
if ($elements.getTagName(elToSelect) === 'br') {
if (root.childNodes.length < 2) {
// no other node to target, shouldn't really happen but we should behave like the contenteditable is empty
// We need to check if element is the root contenteditable element or elements inside it
// because they should be handled differently.
if ($elements.hasContenteditableAttr(el)) {
if (Cypress.isBrowser({ family: 'firefox' })) {
// FireFox doesn't treat a selectAll+arrow the same as clicking the start/end of a contenteditable
// so we need to select the specific nodes inside the contenteditable.
let elToSelect = el.childNodes[toStart ? 0 : el.childNodes.length - 1]

// in firefox, when an empty contenteditable is a single <br> element or <div><br/></div>
// its innerText will be '\n' (maybe find a more efficient measure)
if (!elToSelect || el.innerText === '\n') {
// we must be in an empty contenteditable, so we're already at both the start and end
return
}

elToSelect = toStart ? root.childNodes[1] : root.childNodes[root.childNodes.length - 2]
}
// if we're on a <br> but the text isn't empty, we need to
if ($elements.getTagName(elToSelect) === 'br') {
if (el.childNodes.length < 2) {
// no other node to target, shouldn't really happen but we should behave like the contenteditable is empty
return
}

elToSelect = toStart ? el.childNodes[1] : el.childNodes[el.childNodes.length - 2]
}

const range = selection.getRangeAt(0)
const range = selection.getRangeAt(0)

range.selectNodeContents(elToSelect)
range.selectNodeContents(elToSelect)
} else {
$elements.callNativeMethod(doc, 'execCommand', 'selectAll', false, null)
}
} else {
$elements.callNativeMethod(doc, 'execCommand', 'selectAll', false, null)
let range

// Sometimes, selection.rangeCount is 0 when there is no selection.
// In that case, it fails in Chrome.
// We're creating a new range and add it to the selection to avoid the case.
if (selection.rangeCount === 0) {
range = doc.createRange()
selection.addRange(range)
} else {
range = selection.getRangeAt(0)
}

range.selectNodeContents(el)
}

toStart ? selection.collapseToStart() : selection.collapseToEnd()
Expand Down

4 comments on commit c385273

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on c385273 Nov 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the linux x64 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/6.0.0/circle-v6.0-release-c38527359d56cf1db6bd54384627523253187c97/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on c385273 Nov 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppVeyor has built the win32 x64 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/6.0.0/appveyor-v6.0-release-c38527359d56cf1db6bd54384627523253187c97/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on c385273 Nov 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppVeyor has built the win32 ia32 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/6.0.0/appveyor-v6.0-release-c38527359d56cf1db6bd54384627523253187c97/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on c385273 Nov 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the darwin x64 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/6.0.0/circle-v6.0-release-c38527359d56cf1db6bd54384627523253187c97/cypress.tgz

Please sign in to comment.