Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add initial selection and focus tests

+166 -8
+1 -4
cypress/support/index.js
··· 13 13 // https://on.cypress.io/configuration 14 14 // *********************************************************** 15 15 16 - // Import commands.js using ES2015 syntax: 16 + import 'cypress-promise/register'; 17 17 import 'cypress-real-events/support'; 18 - 19 - // Alternatively you can use CommonJS syntax: 20 - // require('./commands')
+1
package.json
··· 58 58 "@rollup/plugin-node-resolve": "^13.0.2", 59 59 "@types/react": "^17.0.14", 60 60 "cypress": "^8.0.0", 61 + "cypress-promise": "^1.1.0", 61 62 "cypress-real-events": "^1.5.0", 62 63 "husky-v4": "^4.3.8", 63 64 "lint-staged": "^11.0.1",
+41
src/utils/__tests__/focus.test.tsx
··· 1 + import React from 'react'; 2 + import { mount } from '@cypress/react'; 3 + import { getFocusTargets } from '../focus'; 4 + 5 + it('detects typical focusable elements', () => { 6 + const Tabbables = () => ( 7 + <main> 8 + <button id="start">Start</button> 9 + <div className="targets" style={{ display: 'flex', flexDirection: 'column' }}> 10 + <input type="hidden" className="ignored" /> 11 + <input type="text" disabled className="ignored" /> 12 + <button tabIndex={-1} className="ignored" /> 13 + <button style={{ visibility: 'hidden' }} className="ignored">Invisible</button> 14 + <button style={{ display: 'none' }} className="ignored">Invisible</button> 15 + <a className="ignored">No href</a> 16 + 17 + <input type="text" /> 18 + <textarea></textarea> 19 + <button>Button</button> 20 + <a href="#">Link</a> 21 + <div tabIndex={0}>Tabbable</div> 22 + </div> 23 + </main> 24 + ); 25 + 26 + mount(<Tabbables />); 27 + cy.get('#start').focus(); 28 + 29 + const actualTargets: HTMLElement[] = []; 30 + for (let i = 0; i < 5; i++) { 31 + cy.realPress('Tab'); 32 + cy.focused().should('not.have.class', 'ignored').then($el => { 33 + actualTargets.push($el.get(0)); 34 + }); 35 + } 36 + 37 + cy.get('.targets').then($el => { 38 + const element = $el.get(0); 39 + expect(getFocusTargets(element)).to.deep.equal(actualTargets); 40 + }); 41 + });
+100
src/utils/__tests__/selection.test.tsx
··· 1 + import React from 'react'; 2 + import { mount } from '@cypress/react'; 3 + import { snapshotSelection, restoreSelection } from '../selection'; 4 + 5 + it('should restore focused elements', async () => { 6 + await mount(<button id="button">Focusable</button>).promisify(); 7 + 8 + let button: HTMLElement | null = null; 9 + 10 + await cy.get('button').as('button').then($el => { 11 + button = $el.get(0); 12 + }).focus().promisify(); 13 + 14 + const selection = snapshotSelection(); 15 + 16 + // check selection matches expected state 17 + expect(selection).to.deep.equal({ 18 + element: button, 19 + method: 'focus', 20 + }); 21 + 22 + // unfocus the button 23 + await cy.realPress('Tab'); 24 + await cy.focused().should('not.exist').promisify(); 25 + 26 + // restore the snapshotted selection 27 + restoreSelection(selection); 28 + await cy.focused().should('exist').promisify(); 29 + await cy.get('@button').should('have.focus').promisify(); 30 + }); 31 + 32 + it('should restore input selections', async () => { 33 + await mount(<input type="text" name="text" />).promisify(); 34 + 35 + let input: HTMLElement | null = null; 36 + 37 + await cy.get('input').as('input').then($el => { 38 + input = $el.get(0); 39 + }).focus().promisify(); 40 + 41 + // type and move selection 42 + await cy.realType('test'); 43 + await cy.realPress('ArrowLeft'); 44 + await cy.realPress('ArrowLeft'); 45 + 46 + const selection = snapshotSelection(); 47 + 48 + // check selection matches expected state 49 + expect(selection).to.deep.equal({ 50 + element: input, 51 + method: 'setSelectionRange', 52 + arguments: [2, 2, 'none'], 53 + }); 54 + 55 + // unfocus the input 56 + await cy.realPress('Tab'); 57 + await cy.focused().should('not.exist').promisify(); 58 + 59 + // restore the snapshotted selection 60 + restoreSelection(selection); 61 + await cy.focused().should('exist').promisify(); 62 + 63 + // modify input at selected caret and check value 64 + await cy.realType('test'); 65 + await cy.get('@input').should('have.value', 'tetestst').promisify(); 66 + }); 67 + 68 + it('should restore selections otherwise', async () => { 69 + await mount(<div contentEditable="true" id="editable"></div>).promisify(); 70 + 71 + let div: HTMLElement | null = null; 72 + 73 + await cy.get('#editable').as('editable').then($el => { 74 + div = $el.get(0); 75 + }).focus().promisify(); 76 + 77 + // type and move selection 78 + await cy.realType('test'); 79 + await cy.realPress('ArrowLeft'); 80 + await cy.realPress('ArrowLeft'); 81 + 82 + const selection = snapshotSelection(); 83 + 84 + // check selection matches expected state 85 + expect(selection).to.have.property('element', div); 86 + expect(selection).to.have.property('method', 'range'); 87 + expect(selection).to.have.property('range'); 88 + 89 + // unfocus the input 90 + await cy.realPress('Tab'); 91 + await cy.focused().should('not.exist').promisify(); 92 + 93 + // restore the snapshotted selection 94 + restoreSelection(selection); 95 + await cy.focused().should('exist').promisify(); 96 + 97 + // modify input at selected caret and check value 98 + await cy.realType('test'); 99 + await cy.get('@editable').should('have.text', 'tetestst').promisify(); 100 + });
+4 -1
src/utils/focus.ts
··· 52 52 } 53 53 54 54 return tabIndexTargets.length 55 - ? targets.concat(tabIndexTargets.sort(sortByTabindex).map(x => x[2])) 55 + ? tabIndexTargets 56 + .sort(sortByTabindex) 57 + .map(x => x[2]) 58 + .concat(targets) 56 59 : targets; 57 60 }; 58 61
+8 -2
src/utils/selection.ts
··· 1 + import { contains } from './element'; 2 + 1 3 interface RestoreInputSelection { 2 4 element: HTMLElement; 3 5 method: 'setSelectionRange'; ··· 21 23 | RestoreSelectionRange; 22 24 23 25 const isInputElement = (node: HTMLElement): node is HTMLInputElement => 24 - (node.nodeName === 'input' || node.nodeName === 'textarea') && 26 + (node.nodeName === 'INPUT' || node.nodeName === 'TEXTAREA') && 25 27 typeof (node as HTMLInputElement).selectionStart === 'number' && 26 28 typeof (node as HTMLInputElement).selectionEnd === 'number'; 27 29 ··· 48 50 const selection = window.getSelection && window.getSelection(); 49 51 if (selection && selection.rangeCount) { 50 52 const range = selection.getRangeAt(0); 51 - return { element, method: 'range', range }; 53 + if (contains(target, range.startContainer)) { 54 + return { element, method: 'range', range }; 55 + } 52 56 } 53 57 54 58 return { element, method: 'focus' }; ··· 60 64 if (!restore || !target || !target.parentNode) { 61 65 return; 62 66 } else if (restore.method === 'setSelectionRange' && isInputElement(target)) { 67 + target.focus(); 63 68 target.setSelectionRange(...restore.arguments); 64 69 } else if (restore.method === 'range') { 65 70 const selection = window.getSelection()!; 71 + target.focus(); 66 72 selection.removeAllRanges(); 67 73 selection.addRange(restore.range); 68 74 } else {
+6 -1
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 - "types": ["react", "cypress", "cypress-real-events"], 3 + "types": [ 4 + "react", 5 + "cypress", 6 + "cypress-real-events", 7 + "cypress-promise/register" 8 + ], 4 9 "baseUrl": "./", 5 10 "esModuleInterop": true, 6 11 "forceConsistentCasingInFileNames": true,
+5
yarn.lock
··· 686 686 resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" 687 687 integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== 688 688 689 + cypress-promise@^1.1.0: 690 + version "1.1.0" 691 + resolved "https://registry.yarnpkg.com/cypress-promise/-/cypress-promise-1.1.0.tgz#f2d66965945fe198431aaf692d5157cea9d47b25" 692 + integrity sha512-DhIf5PJ/a0iY+Yii6n7Rbwq+9TJxU4pupXYzf9mZd8nPG0AzQrj9i+pqINv4xbI2EV1p+PKW3maCkR7oPG4GrA== 693 + 689 694 cypress-real-events@^1.5.0: 690 695 version "1.5.0" 691 696 resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.5.0.tgz#115945872f3e39b90f6896a5a226ff4effa1b8f5"