Skip to content

Commit

Permalink
[WIP] improve radio buttons key navigation (closes DevExpress#2067, D…
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexKamaev committed Oct 2, 2018
1 parent 6250a20 commit 932d4e7
Show file tree
Hide file tree
Showing 5 changed files with 246 additions and 62 deletions.
69 changes: 32 additions & 37 deletions src/client/automation/playback/press/shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import testCafeUI from '../../deps/testcafe-ui';
const Promise = hammerhead.Promise;
const browserUtils = hammerhead.utils.browser;
const eventSimulator = hammerhead.eventSandbox.eventSimulator;
const focusBlurSandbox = hammerhead.eventSandbox.focusBlur;
const elementEditingWatcher = hammerhead.eventSandbox.elementEditingWatcher;

const textSelection = testCafeCore.textSelection;
Expand Down Expand Up @@ -232,6 +231,9 @@ function left (element) {
if (domUtils.isSelectElement(element))
selectElement.switchOptionsByKeys(element, 'left');

if (isRadioButtonNavigationRequired(element))
return focusAndCheckNextRadioButton(element, true);

if (domUtils.isTextEditableElement(element)) {
startPosition = textSelection.getSelectionStart(element) || 0;
endPosition = textSelection.getSelectionEnd(element);
Expand Down Expand Up @@ -268,6 +270,9 @@ function right (element) {
if (domUtils.isSelectElement(element))
selectElement.switchOptionsByKeys(element, 'right');

if (isRadioButtonNavigationRequired(element))
return focusAndCheckNextRadioButton(element, false);

if (domUtils.isTextEditableElement(element)) {
startPosition = textSelection.getSelectionStart(element);
endPosition = textSelection.getSelectionEnd(element);
Expand Down Expand Up @@ -304,6 +309,9 @@ function up (element) {
if (domUtils.isSelectElement(element))
selectElement.switchOptionsByKeys(element, 'up');

if (isRadioButtonNavigationRequired(element))
return focusAndCheckNextRadioButton(element, true);

if (browserUtils.isWebKit && domUtils.isInputElement(element))
return home(element);

Expand All @@ -317,6 +325,9 @@ function down (element) {
if (domUtils.isSelectElement(element))
selectElement.switchOptionsByKeys(element, 'down');

if (isRadioButtonNavigationRequired(element))
return focusAndCheckNextRadioButton(element, false);

if (browserUtils.isWebKit && domUtils.isInputElement(element))
return end(element);

Expand Down Expand Up @@ -503,46 +514,30 @@ function enter (element) {
return Promise.resolve();
}

function focusNextElement (element) {
return new Promise(resolve => {
if (domUtils.isSelectElement(element)) {
selectElement.collapseOptionList();
resolve();
}

const nextElement = domUtils.getNextFocusableElement(element);

if (!nextElement)
resolve();

focusBlurSandbox.focus(nextElement, () => {
if (domUtils.isTextEditableInput(nextElement))
textSelection.select(nextElement);
function isRadioButtonNavigationRequired (element) {
return domUtils.isRadioButtonElement(element) && !browserUtils.isFirefox;
}

resolve();
function focusAndCheckNextRadioButton (element, reverse) {
return focusNextElement(element, reverse, false)
.then(focusedElement => {
if (focusedElement)
focusedElement.checked = true;
});
});
}

function focusPrevElement (element) {
return new Promise(resolve => {
if (domUtils.isSelectElement(element)) {
selectElement.collapseOptionList();
resolve();
}

const prevElement = domUtils.getNextFocusableElement(element, true);

if (!prevElement)
resolve();

focusBlurSandbox.focus(prevElement, () => {
if (domUtils.isTextEditableInput(prevElement))
textSelection.select(prevElement);
function focusNextElement (element, reverse, skipRadioGroups) {
if (domUtils.isSelectElement(element)) {
selectElement.collapseOptionList();
return Promise.resolve();
}

resolve();
return domUtils.focusNextElement(element, reverse, skipRadioGroups)
.then(nextElement => {
if (nextElement && domUtils.isTextEditableInput(nextElement))
textSelection.select(nextElement);
return nextElement;
});
});
}

export default {
Expand All @@ -562,7 +557,7 @@ export default {
'home': home,
'end': end,
'enter': enter,
'tab': focusNextElement,
'shift+tab': focusPrevElement,
'tab': element => focusNextElement(element, false, true),
'shift+tab': element => focusNextElement(element, true, true),
'esc': esc
};
106 changes: 81 additions & 25 deletions src/client/core/utils/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import hammerhead from '../deps/hammerhead';
import * as styleUtils from './style';
import * as arrayUtils from './array';


const browserUtils = hammerhead.utils.browser;
const nativeMethods = hammerhead.nativeMethods;
const browserUtils = hammerhead.utils.browser;
const nativeMethods = hammerhead.nativeMethods;
const focusBlurSandbox = hammerhead.eventSandbox.focusBlur;
const Promise = hammerhead.Promise;

export const getActiveElement = hammerhead.utils.dom.getActiveElement;
export const findDocument = hammerhead.utils.dom.findDocument;
Expand Down Expand Up @@ -50,6 +51,8 @@ export const closest = hammerhead.utils.dom.close
export const getParents = hammerhead.utils.dom.getParents;
export const getTopSameDomainWindow = hammerhead.utils.dom.getTopSameDomainWindow;

export const isRadioButtonElement = el => isInputElement(el) && el.type === 'radio';

function getElementsWithTabIndex (elements) {
return arrayUtils.filter(elements, el => el.tabIndex > 0);
}
Expand All @@ -74,7 +77,7 @@ function sortElementsByFocusingIndex (elements) {
return elements;
}

elementsWithTabIndex = elementsWithTabIndex.sort(sortBy('tabIndex'));
elementsWithTabIndex = elementsWithTabIndex.sort(sortBy('tabIndex'));
const elementsWithoutTabIndex = getElementsWithoutTabIndex(elements);

if (iFrames.length)
Expand All @@ -84,9 +87,9 @@ function sortElementsByFocusingIndex (elements) {
}

function insertIFramesContentElements (elements, iFrames) {
const sortedIFrames = sortElementsByTabIndex(iFrames);
const sortedIFrames = sortElementsByTabIndex(iFrames);
let results = [];
const iFramesElements = [];
const iFramesElements = [];
let iframeFocusedElements = [];
let i = 0;

Expand All @@ -110,7 +113,7 @@ function insertIFramesContentElements (elements, iFrames) {
results.pop();

const iFrameElements = iFramesElements[arrayUtils.indexOf(iFrames, elements[i])];
let elementsWithTabIndex = getElementsWithTabIndex(iFrameElements);
let elementsWithTabIndex = getElementsWithTabIndex(iFrameElements);
const elementsWithoutTabIndexArray = getElementsWithoutTabIndex(iFrameElements);

elementsWithTabIndex = elementsWithTabIndex.sort(sortBy('tabIndex'));
Expand Down Expand Up @@ -157,9 +160,9 @@ function getFocusableElements (doc) {
const invisibleElements = getInvisibleElements(allElements);
const inputElementsRegExp = /^(input|button|select|textarea)$/;
const focusableElements = [];
let element = null;
let tagName = null;
let tabIndex = null;
let element = null;
let tagName = null;
let tabIndex = null;

let needPush = false;

Expand Down Expand Up @@ -248,8 +251,8 @@ export function getTextareaIndentInLine (textarea, position) {
export function getTextareaLineNumberByPosition (textarea, position) {
const textareaValue = getTextAreaValue(textarea);
const lines = textareaValue.split('\n');
let topPartLength = 0;
let line = 0;
let topPartLength = 0;
let line = 0;

for (let i = 0; topPartLength <= position; i++) {
if (position <= topPartLength + lines[i].length) {
Expand All @@ -267,7 +270,7 @@ export function getTextareaLineNumberByPosition (textarea, position) {
export function getTextareaPositionByLineAndOffset (textarea, line, offset) {
const textareaValue = getTextAreaValue(textarea);
const lines = textareaValue.split('\n');
let lineIndex = 0;
let lineIndex = 0;

for (let i = 0; i < line; i++)
lineIndex += lines[i].length + 1;
Expand Down Expand Up @@ -364,28 +367,81 @@ export function getElementDescription (el) {
return res.join('');
}

export function getNextFocusableElement (element, reverse) {
const offset = reverse ? -1 : 1;
let allFocusable = sortElementsByFocusingIndex(getFocusableElements(findDocument(element)));
export function focusNextElement (element, reverse, skipRadioGroups) {
return new Promise(resolve => {
const nextElement = getNextFocusableElement(element, reverse, skipRadioGroups);

if (!nextElement)
resolve();

//NOTE: in all browsers except Mozilla and Opera focus sets on one radio set from group only.
// in Mozilla and Opera focus sets on any radio set.
if (isInputElement(element) && element.type === 'radio' && element.name !== '' && !browserUtils.isFirefox) {
allFocusable = arrayUtils.filter(allFocusable, item => {
return !item.name || item === element || item.name !== element.name;
focusBlurSandbox.focus(nextElement, () => {
resolve(nextElement);
});
}
});
}

function filterFocusableElements (elements, sourceElement, skipRadioGroups) {
if (!isRadioButtonElement(sourceElement))
return elements;

const isArrowNavigationAllowed = !skipRadioGroups && sourceElement.name !== '';
const isArrowNavigationBetweenNonamesAllowed = !skipRadioGroups && !sourceElement.name && browserUtils.isChrome;
const isArrowNavigationBetweenNonamesDisallowed = !skipRadioGroups && !sourceElement.name && !browserUtils.isChrome;

elements = arrayUtils.filter(elements, item => {
const isRadioItem = isRadioButtonElement(item);

if (isArrowNavigationAllowed)
return isRadioItem && item.name === sourceElement.name;

if (isArrowNavigationBetweenNonamesAllowed)
return isRadioItem && !item.name;

if (isArrowNavigationBetweenNonamesDisallowed)
return item === sourceElement;

//NOTE: in all browsers except Mozilla and Opera focus sets on one radio set from group only.
// in Mozilla and Opera focus sets on any radio set.

if (sourceElement.name !== '' && !browserUtils.isFirefox)
return !item.name || item === sourceElement || item.name !== sourceElement.name;

return false;
});

return elements;
}

function correctFocusableElement (elements, element, skipRadioGroups) {
const isNotCheckedRadioButtonElement = isRadioButtonElement(element) && element.name && !element.checked;

if (!skipRadioGroups || !isNotCheckedRadioButtonElement)
return element;

const checkedRadioButtonElementWithSameName = arrayUtils.find(elements, el => {
return isRadioButtonElement(el) && el.name === element.name && el.checked;
});

return checkedRadioButtonElementWithSameName || element;
}

export function getNextFocusableElement (element, reverse, skipRadioGroups) {
const offset = reverse ? -1 : 1;
let allFocusable = sortElementsByFocusingIndex(getFocusableElements(findDocument(element)));

allFocusable = filterFocusableElements(allFocusable, element, skipRadioGroups);

const isRadioInput = isRadioButtonElement(element);
const currentIndex = arrayUtils.indexOf(allFocusable, element);
const isLastElementFocused = reverse ? currentIndex === 0 : currentIndex === allFocusable.length - 1;

if (isLastElementFocused)
return document.body;
return skipRadioGroups || !isRadioInput ? document.body : allFocusable[allFocusable.length - 1 - currentIndex];

if (reverse && currentIndex === -1)
return allFocusable[allFocusable.length - 1];

return allFocusable[currentIndex + offset];
return correctFocusableElement(allFocusable, allFocusable[currentIndex + offset], skipRadioGroups);
}

export function getFocusableParent (el) {
Expand Down Expand Up @@ -457,7 +513,7 @@ export function getCommonAncestor (element1, element2) {
if (isTheSameNode(element1, element2))
return element1;

const el1Parents = [element1].concat(getParents(element1));
const el1Parents = [element1].concat(getParents(element1));
let commonAncestor = element2;

while (commonAncestor) {
Expand Down
29 changes: 29 additions & 0 deletions test/functional/fixtures/regression/gh-2067/pages/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<input type="checkbox" id="check1" />

<input type="radio" name="os" id="windows" value="Windows">Windows
<input type="radio" name="os" id="macos" value="MacOS">MacOS
<input type="radio" name="os" id="linux" value="Linux">Linux
<input type="radio" name="os" id="android" value="Android">Android

<input type="radio" name="rad" id="windows1" value="1">1
<input type="radio" name="rad" id="macos1" value="2">2
<input type="radio" name="rad" id="linux1" value="3">3

<input type="text" id="keks" value="" />

<button>btn</button>

<input type="radio" id="ford">ford
<input type="radio" id="bmw">bmw
<input type="radio" id="mazda">mazda
<input type="radio" id="honda">honda

<input type="checkbox" id="check2" />
</body>
</html>
17 changes: 17 additions & 0 deletions test/functional/fixtures/regression/gh-2067/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
describe('[Regression](GH-2067)', function () {
it('named', function () {
return runTests('testcafe-fixtures/index.js', 'named');
});

it('nonamed - chrome', function () {
return runTests('testcafe-fixtures/index.js', 'nonamed - chrome', { only: ['chrome'] });
});

it('nonamed - ie, firefox', function () {
return runTests('testcafe-fixtures/index.js', 'nonamed - ie, firefox', { skip: ['chrome'] });
});

it('Should select the checked radio button by pressing the tab key', function () {
return runTests('testcafe-fixtures/index.js', 'Should select the checked radio button by pressing the tab key');
});
});
Loading

0 comments on commit 932d4e7

Please sign in to comment.