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

Add initial selection and focus tests

+1 -4
cypress/support/index.js
···
// https://on.cypress.io/configuration
// ***********************************************************
-
// Import commands.js using ES2015 syntax:
+
import 'cypress-promise/register';
import 'cypress-real-events/support';
-
-
// Alternatively you can use CommonJS syntax:
-
// require('./commands')
+1
package.json
···
"@rollup/plugin-node-resolve": "^13.0.2",
"@types/react": "^17.0.14",
"cypress": "^8.0.0",
+
"cypress-promise": "^1.1.0",
"cypress-real-events": "^1.5.0",
"husky-v4": "^4.3.8",
"lint-staged": "^11.0.1",
+41
src/utils/__tests__/focus.test.tsx
···
+
import React from 'react';
+
import { mount } from '@cypress/react';
+
import { getFocusTargets } from '../focus';
+
+
it('detects typical focusable elements', () => {
+
const Tabbables = () => (
+
<main>
+
<button id="start">Start</button>
+
<div className="targets" style={{ display: 'flex', flexDirection: 'column' }}>
+
<input type="hidden" className="ignored" />
+
<input type="text" disabled className="ignored" />
+
<button tabIndex={-1} className="ignored" />
+
<button style={{ visibility: 'hidden' }} className="ignored">Invisible</button>
+
<button style={{ display: 'none' }} className="ignored">Invisible</button>
+
<a className="ignored">No href</a>
+
+
<input type="text" />
+
<textarea></textarea>
+
<button>Button</button>
+
<a href="#">Link</a>
+
<div tabIndex={0}>Tabbable</div>
+
</div>
+
</main>
+
);
+
+
mount(<Tabbables />);
+
cy.get('#start').focus();
+
+
const actualTargets: HTMLElement[] = [];
+
for (let i = 0; i < 5; i++) {
+
cy.realPress('Tab');
+
cy.focused().should('not.have.class', 'ignored').then($el => {
+
actualTargets.push($el.get(0));
+
});
+
}
+
+
cy.get('.targets').then($el => {
+
const element = $el.get(0);
+
expect(getFocusTargets(element)).to.deep.equal(actualTargets);
+
});
+
});
+100
src/utils/__tests__/selection.test.tsx
···
+
import React from 'react';
+
import { mount } from '@cypress/react';
+
import { snapshotSelection, restoreSelection } from '../selection';
+
+
it('should restore focused elements', async () => {
+
await mount(<button id="button">Focusable</button>).promisify();
+
+
let button: HTMLElement | null = null;
+
+
await cy.get('button').as('button').then($el => {
+
button = $el.get(0);
+
}).focus().promisify();
+
+
const selection = snapshotSelection();
+
+
// check selection matches expected state
+
expect(selection).to.deep.equal({
+
element: button,
+
method: 'focus',
+
});
+
+
// unfocus the button
+
await cy.realPress('Tab');
+
await cy.focused().should('not.exist').promisify();
+
+
// restore the snapshotted selection
+
restoreSelection(selection);
+
await cy.focused().should('exist').promisify();
+
await cy.get('@button').should('have.focus').promisify();
+
});
+
+
it('should restore input selections', async () => {
+
await mount(<input type="text" name="text" />).promisify();
+
+
let input: HTMLElement | null = null;
+
+
await cy.get('input').as('input').then($el => {
+
input = $el.get(0);
+
}).focus().promisify();
+
+
// type and move selection
+
await cy.realType('test');
+
await cy.realPress('ArrowLeft');
+
await cy.realPress('ArrowLeft');
+
+
const selection = snapshotSelection();
+
+
// check selection matches expected state
+
expect(selection).to.deep.equal({
+
element: input,
+
method: 'setSelectionRange',
+
arguments: [2, 2, 'none'],
+
});
+
+
// unfocus the input
+
await cy.realPress('Tab');
+
await cy.focused().should('not.exist').promisify();
+
+
// restore the snapshotted selection
+
restoreSelection(selection);
+
await cy.focused().should('exist').promisify();
+
+
// modify input at selected caret and check value
+
await cy.realType('test');
+
await cy.get('@input').should('have.value', 'tetestst').promisify();
+
});
+
+
it('should restore selections otherwise', async () => {
+
await mount(<div contentEditable="true" id="editable"></div>).promisify();
+
+
let div: HTMLElement | null = null;
+
+
await cy.get('#editable').as('editable').then($el => {
+
div = $el.get(0);
+
}).focus().promisify();
+
+
// type and move selection
+
await cy.realType('test');
+
await cy.realPress('ArrowLeft');
+
await cy.realPress('ArrowLeft');
+
+
const selection = snapshotSelection();
+
+
// check selection matches expected state
+
expect(selection).to.have.property('element', div);
+
expect(selection).to.have.property('method', 'range');
+
expect(selection).to.have.property('range');
+
+
// unfocus the input
+
await cy.realPress('Tab');
+
await cy.focused().should('not.exist').promisify();
+
+
// restore the snapshotted selection
+
restoreSelection(selection);
+
await cy.focused().should('exist').promisify();
+
+
// modify input at selected caret and check value
+
await cy.realType('test');
+
await cy.get('@editable').should('have.text', 'tetestst').promisify();
+
});
+4 -1
src/utils/focus.ts
···
}
return tabIndexTargets.length
-
? targets.concat(tabIndexTargets.sort(sortByTabindex).map(x => x[2]))
+
? tabIndexTargets
+
.sort(sortByTabindex)
+
.map(x => x[2])
+
.concat(targets)
: targets;
};
+8 -2
src/utils/selection.ts
···
+
import { contains } from './element';
+
interface RestoreInputSelection {
element: HTMLElement;
method: 'setSelectionRange';
···
| RestoreSelectionRange;
const isInputElement = (node: HTMLElement): node is HTMLInputElement =>
-
(node.nodeName === 'input' || node.nodeName === 'textarea') &&
+
(node.nodeName === 'INPUT' || node.nodeName === 'TEXTAREA') &&
typeof (node as HTMLInputElement).selectionStart === 'number' &&
typeof (node as HTMLInputElement).selectionEnd === 'number';
···
const selection = window.getSelection && window.getSelection();
if (selection && selection.rangeCount) {
const range = selection.getRangeAt(0);
-
return { element, method: 'range', range };
+
if (contains(target, range.startContainer)) {
+
return { element, method: 'range', range };
+
}
}
return { element, method: 'focus' };
···
if (!restore || !target || !target.parentNode) {
return;
} else if (restore.method === 'setSelectionRange' && isInputElement(target)) {
+
target.focus();
target.setSelectionRange(...restore.arguments);
} else if (restore.method === 'range') {
const selection = window.getSelection()!;
+
target.focus();
selection.removeAllRanges();
selection.addRange(restore.range);
} else {
+6 -1
tsconfig.json
···
{
"compilerOptions": {
-
"types": ["react", "cypress", "cypress-real-events"],
+
"types": [
+
"react",
+
"cypress",
+
"cypress-real-events",
+
"cypress-promise/register"
+
],
"baseUrl": "./",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
+5
yarn.lock
···
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340"
integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==
+
cypress-promise@^1.1.0:
+
version "1.1.0"
+
resolved "https://registry.yarnpkg.com/cypress-promise/-/cypress-promise-1.1.0.tgz#f2d66965945fe198431aaf692d5157cea9d47b25"
+
integrity sha512-DhIf5PJ/a0iY+Yii6n7Rbwq+9TJxU4pupXYzf9mZd8nPG0AzQrj9i+pqINv4xbI2EV1p+PKW3maCkR7oPG4GrA==
+
cypress-real-events@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.5.0.tgz#115945872f3e39b90f6896a5a226ff4effa1b8f5"