├── .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 | 
17 | 
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 |
--------------------------------------------------------------------------------