Skip to content

Commit

Permalink
feat(overlay-dropdown): add arrow key navigation
Browse files Browse the repository at this point in the history
Closes #2626
  • Loading branch information
Niklas Kiefer committed Jan 17, 2022
1 parent bbead93 commit fb0c2b8
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 4 deletions.
103 changes: 102 additions & 1 deletion client/src/shared/ui/__tests__/OverlayDropdownSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,105 @@ describe('<OverlayDropdown>', function() {
});
});

});

describe('arrow navigation', () => {

let wrapper;

function expectFocus(selector) {
const newFocus = wrapper.find(selector).getDOMNode();
expect(document.activeElement).to.eql(newFocus);
}

function focusAndNavigate(selector, keyCode) {
const item = wrapper.find(selector);

item.getDOMNode().focus();
item.simulate('keyDown', { keyCode });
}

beforeEach(() => {

const items = [
{ key: 'section1', items: [ { text: 'item1' }, { text: 'item2' } ] },
{ key: 'section2', items: [ { text: 'item3' }, { text: 'item4' } ] },
{ key: 'section3', items: [ { text: 'item5' }, { text: 'item6' } ] }
];

wrapper = mount((
<OverlayDropdown shouldOpen={ true } items={ items } buttonRef={ mockButtonRef }>
foo
</OverlayDropdown>
));
});


it('should auto-focus first element', () => {

// then
expectFocus('button[title="item1"]', wrapper);
});


it('should focus next item', () => {

// when
focusAndNavigate('button[title="item1"]', 40);

// then
expectFocus('button[title="item2"]', wrapper);
});


it('should focus next section', () => {

// when
focusAndNavigate('button[title="item2"]', 40);

// then
expectFocus('button[title="item3"]', wrapper);
});


it('should focus first section on end', () => {

// when
focusAndNavigate('button[title="item6"]', 40);

// then
expectFocus('button[title="item1"]', wrapper);
});


it('should focus previous item', () => {

// when
focusAndNavigate('button[title="item2"]', 38);

// then
expectFocus('button[title="item1"]', wrapper);
});


it('should focus previous section', () => {

// when
focusAndNavigate('button[title="item3"]', 38);

// then
expectFocus('button[title="item2"]', wrapper);
});


it('should focus last section on start', () => {

// when
focusAndNavigate('button[title="item1"]', 38);

// then
expectFocus('button[title="item6"]', wrapper);
});

});

});
82 changes: 80 additions & 2 deletions client/src/shared/ui/overlay/OverlayDropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { Overlay, Section } from '..';

import css from './OverlayDropdown.less';

const LIST_ITEM_SELECTOR = 'li[role="menuitem"]';

/**
* @typedef {{ text: String, onClick: Function, icon?: React.Component }} Item
*/
Expand Down Expand Up @@ -140,7 +142,7 @@ function Options(props) {

return (
<Section.Body>
<ul>
<ul role="menu">
{
items.map((item, index) =>
<Option
Expand All @@ -162,8 +164,22 @@ function Option(props) {
icon: IconComponent
} = props;

const handleKeydown = (event) => {
const {
key,
keyCode,
currentTarget
} = event;

if (key === 'ArrowDown' || keyCode == 40) {
focusNext(currentTarget);
} else if (key === 'ArrowUp' || keyCode == 38) {
focusPrevious(currentTarget);
}
};

return (
<li onClick={ onClick }>
<li role="menuitem" onClick={ onClick } onKeyDown={ handleKeydown }>
<button type="button" title={ text }>
{ IconComponent && <IconComponent /> }
{ text }
Expand All @@ -177,4 +193,66 @@ function Option(props) {

function isGrouped(items) {
return items.length && items[0].key;
}

/**
*
* @param {Node} focusElement
*/
function focusNext(focusElement) {
const { nextSibling } = focusElement;

// (1) focus immediate neighbor
if (nextSibling) {
return nextSibling.querySelector('button').focus();
}

// (2) try to find neighbor in other section
const currenSection = focusElement.closest('section');
const { nextElementSibling: nextSection } = currenSection;

if (nextSection) {
return nextSection.querySelector(`${LIST_ITEM_SELECTOR} button`).focus();
}

// (3) when on end of sections, try first one
const parentContainer = focusElement.closest('[role="dialog"]');

const lastSection = parentContainer.querySelector('section:last-child');
const firstSection = parentContainer.querySelector('section:first-child');

if (currenSection === lastSection) {
return firstSection.querySelector(`${LIST_ITEM_SELECTOR} button`).focus();
}
}

/**
*
* @param {Node} focusElement
*/
function focusPrevious(focusElement) {
const { previousSibling } = focusElement;

// (1) focus immediate neighbor
if (previousSibling) {
return previousSibling.querySelector('button').focus();
}

// (2) try to find neighbor in other section
const currenSection = focusElement.closest('section');
const { previousElementSibling: previousSection } = currenSection;

if (previousSection) {
return previousSection.querySelector(`${LIST_ITEM_SELECTOR}:last-child button`).focus();
}

// (3) when on start of sections, try last one
const parentContainer = focusElement.closest('[role="dialog"]');

const lastSection = parentContainer.querySelector('section:last-child');
const firstSection = parentContainer.querySelector('section:first-child');

if (currenSection === firstSection) {
return lastSection.querySelector(`${LIST_ITEM_SELECTOR}:last-child button`).focus();
}
}
2 changes: 1 addition & 1 deletion client/src/shared/ui/overlay/OverlayDropdown.less
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
background-color: var(--overlay-dropdown-option-hover-background-color);
}

li button:focus-visible {
li button:focus {
background-color: var(--overlay-dropdown-focus-hover-background-color);
outline: none;
box-shadow: none;
Expand Down

0 comments on commit fb0c2b8

Please sign in to comment.