├── .npmignore ├── .eslintignore ├── cypress.json ├── .gitignore ├── src ├── .eslintrc.json ├── index.d.ts └── index.js ├── docs ├── design.gvdesign ├── readme-demo.gif ├── readme-logo.png └── readme-screenshot.png ├── cypress ├── .eslintrc.json ├── plugins │ └── index.js ├── support │ └── index.js └── integration │ └── spec.js ├── .eslintrc.json ├── .vscode └── settings.json ├── circle.yml ├── .github └── workflows │ └── nodejs.yml ├── LICENSE ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !src/* 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !.* 2 | **/node_modules 3 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "3e57fb" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.tgz 3 | cypress/videos 4 | cypress/screenshots 5 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:cypress/recommended" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /docs/design.gvdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuceb/cypress-plugin-tab/HEAD/docs/design.gvdesign -------------------------------------------------------------------------------- /docs/readme-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuceb/cypress-plugin-tab/HEAD/docs/readme-demo.gif -------------------------------------------------------------------------------- /docs/readme-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuceb/cypress-plugin-tab/HEAD/docs/readme-logo.png -------------------------------------------------------------------------------- /docs/readme-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuceb/cypress-plugin-tab/HEAD/docs/readme-screenshot.png -------------------------------------------------------------------------------- /cypress/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:cypress/recommended", 4 | "plugin:@cypress/dev/tests" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@cypress/dev" 4 | ], 5 | "extends": [ 6 | "plugin:@cypress/dev/general" 7 | ], 8 | "rules": {} 9 | } 10 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace Cypress { 4 | interface Chainable { 5 | tab(options?: Partial<{shift: Boolean}>): Chainable 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.alwaysShowStatus": true, 3 | "eslint.autoFixOnSave": true, 4 | "eslint.validate": [ 5 | { 6 | "language": "javascript", 7 | "autoFix": true 8 | }, 9 | { 10 | "language": "javascriptreact", 11 | "autoFix": true 12 | }, 13 | { 14 | "language": "typescript", 15 | "autoFix": true 16 | }, 17 | { 18 | "language": "typescriptreact", 19 | "autoFix": true 20 | }, 21 | { 22 | "language": "json", 23 | "autoFix": true 24 | }, 25 | ], 26 | "editor.codeActionsOnSave": { 27 | "source.fixAll.eslint": true 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | 18 | // Alternatively you can use CommonJS syntax: 19 | // require('./commands') 20 | 21 | require('../../') 22 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | test: 4 | docker: 5 | - image: cypress/base:10 6 | steps: 7 | - checkout 8 | - restore_cache: 9 | keys: 10 | - cache-{{ arch }}-{{ .Branch }}-{{ checksum "package.json" }} 11 | - run: 12 | name: Yarn install 13 | command: yarn install --frozen-lockfile 14 | - save_cache: 15 | key: cache-{{ arch }}-{{ .Branch }}-{{ checksum "package.json" }} 16 | paths: 17 | - ~/.cache 18 | - run: 19 | command: yarn lint 20 | - run: 21 | command: yarn test-e2e 22 | - run: 23 | command: yarn add -D cypress@3.8.3 && yarn test-e2e 24 | - run: 25 | command: yarn add -D cypress@3.4.1 && yarn test-e2e 26 | - run: 27 | command: yarn run semantic-release 28 | workflows: 29 | build: 30 | jobs: 31 | - test 32 | version: 2 33 | # Semantic-release bot needs env vars: 34 | # GH_TOKEN: repo, read:org, write:repo_hook, user:email 35 | # NPM_TOKEN: read/release 36 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test-e2e: 7 | runs-on: ubuntu-16.04 8 | container: 9 | image: cypress/browsers:node12.16.1-chrome80-ff73 10 | options: --user 1001 11 | 12 | strategy: 13 | matrix: 14 | cypress-version: ["current", "current-firefox", 3.8.3, 3.4.1] 15 | 16 | steps: 17 | - uses: actions/checkout@v1 18 | - uses: actions/setup-node@v1 19 | with: 20 | node-version: "12.x" 21 | 22 | - name: install 23 | run: | 24 | yarn install --frozen-lockfile 25 | 26 | - name: print-info 27 | run: | 28 | firefox --version 29 | 30 | - name: maybe-install 31 | run: | 32 | firefox --version 33 | [[ current-firefox =~ current ]] || yarn add -D cypress@current-firefox 34 | shell: bash 35 | 36 | - name: test 37 | run: | 38 | yarn test-e2e $([[ ${{ matrix.cypress-version }} =~ 'firefox' ]] && echo '--browser firefox') 39 | env: 40 | CI: true 41 | shell: bash 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ben Kucera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cypress-plugin-tab", 3 | "version": "1.0.1", 4 | "description": "", 5 | "main": "src", 6 | "types": "src/index.d.ts", 7 | "scripts": { 8 | "lint": "eslint --ext .js,.ts,.eslintrc,.json .", 9 | "test-e2e": "cypress run" 10 | }, 11 | "husky": { 12 | "hooks": { 13 | "pre-commit": "lint-staged" 14 | } 15 | }, 16 | "dependencies": { 17 | "ally.js": "^1.4.1" 18 | }, 19 | "devDependencies": { 20 | "@cypress/eslint-plugin-dev": "^4.0.0", 21 | "@typescript-eslint/eslint-plugin": "^2.3.0", 22 | "@typescript-eslint/parser": "^2.3.0", 23 | "cypress": "4.1.0", 24 | "eslint": "^6.4.0", 25 | "eslint-plugin-cypress": "^2.2.1", 26 | "eslint-plugin-json-format": "^2.0.1", 27 | "eslint-plugin-mocha": "^6.1.1", 28 | "http-server": "^0.11.1", 29 | "husky": "^3.0.5", 30 | "lint-staged": "^9.2.5", 31 | "semantic-release": "^15.13.24", 32 | "typescript": "^3.6.3" 33 | }, 34 | "license": "MIT", 35 | "repository": { 36 | "type": "git", 37 | "url": "git://github.com/Bkucera/cypress-plugin-tab.git" 38 | }, 39 | "author": "Ben Kucera", 40 | "keywords": [ 41 | "cypress", 42 | "plugin", 43 | "tab" 44 | ], 45 | "lint-staged": { 46 | "*.{js,jsx,ts,tsx,json,eslintrc}": [ 47 | "eslint --fix", 48 | "git add" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |

cypress-plugin-tab beta

5 | 6 | 7 | 8 |

A Cypress plugin to add a tab command

9 | 10 |
11 | 12 | > :warning: this module is in beta, and might cause some strange failures. Please report bugs in the issues of this repo. 13 | 14 | > Note: [please refer to this issue for updates about official cypress tab support](https://github.com/cypress-io/cypress/issues/299) 15 | 16 | ![](docs/readme-screenshot.png) 17 | ![](docs/readme-demo.gif) 18 | 19 | ### Installation 20 | 21 | Add the plugin to `devDependencies` 22 | ```bash 23 | npm install -D cypress-plugin-tab 24 | ``` 25 | 26 | At the top of **`cypress/support/index.js`**: 27 | ```js 28 | require('cypress-plugin-tab') 29 | ``` 30 | 31 | 32 | ### Usage 33 | 34 | - `.tab()` must be chained off of a tabbable(focusable) subject, or the `body` 35 | - `.tab()` changes the subject to the newly focused element after pressing `tab` 36 | - `.tab({ shift: true })` sends a shift-tab to the element 37 | 38 | ```js 39 | cy.get('input').type('foo').tab().type('bar') // type foo, then press tab, then type bar 40 | cy.get('body').tab() // tab into the first tabbable element on the page 41 | cy.focused().tab() // tab into the currently focused element 42 | ``` 43 | 44 | shift+tab: 45 | 46 | ```js 47 | cy.get('input') 48 | .type('foop').tab() 49 | .type('bar').tab({ shift: true }) 50 | .type('foo') // correct your mistake 51 | ``` 52 | 53 | ### License 54 | [MIT](LICENSE) 55 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const tabSequence = require('ally.js/query/tabsequence') 2 | 3 | const { _, Promise } = Cypress 4 | 5 | Cypress.Commands.add('tab', { prevSubject: ['optional', 'element'] }, (subject, opts = {}) => { 6 | 7 | const options = _.defaults({}, opts, { 8 | shift: false, 9 | }) 10 | 11 | debug('subject:', subject) 12 | 13 | if (subject) { 14 | return performTab(subject[0], options) 15 | } 16 | 17 | const win = cy.state('window') 18 | const activeElement = win.document.activeElement 19 | 20 | return performTab(activeElement, options) 21 | 22 | }) 23 | 24 | const performTab = (el, options) => { 25 | 26 | const doc = el.ownerDocument 27 | const activeElement = doc.activeElement 28 | 29 | const seq = tabSequence({ 30 | strategy: 'quick', 31 | includeContext: false, 32 | includeOnlyTabbable: true, 33 | context: doc.documentElement, 34 | }) 35 | 36 | let index = seq.indexOf(el) 37 | 38 | if (index === -1) { 39 | if (el && !(el === doc.body)) { 40 | pluginError(` 41 | Subject is not a tabbable element 42 | - Use cy.get(\'body\').tab() if you wish to tab into the first element on the page 43 | - Use cy.focused().tab() if you wish to tab into the currently active element 44 | `) 45 | } 46 | } 47 | 48 | debug(seq, index) 49 | 50 | /** 51 | * @type {HTMLElement} 52 | */ 53 | const newElm = nextItemFromIndex(index, seq, options.shift) 54 | 55 | const simulatedDefault = () => { 56 | if (newElm.select) { 57 | newElm.select() 58 | } 59 | 60 | return cy.now('focus', cy.$$(newElm)) 61 | // newElm.focus() 62 | // return newElm 63 | } 64 | 65 | return new Promise((resolve) => { 66 | doc.defaultView.requestAnimationFrame(resolve) 67 | }).then(() => { 68 | // return Promise.try(() => { 69 | return keydown(activeElement, options, simulatedDefault, () => doc.activeElement) 70 | }).finally(() => { 71 | keyup(activeElement, options) 72 | }) 73 | 74 | } 75 | 76 | const nextItemFromIndex = (i, seq, reverse) => { 77 | if (reverse) { 78 | const nextIndex = i <= 0 ? seq.length - 1 : i - 1 79 | 80 | return seq[nextIndex] 81 | } 82 | 83 | const nextIndex = i === seq.length - 1 ? 0 : i + 1 84 | 85 | return seq[nextIndex] 86 | } 87 | 88 | const tabKeyEventPartial = { 89 | key: 'Tab', 90 | code: 'Tab', 91 | keyCode: 9, 92 | which: 9, 93 | charCode: 0, 94 | } 95 | 96 | const fireKeyEvent = (type, el, eventOptionsExtend, bubbles = false, cancelable = false) => { 97 | const win = el.ownerDocument.defaultView 98 | 99 | const eventInit = _.extend({ 100 | bubbles, 101 | cancelable, 102 | altKey: false, 103 | ctrlKey: false, 104 | metaKey: false, 105 | shiftKey: false, 106 | }, eventOptionsExtend) 107 | 108 | const keyboardEvent = new win.KeyboardEvent(type, eventInit) 109 | 110 | const cancelled = !el.dispatchEvent(keyboardEvent) 111 | 112 | return cancelled 113 | 114 | } 115 | 116 | const keydown = (el, options, onSucceed, onCancel) => { 117 | 118 | const eventOptions = _.extend({}, tabKeyEventPartial, { 119 | shiftKey: options.shift, 120 | }) 121 | 122 | const cancelled = fireKeyEvent('keydown', el, eventOptions, true, true) 123 | 124 | if (cancelled) { 125 | return onCancel() 126 | } 127 | 128 | return onSucceed() 129 | } 130 | 131 | const keyup = (el, options) => { 132 | 133 | const eventOptions = _.extend({}, tabKeyEventPartial, { 134 | shiftKey: options.shift, 135 | }) 136 | 137 | return fireKeyEvent('keyup', el, eventOptions, true, false) 138 | 139 | } 140 | 141 | const pluginError = (mes) => { 142 | throw new Error(`[cypress-plugin-tab]: ${mes}`) 143 | } 144 | 145 | const debug = function () { 146 | // console.log(...arguments) 147 | } 148 | -------------------------------------------------------------------------------- /cypress/integration/spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // const { _ } = Cypress 4 | 5 | describe('form test', () => { 6 | 7 | beforeEach(() => { 8 | cy.visit('/cypress/fixtures/forms.html') 9 | }) 10 | 11 | it('can tab', () => { 12 | cy.get('body').tab().tab().tab().then(beFocused) 13 | 14 | cy.get('.navbar-brand').should(beFocused) 15 | }) 16 | 17 | it('can tab from element', () => { 18 | cy.get('input:first').tab().tab().tab() 19 | cy.get(':nth-child(3) > .bd-toc-link').should(beFocused) 20 | }) 21 | 22 | it('throws on non-tabbable subject', (done) => { 23 | cy.on('fail', (err) => { 24 | expect(err.message).contain('not a tabbable') 25 | done() 26 | }) 27 | 28 | cy.get('body').tab().tab() 29 | cy.tab() 30 | cy.get('header:first').tab() 31 | 32 | }) 33 | 34 | // tab will respect element nearest to selection 35 | // in window.getSelection().baseNode, but this is complex 36 | 37 | // it('can tab from selection', () => { 38 | // cy.get('header.navbar').tab() 39 | // cy.get('.navbar-brand').should(beFocused) 40 | 41 | // cy.get('.bd-example form:first').tab() 42 | // cy.get('.col-md-9 > :nth-child(6)').tab() 43 | // cy.get('.bd-example form:first input:first').should(beFocused) 44 | // }) 45 | 46 | // this is slow, obviously 47 | // it('can tab 31 times', () => { 48 | // const tab = (el) => el.tab() 49 | // let body = cy.get('body') 50 | 51 | // // tab(tab(body)) 52 | // _.times(31, () => { 53 | // body = tab(body) 54 | // }) 55 | // cy.contains('Jumbotron').should(beFocused) 56 | // }) 57 | 58 | it('can shift-tab', () => { 59 | cy.get('body').tab({ shift: true }) 60 | cy.get('a:last').should(beFocused) 61 | }) 62 | 63 | it('selects text in input', () => { 64 | cy.get('input#search-input').type('foobar').tab().tab({ shift: true }) 65 | cy.window().then((win) => { 66 | expect(selectedText(win)).eq('foobar') 67 | }) 68 | }) 69 | 70 | it('can tab from focus', () => { 71 | cy.get('#overview > div > .anchorjs-link').focus().tab().tab() 72 | 73 | cy.get('.bd-example form input:first').should(beFocused) 74 | }) 75 | 76 | it('can be cancelled', () => { 77 | cy.get('body').should(($el) => { 78 | return $el.on('keydown', (e) => e.preventDefault()) 79 | }) 80 | 81 | cy.get('body').tab().tab().tab() 82 | 83 | cy.get('body').should(beFocused) 84 | }) 85 | 86 | it('can be cancelled and yield activeElement', () => { 87 | cy.get('body').should(($el) => { 88 | return $el.on('keydown', (e) => e.preventDefault()) 89 | }) 90 | 91 | cy.get('body').tab().tab().tab().then(beFocused) 92 | 93 | cy.get('body').should(beFocused) 94 | }) 95 | 96 | it('moves focus back to the first element when the last element is focused', () => { 97 | cy.get('a:last').tab() 98 | cy.get('a:first').should(beFocused) 99 | }) 100 | 101 | it('moves focus back to the first element when the last element is focused', () => { 102 | cy.get('a:first').tab({ shift: true }) 103 | cy.get('a:last').should(beFocused) 104 | }) 105 | 106 | describe('events', () => { 107 | beforeEach(() => { 108 | 109 | cy.document().then((doc) => { 110 | const keydownStub = cy.stub() 111 | // .callsFake((e) => { 112 | // console.log('keydown, Target:', e.target, e) 113 | // }) 114 | .as('keydown') 115 | const keyupStub = cy.stub() 116 | // .callsFake((e) => { 117 | // // console.log('keyup, Target:', e.target, e) 118 | // }) 119 | .as('keyup') 120 | 121 | doc.addEventListener('keydown', keydownStub) 122 | doc.addEventListener('keyup', keyupStub) 123 | }) 124 | }) 125 | 126 | it('sends keydown event', () => { 127 | cy.get('body').tab().tab() 128 | cy.get('@keydown').should('be.calledTwice') 129 | }) 130 | 131 | it('sends keyup event', () => { 132 | cy.get('body').tab().tab() 133 | cy.get('@keydown').should('be.calledTwice') 134 | }) 135 | 136 | it('uses RAF for a delay', (done) => { 137 | let hasTripped = false 138 | let counter = 0 139 | 140 | cy.$$('body').on('keydown', () => { 141 | counter++ 142 | 143 | if (counter === 1) { 144 | cy.state('window').requestAnimationFrame(() => { 145 | hasTripped = true 146 | }) 147 | 148 | return 149 | } 150 | 151 | expect(hasTripped).ok 152 | done() 153 | }) 154 | 155 | cy.get('body').tab().tab() 156 | }) 157 | }) 158 | }) 159 | 160 | const beFocused = ($el) => { 161 | const el = $el[0] 162 | const activeElement = cy.state('document').activeElement 163 | 164 | expect(el, 'activeElement').eq(activeElement) 165 | } 166 | 167 | const selectedText = () => { 168 | const selectedText = cy.state('document').getSelection().toString() 169 | 170 | if (selectedText) return selectedText 171 | 172 | /** 173 | * @type {HTMLInputElement} 174 | */ 175 | const activeElement = cy.state('document').activeElement 176 | 177 | let selectedTextIsValue = false 178 | 179 | try { 180 | selectedTextIsValue = activeElement.selectionStart === 0 && activeElement.selectionEnd === activeElement.value.length 181 | } finally { 182 | // 183 | } 184 | 185 | if (selectedTextIsValue) { 186 | return activeElement.value 187 | } 188 | 189 | return '' 190 | 191 | } 192 | --------------------------------------------------------------------------------