diff --git a/packages/driver/package.json b/packages/driver/package.json index 216b315fa817..3a743ae6c655 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "../coffee/node_modules/.bin/coffee test/support/server.coffee", "cypress:open": "node ../../cli/bin/cypress open --dev --project ./test", - "cypress:run": "node ../../scripts/run-cypress-tests.js --browser chrome --dir test", + "cypress:run": "node ../../scripts/run-cypress-tests.js --dir test", "clean-deps": "rm -rf node_modules" }, "files": [ @@ -53,9 +53,11 @@ "parse-domain": "2.0.0", "setimmediate": "^1.0.2", "sinon": "3.2.0", + "text-mask-addons": "^3.7.2", "underscore": "^1.8.3", "underscore.string": "3.3.4", "url-parse": "^1.1.7", + "vanilla-text-mask": "^5.1.1", "wait-on": "^2.0.2", "zone.js": "^0.8.18" } diff --git a/packages/driver/src/cy/actionability.coffee b/packages/driver/src/cy/actionability.coffee index 90346e3f0dec..c217e929352a 100644 --- a/packages/driver/src/cy/actionability.coffee +++ b/packages/driver/src/cy/actionability.coffee @@ -247,7 +247,7 @@ verify = (cy, $el, options, callbacks) -> ## then do not perform these additional ensures... if (force isnt true) and (options.waitForAnimations isnt false) ## store the coords that were absolute - ## from the window or from the viewport for sticky elements + ## from the window or from the viewport for sticky elements ## (see https://github.com/cypress-io/cypress/pull/1478) sticky = !!getStickyEl($el) diff --git a/packages/driver/src/cy/commands/actions/check.coffee b/packages/driver/src/cy/commands/actions/check.coffee index 43e79a694735..2c413ffbdcf1 100644 --- a/packages/driver/src/cy/commands/actions/check.coffee +++ b/packages/driver/src/cy/commands/actions/check.coffee @@ -4,6 +4,7 @@ Promise = require("bluebird") $dom = require("../../../dom") $utils = require("../../../cypress/utils") +$elements = require("../../../dom/elements") checkOrUncheck = (type, subject, values = [], options = {}) -> ## we're not handling conversion of values to strings @@ -46,13 +47,23 @@ checkOrUncheck = (type, subject, values = [], options = {}) -> ## in the values array? ## or values array is empty elHasMatchingValue = ($el) -> - values.length is 0 or $el.val() in values + value = $elements.getNativeProp($el.get(0), "value") + values.length is 0 or value in values ## blow up if any member of the subject ## isnt a checkbox or radio checkOrUncheckEl = (el, index) => $el = $(el) + if not isAcceptableElement($el) + node = $dom.stringify($el) + word = $utils.plural(options.$el, "contains", "is") + phrase = if type is "check" then " and :radio" else "" + $utils.throwErrByPath "check_uncheck.invalid_element", { + onFail: options._log + args: { node, word, phrase, cmd: type } + } + isElActionable = elHasMatchingValue($el) if isElActionable @@ -78,14 +89,6 @@ checkOrUncheck = (type, subject, values = [], options = {}) -> options._log.snapshot("before", {next: "after"}) - if not isAcceptableElement($el) - node = $dom.stringify($el) - word = $utils.plural(options.$el, "contains", "is") - phrase = if type is "check" then " and :radio" else "" - $utils.throwErrByPath "check_uncheck.invalid_element", { - onFail: options._log - args: { node, word, phrase, cmd: type } - } ## if the checkbox was already checked ## then notify the user of this note diff --git a/packages/driver/src/cy/commands/actions/click.coffee b/packages/driver/src/cy/commands/actions/click.coffee index d61f888def78..f42d0b21608d 100644 --- a/packages/driver/src/cy/commands/actions/click.coffee +++ b/packages/driver/src/cy/commands/actions/click.coffee @@ -6,6 +6,8 @@ $Mouse = require("../../../cypress/mouse") $dom = require("../../../dom") $utils = require("../../../cypress/utils") +$elements = require("../../../dom/elements") +$selection = require("../../../dom/selection") $actionability = require("../../actionability") module.exports = (Commands, Cypress, cy, state, config) -> @@ -183,7 +185,9 @@ module.exports = (Commands, Cypress, cy, state, config) -> onReady: ($elToClick, coords) -> ## TODO: get focused through a callback here $focused = cy.getFocused() - + + el = $elToClick.get(0) + ## record the previously focused element before ## issuing the mousedown because browsers may ## automatically shift the focus to the element @@ -199,11 +203,14 @@ module.exports = (Commands, Cypress, cy, state, config) -> if domEvents.mouseDown.preventedDefault or not $dom.isAttached($elToClick) afterMouseDown($elToClick, coords) else + if $elements.isInput(el) or $elements.isTextarea(el) or $elements.isContentEditable(el) + if !$elements.isNeedSingleValueChangeInputElement(el) + $selection.moveSelectionToEnd(el) + ## retrieve the first focusable $el in our parent chain $elToFocus = getFirstFocusableEl($elToClick) $focused = cy.getFocused() - if shouldFireFocusEvent($focused, $elToFocus) ## if our mousedown went through and ## we are focusing a different element diff --git a/packages/driver/src/cy/commands/actions/select.coffee b/packages/driver/src/cy/commands/actions/select.coffee index 5b1d29cf2b36..bc2e0755ba78 100644 --- a/packages/driver/src/cy/commands/actions/select.coffee +++ b/packages/driver/src/cy/commands/actions/select.coffee @@ -4,6 +4,7 @@ Promise = require("bluebird") $dom = require("../../../dom") $utils = require("../../../cypress/utils") +$elements = require('../../../dom/elements') newLineRe = /\n/g @@ -70,7 +71,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> optionsObjects = options.$el.find("option").map((index, el) -> ## push the value in values array if its ## found within the valueOrText - value = el.value + value = $elements.getNativeProp(el, "value") optEl = $(el) if value in valueOrText @@ -101,7 +102,8 @@ module.exports = (Commands, Cypress, cy, state, config) -> _.each optionsObjects, (obj, index) -> if obj.text in valueOrText optionEls.push obj.$el - values.push(obj.value) + objValue = obj.value + values.push(objValue) ## if we didnt set multiple to true and ## we have more than 1 option to set then blow up diff --git a/packages/driver/src/cy/commands/actions/type.coffee b/packages/driver/src/cy/commands/actions/type.coffee index c862cb5c1648..47a166c2d803 100644 --- a/packages/driver/src/cy/commands/actions/type.coffee +++ b/packages/driver/src/cy/commands/actions/type.coffee @@ -4,6 +4,8 @@ Promise = require("bluebird") moment = require("moment") $dom = require("../../../dom") +$elements = require("../../../dom/elements") +$selection = require("../../../dom/selection") $Keyboard = require("../../../cypress/keyboard") $utils = require("../../../cypress/utils") $actionability = require("../../actionability") @@ -21,6 +23,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> Commands.addAll({ prevSubject: "element" }, { type: (subject, chars, options = {}) -> + options = _.clone(options) ## allow the el we're typing into to be ## changed by options -- used by cy.clear() _.defaults(options, { @@ -61,10 +64,10 @@ module.exports = (Commands, Cypress, cy, state, config) -> memo , {} - options._log = Cypress.log + options._log = Cypress.log { message: [chars, deltaOptions] $el: options.$el - consoleProps: -> + consoleProps: -> { "Typed": chars "Applied To": $dom.getElements(options.$el) "Options": deltaOptions @@ -74,6 +77,8 @@ module.exports = (Commands, Cypress, cy, state, config) -> data: getTableData() columns: ["typed", "which", "keydown", "keypress", "textInput", "input", "keyup", "change", "modifiers"] } + } + } options._log.snapshot("before", {next: "after"}) @@ -210,27 +215,24 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## consider changing type to a Promise and juggle logging cy.now("submit", form, {log: false, $el: form}) - dispatchChangeEvent = (id) -> + dispatchChangeEvent = (el, id) -> change = document.createEvent("HTMLEvents") change.initEvent("change", true, false) - dispatched = options.$el.get(0).dispatchEvent(change) + dispatched = el.dispatchEvent(change) if id and updateTable updateTable(id, null, "change", null, dispatched) - return dispatched - needSingleValueChange = -> - isDate or - isMonth or - isWeek or - isTime or - ($dom.isType(options.$el, "number") and _.includes(options.chars, ".")) + return $elements.isNeedSingleValueChangeInputElement(options.$el.get(0)) ## see comment in updateValue below typed = "" + isContentEditable = $elements.isContentEditable(options.$el.get(0)) + isTextarea = $elements.isTextarea(options.$el.get(0)) + $Keyboard.type({ $el: options.$el chars: options.chars @@ -238,17 +240,17 @@ module.exports = (Commands, Cypress, cy, state, config) -> release: options.release window: win - updateValue: (rng, key) -> + updateValue: (el, key) -> + ## in these cases, the value must only be set after all + ## the characters are input because attemping to set + ## a partial/invalid value results in the value being + ## set to an empty string if needSingleValueChange() - ## in these cases, the value must only be set after all - ## the characters are input because attemping to set - ## a partial/invalid value results in the value being - ## set to an empty string typed += key if typed is options.chars - options.$el.val(options.chars) + $elements.setNativeProp(el, "value", options.chars) else - rng.text(key, "end") + $selection.replaceSelectionContents(el, key) onBeforeType: (totalKeys) -> ## for the total number of keys we're about to @@ -277,25 +279,35 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## fires only when the 'value' ## of input/text/contenteditable ## changes - onTypeChange: -> - ## never fire any change events for contenteditable - return if options.$el.is("[contenteditable]") + onValueChange: (originalText, el) -> + ## contenteditable should never be called here. + ## only input's and textareas can have change events + if changeEvent = state("changeEvent") + if !changeEvent(null, true) + state("changeEvent", null) + return + + state "changeEvent", (id, readOnly) -> + changed = $elements.getNativeProp(el, 'value') isnt originalText + + if !readOnly + if changed + dispatchChangeEvent(el, id) + state "changeEvent", null - state "changeEvent", -> - dispatchChangeEvent() - state "changeEvent", null + return changed - onEnterPressed: (changed, id) -> + onEnterPressed: (id) -> ## dont dispatch change events or handle ## submit event if we've pressed enter into ## a textarea or contenteditable - return if options.$el.is("textarea,[contenteditable]") + return if isTextarea || isContentEditable ## if our value has changed since our ## element was activated we need to ## fire a change event immediately - if changed - dispatchChangeEvent(id) + if changeEvent = state("changeEvent") + changeEvent(id) ## handle submit event handler here simulateSubmitHandler() @@ -315,32 +327,33 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## if it's the body, don't need to worry about focus return type() if isBody - cy.now("focused", {log: false, verify: false}) - .then ($focused) -> - $actionability.verify(cy, options.$el, options, { - onScroll: ($el, type) -> - Cypress.action("cy:scrolled", $el, type) - - onReady: ($elToClick) -> - ## if we dont have a focused element - ## or if we do and its not ourselves - ## then issue the click - if not $focused or ($focused and $focused.get(0) isnt options.$el.get(0)) - ## click the element first to simulate focus - ## and typical user behavior in case the window - ## is out of focus - cy.now("click", $elToClick, { - $el: $elToClick - log: false - verify: false - _log: options._log - force: true ## force the click, avoid waiting - timeout: options.timeout - interval: options.interval - }).then(type) - else - ## don't click, just type + $actionability.verify(cy, options.$el, options, { + onScroll: ($el, type) -> + Cypress.action("cy:scrolled", $el, type) + + onReady: ($elToClick) -> + $focused = cy.getFocused() + + ## if we dont have a focused element + ## or if we do and its not ourselves + ## then issue the click + if not $focused or ($focused and $focused.get(0) isnt options.$el.get(0)) + ## click the element first to simulate focus + ## and typical user behavior in case the window + ## is out of focus + cy.now("click", $elToClick, { + $el: $elToClick + log: false + verify: false + _log: options._log + force: true ## force the click, avoid waiting + timeout: options.timeout + interval: options.interval + }) + .then -> type() + else + type() }) handleFocused() @@ -377,13 +390,15 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## figure out the options which actually change the behavior of clicks deltaOptions = $utils.filterOutOptions(options) - options._log = Cypress.log + options._log = Cypress.log { message: deltaOptions $el: $el - consoleProps: -> + consoleProps: () -> { "Applied To": $dom.getElements($el) "Elements": $el.length "Options": deltaOptions + } + } node = $dom.stringify($el) diff --git a/packages/driver/src/cypress.coffee b/packages/driver/src/cypress.coffee index 41793067868a..7201cf8668fc 100644 --- a/packages/driver/src/cypress.coffee +++ b/packages/driver/src/cypress.coffee @@ -6,7 +6,6 @@ moment = require("moment") Promise = require("bluebird") sinon = require("sinon") lolex = require("lolex") -bililiteRange = require("../vendor/bililiteRange") $dom = require("./dom") $errorMessages = require("./cypress/error_messages") @@ -477,7 +476,6 @@ class $Cypress minimatch: minimatch sinon: sinon lolex: lolex - bililiteRange: bililiteRange _.extend $Cypress.prototype.$, _.pick($, "Event", "Deferred", "ajax", "get", "getJSON", "getScript", "post", "when") diff --git a/packages/driver/src/cypress/keyboard.coffee b/packages/driver/src/cypress/keyboard.coffee index 500d5a8de8f6..e4ef52fbab24 100644 --- a/packages/driver/src/cypress/keyboard.coffee +++ b/packages/driver/src/cypress/keyboard.coffee @@ -1,10 +1,12 @@ _ = require("lodash") Promise = require("bluebird") -bililiteRange = require("../../vendor/bililiteRange") - +$elements = require("../dom/elements") +$selection = require("../dom/selection") $Cypress = require("../cypress") -charsBetweenCurlyBraces = /({.+?})/ +isSingleDigitRe = /^\d$/ +isStartingDigitRe = /^\d/ +charsBetweenCurlyBracesRe = /({.+?})/ # Keyboard event map # https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values @@ -71,55 +73,60 @@ $Keyboard = { } specialChars: { - "{selectall}": (el, options) -> - options.rng.bounds('all').select() + "{selectall}": $selection.selectAll ## charCode = 46 ## no keyPress ## no textInput ## yes input (if value is actually changed) "{del}": (el, options) -> - {rng} = options - bounds = rng.bounds() - if @boundsAreEqual(bounds) - rng.bounds([bounds[0], bounds[0] + 1]) options.charCode = 46 options.keypress = false options.textInput = false options.setKey = "{del}" + @ensureKey el, null, options, -> - prev = rng.all() - rng.text("", "end") + bounds = $selection.getSelectionBounds(el) + + if $selection.isCollapsed(el) + ## if there's no text selected, delete the prev char + ## if deleted char, send the input event + options.input = $selection.deleteRightOfCursor(el) + return + + ## text is selected, so delete the selection + ## contents and send the input event + $selection.deleteSelectionContents(el) + options.input = true - ## after applying the {del} - ## if our text didnt change - ## dont send the input event - if prev is rng.all() - options.input = false + return ## charCode = 8 ## no keyPress ## no textInput ## yes input (if value is actually changed) "{backspace}": (el, options) -> - {rng} = options - bounds = rng.bounds() - if @boundsAreEqual(bounds) - rng.bounds([bounds[0] - 1, bounds[0]]) options.charCode = 8 options.keypress = false options.textInput = false options.setKey = "{backspace}" + @ensureKey el, null, options, -> - prev = rng.all() - rng.text("", "end") - ## after applying the {backspace} - ## if our text didnt change - ## dont send the input event - if prev is rng.all() - options.input = false + if $selection.isCollapsed(el) + ## if there's no text selected, delete the prev char + ## if deleted char, send the input event + options.input = $selection.deleteLeftOfCursor(el) + return + + ## text is selected, so delete the selection + ## contents and send the input event + $selection.deleteSelectionContents(el) + options.input = true + + return + ## charCode = 27 ## no keyPress ## no textInput @@ -144,84 +151,53 @@ $Keyboard = { ## no input ## yes change (if input is different from last change event) "{enter}": (el, options) -> - {rng} = options options.charCode = 13 options.textInput = false options.input = false options.setKey = "{enter}" @ensureKey el, "\n", options, -> - rng.insertEOL() - changed = options.prev isnt rng.all() - options.onEnterPressed(changed, options.id) + $selection.replaceSelectionContents(el, "\n") + options.onEnterPressed(options.id) ## charCode = 37 ## no keyPress ## no textInput ## no input "{leftarrow}": (el, options) -> - {rng} = options - bounds = rng.bounds() options.charCode = 37 options.keypress = false options.textInput = false options.input = false options.setKey = "{leftarrow}" @ensureKey el, null, options, -> - switch - when @boundsAreEqual(bounds) - ## if bounds are equal move the caret - ## 1 to the left - left = bounds[0] - 1 - right = left - when bounds[0] > 0 - ## just set the cursor back to the left - ## position - left = bounds[0] - right = left - else - left = 0 - right = 0 - - rng.bounds([left, right]) + $selection.moveCursorLeft(el) - ## charCode = 38 + ## charCode = 39 ## no keyPress ## no textInput ## no input - "{uparrow}": (el, options) -> - options.charCode = 38 + "{rightarrow}": (el, options) -> + options.charCode = 39 options.keypress = false options.textInput = false options.input = false - options.setKey = "{uparrow}" - @ensureKey(el, null, options) + options.setKey = "{rightarrow}" + @ensureKey el, null, options, -> + $selection.moveCursorRight(el) - ## charCode = 39 + ## charCode = 38 ## no keyPress ## no textInput ## no input - "{rightarrow}": (el, options) -> - {rng} = options - bounds = rng.bounds() - options.charCode = 39 + "{uparrow}": (el, options) -> + options.charCode = 38 options.keypress = false options.textInput = false options.input = false - options.setKey = "{rightarrow}" + options.setKey = "{uparrow}" @ensureKey el, null, options, -> - switch - when @boundsAreEqual(bounds) - ## if bounds are equal move the caret - ## 1 to the right - left = bounds[0] + 1 - right = left - else - ## just set the cursor back to the left - ## position - right = bounds[1] - left = right + $selection.moveCursorUp(el) - rng.bounds([left, right]) ## charCode = 40 ## no keyPress @@ -233,7 +209,8 @@ $Keyboard = { options.textInput = false options.input = false options.setKey = "{downarrow}" - @ensureKey(el, null, options) + @ensureKey el, null, options, -> + $selection.moveCursorDown(el) } modifierChars: { @@ -259,7 +236,7 @@ $Keyboard = { onEvent: -> onBeforeEvent: -> onBeforeType: -> - onTypeChange: -> + onValueChange: -> onEnterPressed: -> onNoMatchingSpecialChars: -> onBeforeSpecialCharAction: -> @@ -267,79 +244,8 @@ $Keyboard = { el = options.$el.get(0) - bililiteRangeSelection = el.bililiteRangeSelection - rng = bililiteRange(el).bounds("selection") - - ## if the value has changed since previously typing, we need to - ## update the caret position if the value has changed - if el.prevValue and @expectedValueDoesNotMatchCurrentValue(el.prevValue, rng) - @moveCaretToEnd(rng) - el.prevValue = rng.all() - bililiteRangeSelection = el.bililiteRangeSelection = rng.bounds() - - ## store the previous text value - ## so we know to fire change events - ## and change callbacks - options.prev = rng.all() - - resetBounds = (start, end) -> - if start? and end? - bounds = [start, end] - else - len = rng.length() - bounds = [len, len] - - ## resets the bounds to the - ## end of the element's text - if not _.isEqual(rng._bounds, bounds) - el.bililiteRangeSelection = bounds - rng.bounds(bounds) - - ## restore the bounds if our el already has this - if bililiteRangeSelection - rng.bounds(bililiteRangeSelection) - else - ## native date/moth/datetime/time input types - ## do not have selectionStart so we have to - ## manually fix the range on those elements. - ## we know we need to do that when - ## el.selectionStart throws or if the element - ## does not have a selectionStart property - try - if "selectionStart" of el - el.selectionStart - else - resetBounds() - catch - ## currently if this throws we're likely on - ## a native input type (number, etc) - ## and we're just going to take a shortcut here - ## by figuring out if there is currently a - ## selection range of the window. whatever that - ## value is we need to set the range of the el. - ## now this will fail if there is a PARTIAL range - ## for instance if our element has value of: 121234 - ## and the selection range is '12' we cannot know - ## if it is the [0,1] index or the [2,3] index. to - ## fix this we need to walk forward and backward by - ## s.modify('extend', 'backward', 'character') until - ## we can definitely figure out where the selection is - ## check if this fires selectionchange events. if it does - ## we may need an option that enables to use to simply - ## silence these events, or perhaps just TELL US where - ## to type via the index. - try - selection = el.ownerDocument.getSelection().toString() - index = options.$el.val().indexOf(selection) - if selection.length and index > -1 - resetBounds(index, selection.length) - else - resetBounds() - catch - resetBounds() - - keys = options.chars.split(charsBetweenCurlyBraces).map (chars) -> - if charsBetweenCurlyBraces.test(chars) + keys = options.chars.split(charsBetweenCurlyBracesRe).map (chars) -> + if charsBetweenCurlyBracesRe.test(chars) ## allow special chars and modifiers to be case-insensitive chars.toLowerCase() else @@ -351,18 +257,9 @@ $Keyboard = { ## how keystrokes come into javascript naturally Promise .each keys, (key) => - @typeChars(el, rng, key, options) + @typeChars(el, key, options) .then => - ## if after typing we ended up changing - ## our value then fire the onTypeChange callback - if @expectedValueDoesNotMatchCurrentValue(options.prev, rng) - options.onTypeChange() - - ## after typing be sure to clear all ranges - if sel = options.window.getSelection() - sel.removeAllRanges() - - unless options.release is false + if options.release isnt false @resetModifiers(el, options.window) countNumIndividualKeyStrokes: (keys) -> @@ -377,9 +274,8 @@ $Keyboard = { memo + chars.length , 0 - typeChars: (el, rng, chars, options) -> + typeChars: (el, chars, options) -> options = _.clone(options) - options.rng = rng switch when @isSpecialChar(chars) @@ -392,7 +288,7 @@ $Keyboard = { .resolve @handleModifier(el, chars, options) .delay(options.delay) - when charsBetweenCurlyBraces.test(chars) + when charsBetweenCurlyBracesRe.test(chars) ## between curly braces, but not a valid special ## char or modifier allChars = _.keys(@specialChars).concat(_.keys(@modifierChars)).join(", ") @@ -422,6 +318,7 @@ $Keyboard = { simulateKey: (el, eventType, key, options) -> ## bail if we've said not to fire this specific event ## in our options + return true if options[eventType] is false key = options.key ? key @@ -503,54 +400,71 @@ $Keyboard = { return dispatched typeKey: (el, key, options) -> - ## if we have an afterKey value it means - ## we've typed in prior to this - if after = options.afterKey - ## if this afterKey value is no longer the current value - ## then something has altered the value and we need to - ## automatically shift the caret to the end like a real browser - if @expectedValueDoesNotMatchCurrentValue(after, options.rng) - @moveCaretToEnd(options.rng) - @ensureKey el, key, options, -> - options.updateValue(options.rng, key) - ## update the selection that's cached on the element - ## and store the value for comparison in any future typing - el.bililiteRangeSelection = options.rng.bounds() - el.prevValue = el.value + + isDigit = isSingleDigitRe.test(key) + isNumberInputType = $elements.isInput(el) and $elements.isInputType(el, 'number') + + if isNumberInputType + selectionStart = el.selectionStart + valueLength = $elements.getNativeProp(el, "value").length + isDigitsInText = isStartingDigitRe.test(options.chars) + isValidCharacter = key is '.' or (key is '-' and valueLength) + prevChar = options.prevChar + + if !isDigit and (isDigitsInText or !isValidCharacter or selectionStart isnt 0) + options.prevChar = key + return + + ## only type '.' and '-' if it is the first symbol and there already is a value, or if + ## '.' or '-' are appended to a digit. If not, value cannot be set. + if isDigit and (prevChar is '.' or (prevChar is '-' and !valueLength)) + options.prevChar = key + key = prevChar + key + + options.updateValue(el, key) ensureKey: (el, key, options, fn) -> + _.defaults(options, { + prevText: null + }) + options.id = _.uniqueId("char") - options.beforeKey = options.rng.all() + # options.beforeKey = el.value maybeUpdateValueAndFireInput = => ## only call this function if we haven't been told not to if fn and options.onBeforeSpecialCharAction(options.id, options.key) isnt false + if not $elements.isContentEditable(el) + prevText = $elements.getNativeProp(el, "value") fn.call(@) + if options.prevText is null and not $elements.isContentEditable(el) + options.prevText = prevText + options.onValueChange(options.prevText, el) + @simulateKey(el, "input", key, options) if @simulateKey(el, "keydown", key, options) if @simulateKey(el, "keypress", key, options) if @simulateKey(el, "textInput", key, options) - ml = el.maxLength - + if $elements.isInput(el) or $elements.isTextarea(el) + ml = el.maxLength + ## maxlength is -1 by default when omitted ## but could also be null or undefined :-/ - if ml is 0 or ml > 0 + ## only cafe if we are trying to type a key + if (ml is 0 or ml > 0 ) and key ## check if we should update the value ## and fire the input event ## as long as we're under maxlength - if el.value.length < ml + + if $elements.getNativeProp(el, "value").length < ml maybeUpdateValueAndFireInput() else maybeUpdateValueAndFireInput() - ## store the afterKey value so we know - ## if something mutates the value between typing keys - options.afterKey = options.rng.all() - @simulateKey(el, "keyup", key, options) isSpecialChar: (chars) -> diff --git a/packages/driver/src/dom/elements.coffee b/packages/driver/src/dom/elements.coffee index c7f1fc04d023..23017a3eb07d 100644 --- a/packages/driver/src/dom/elements.coffee +++ b/packages/driver/src/dom/elements.coffee @@ -7,7 +7,161 @@ $utils = require("../cypress/utils") fixedOrStickyRe = /(fixed|sticky)/ -focusable = "a[href],link[href],button,input,select,textarea,[tabindex],[contenteditable]" +contentEditable = '[contenteditable]' +focusable = "a[href],link[href],button,select,[tabindex],input,textarea,#{contentEditable}" + +inputTypeNeedSingleValueChangeRe = /^(date|time|month|week)$/ +canSetSelectionRangeElementRe = /^(text|search|URL|tel|password)$/ + +## rules for native methods and props +## if a setter or getter or function then add a native method +## if a traversal, don't + +descriptor = (klass, prop) -> + Object.getOwnPropertyDescriptor(window[klass].prototype, prop) + +_getValue = -> + switch + when isInput(this) + descriptor("HTMLInputElement", "value").get + when isTextarea(this) + descriptor("HTMLTextAreaElement", "value").get + when isSelect(this) + descriptor("HTMLSelectElement", "value").get + else + ## is an option element + descriptor("HTMLOptionElement", "value").get + + +_setValue = -> + switch + when isInput(this) + descriptor("HTMLInputElement", "value").set + when isTextarea(this) + descriptor("HTMLTextAreaElement", "value").set + when isSelect(this) + descriptor("HTMLSelectElement", "value").set + else + ## is an options element + descriptor("HTMLOptionElement", "value").set + + +_setSelectionRange = () -> + switch + when isInput(this) + window.HTMLInputElement.prototype.setSelectionRange + when isTextarea(this) + window.HTMLTextAreaElement.prototype.setSelectionRange + +nativeGetters = { + value: _getValue + selectionStart: descriptor("HTMLInputElement", "selectionStart").get + isContentEditable: descriptor("HTMLElement", "isContentEditable").get +} + +nativeSetters = { + value: _setValue +} + +nativeMethods = { + createRange: window.document.createRange + execCommand: window.document.execCommand + getAttribute: window.Element.prototype.getAttribute + setSelectionRange: _setSelectionRange + modify: window.Selection.prototype.modify +} + +tryCallNativeMethod = -> + try + callNativeMethod.apply(null, arguments) + catch + null + +callNativeMethod = (obj, fn, args...) -> + if not nativeFn = nativeMethods[fn] + fns = _.keys(nativeMethods).join(", ") + throw new Error("attempted to use a native fn called: #{fn}. Available fns are: #{fns}") + + retFn = nativeFn.apply(obj, args) + + if _.isFunction(retFn) + retFn = retFn.apply(obj, args) + + return retFn + +getNativeProp = (obj, prop) -> + if not nativeProp = nativeGetters[prop] + props = _.keys(nativeGetters).join(", ") + throw new Error("attempted to use a native getter prop called: #{prop}. Available props are: #{props}") + + retProp = nativeProp.call(obj, prop) + + if _.isFunction(retProp) + ## if we got back another function + ## then invoke it again + retProp = retProp.call(obj, prop) + + return retProp + +setNativeProp = (obj, prop, val) -> + if not nativeProp = nativeSetters[prop] + fns = _.keys(nativeSetters).join(", ") + throw new Error("attempted to use a native setter prop called: #{fn}. Available props are: #{fns}") + + retProp = nativeProp.call(obj, val) + + if _.isFunction(retProp) + retProp = retProp.call(obj, val) + + return retProp + +isNeedSingleValueChangeInputElement = (el) -> + if !isInput(el) + return false + + return inputTypeNeedSingleValueChangeRe.test(el.type) + +## TODO: switch this to not use this +# getValue = (el) -> +# return getNativeProp(el, "value") + +# if isTextarea(el) +# return nativeTextareaValueGetter.call(el) + +## TODO: switch this to not use this +# _setValue = (el, val) -> +# ## sets value for or