Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(Tabs): ignore disabled tabs on keyboard navigation #4784

Merged
5 changes: 1 addition & 4 deletions packages/react/src/components/Tab/Tab-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,21 +101,18 @@ describe('Tab', () => {

describe('keydown', () => {
const onKeyDown = jest.fn();
const handleTabAnchorFocus = jest.fn();
const handleTabKeyDown = jest.fn();
const wrapper = shallow(<Tab label="firstTab" />);
wrapper.setProps({ onKeyDown, handleTabAnchorFocus, handleTabKeyDown });
wrapper.setProps({ onKeyDown, handleTabKeyDown });

it('invokes onKeyDown when a function is passed to onKeyDown prop', () => {
wrapper.simulate('keyDown', { which: 38 });
expect(onKeyDown).toBeCalled();
expect(handleTabAnchorFocus).not.toBeCalled();
});

it('invokes handleTabAnchorFocus when onKeyDown occurs for appropriate events', () => {
wrapper.simulate('keyDown', { which: 37 });
expect(onKeyDown).toBeCalled();
expect(handleTabAnchorFocus).toBeCalled();
});
});
});
Expand Down
20 changes: 0 additions & 20 deletions packages/react/src/components/Tab/Tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,6 @@ export default class Tab extends React.Component {
*/
handleTabClick: PropTypes.func,

/**
* A handler that is invoked when a user presses left/right key.
* Reserved for usage in Tabs
*/
handleTabAnchorFocus: PropTypes.func,

/**
* A handler that is invoked on the key down event for the control.
* Reserved for usage in Tabs
Expand Down Expand Up @@ -108,23 +102,10 @@ export default class Tab extends React.Component {
onKeyDown: () => {},
};

setTabFocus(evt) {
const leftKey = 37;
const rightKey = 39;
if (evt.which === leftKey) {
this.props.handleTabAnchorFocus(this.props.index - 1);
} else if (evt.which === rightKey) {
this.props.handleTabAnchorFocus(this.props.index + 1);
} else {
return;
}
}

render() {
const {
className,
handleTabClick,
handleTabAnchorFocus, // eslint-disable-line
handleTabKeyDown,
disabled,
href,
Expand Down Expand Up @@ -172,7 +153,6 @@ export default class Tab extends React.Component {
if (disabled) {
return;
}
this.setTabFocus(evt);
handleTabKeyDown(index, evt);
onKeyDown(evt);
}}
Expand Down
9 changes: 6 additions & 3 deletions packages/react/src/components/Tabs/Tabs-story.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,17 @@ storiesOf('Tabs', module)
<Tab {...props.tab()} label="Tab label 2">
<div className="some-content">Content for second tab goes here.</div>
</Tab>
<Tab {...props.tab()} label="Tab label 3" disabled>
<div className="some-content">Content for third tab goes here.</div>
</Tab>
<Tab
{...props.tab()}
label="Tab label 3"
label="Tab label 4"
renderContent={TabContentRenderedOnlyWhenSelected}>
<div className="some-content">Content for third tab goes here.</div>
<div className="some-content">Content for fourth tab goes here.</div>
</Tab>
<Tab {...props.tab()} label={<CustomLabel text="Custom Label" />}>
<div className="some-content">Content for fourth tab goes here.</div>
<div className="some-content">Content for fifth tab goes here.</div>
</Tab>
</Tabs>
),
Expand Down
62 changes: 60 additions & 2 deletions packages/react/src/components/Tabs/Tabs-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,25 @@

import React from 'react';
import { ChevronDownGlyph } from '@carbon/icons-react';
import { settings } from 'carbon-components';
import { shallow, mount } from 'enzyme';
import Tabs from '../Tabs';
import Tab from '../Tab';
import TabsSkeleton from '../Tabs/Tabs.Skeleton';
import { shallow, mount } from 'enzyme';
import { settings } from 'carbon-components';

const { prefix } = settings;

window.matchMedia = jest.fn().mockImplementation(query => ({
matches: true,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));

describe('Tabs', () => {
describe('renders as expected', () => {
describe('navigation (<div>)', () => {
Expand Down Expand Up @@ -241,6 +252,53 @@ describe('Tabs', () => {
expect(wrapper.state().selected).toEqual(1);
});
});

describe('ignore disabled child tab', () => {
let wrapper;
let firstTab;
let lastTab;
beforeEach(() => {
wrapper = mount(
<Tabs>
<Tab label="firstTab" className="firstTab">
content1
</Tab>
<Tab label="middleTab" className="middleTab" disabled>
content2
</Tab>
<Tab label="lastTab" className="lastTab">
content3
</Tab>
</Tabs>
);
firstTab = wrapper.find('.firstTab').last();
lastTab = wrapper.find('.lastTab').last();
});
it('updates selected state when pressing arrow keys', () => {
firstTab.simulate('keydown', { which: rightKey });
expect(wrapper.state().selected).toEqual(2);
lastTab.simulate('keydown', { which: leftKey });
expect(wrapper.state().selected).toEqual(0);
});

it('loops focus and selected state from lastTab to firstTab', () => {
wrapper.setState({ selected: 2 });
lastTab.simulate('keydown', { which: rightKey });
expect(wrapper.state().selected).toEqual(0);
});

it('loops focus and selected state from firstTab to lastTab', () => {
firstTab.simulate('keydown', { which: leftKey });
expect(wrapper.state().selected).toEqual(2);
});

it('updates selected state when pressing space or enter key', () => {
firstTab.simulate('keydown', { which: spaceKey });
expect(wrapper.state().selected).toEqual(0);
lastTab.simulate('keydown', { which: enterKey });
expect(wrapper.state().selected).toEqual(2);
});
});
});
});

Expand Down
62 changes: 40 additions & 22 deletions packages/react/src/components/Tabs/Tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import React from 'react';
import classNames from 'classnames';
import { ChevronDownGlyph } from '@carbon/icons-react';
import { settings } from 'carbon-components';
import { keys, match, matches } from '../../internal/keyboard';

const { prefix } = settings;

Expand Down Expand Up @@ -116,6 +117,12 @@ export default class Tabs extends React.Component {
return React.Children.map(this.props.children, tab => tab);
}

getEnabledTabs = () =>
React.Children.toArray(this.props.children).reduce(
(acc, tab, index) => (!tab.props.disabled ? acc.concat(index) : acc),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we're looping through children, do we ever need to do null checks? Wasn't sure about toArray but was just remembering some issues with UI Shell where this was coming up.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[]
);

getTabAt = (index, useFresh) => {
return (
(!useFresh && this[`tab${index}`]) ||
Expand All @@ -139,35 +146,47 @@ export default class Tabs extends React.Component {
};
};

getDirection = evt => {
if (match(evt, keys.ArrowLeft)) {
return -1;
}
if (match(evt, keys.ArrowRight)) {
return 1;
}
return 0;
};

getNextIndex = (index, direction) => {
const enabledTabs = this.getEnabledTabs();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that doesn't look like a drop-in replacement for the function I have currently

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just was speaking to figuring out the nextIndex and using a ring buffer instead of the current logic 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does it account for disabled tab indices?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@emyarod if I understand correctly, the ring buffer would be done on a filtered down version of tabs, correct?

const tabs = [
  {
    title: 'Tab A',
    disabled: false,
  },
  {
    title: 'Tab B',
    disabled: true,
  },
  {
    title: 'Tab B',
    disabled: false,
  },
];

const enabledTabs = tabs.filter(tab => !tab.disabled);
getNextIndex(ArrowRight, 0, enabledTabs.length); // 1
getNextIndex(ArrowRight, 1, enabledTabs.length); // 0
getNextIndex(ArrowLeft, 0, enabledTabs.length); // 1

Definitely get that it's not mapping exactly 1:1, but just was seeing if the pattern was worthwhile to match 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the returned index from that function corresponds to the element's location in the filtered array and not the full array. but if you are just referring to the pattern then I have implemented it already, I'm just also accounting for the fact that the component is expecting the full array and not only the enabled tabs

const nextIndex = Math.max(
enabledTabs.indexOf(index) + direction,
-1 /* For `tab` not found in `enabledTabs` */
);
const nextIndexLooped =
nextIndex >= 0 && nextIndex < enabledTabs.length
? nextIndex
: nextIndex - Math.sign(nextIndex) * enabledTabs.length;
return enabledTabs[nextIndexLooped];
};

handleTabKeyDown = onSelectionChange => {
return (index, evt) => {
const key = evt.key || evt.which;

if (key === 'Enter' || key === 13 || key === ' ' || key === 32) {
if (matches(evt, [keys.Enter, keys.Space])) {
this.selectTabAt(index, onSelectionChange);
this.setState({
dropdownHidden: true,
});
}
};
};

handleTabAnchorFocus = onSelectionChange => {
return index => {
const tabCount = React.Children.count(this.props.children) - 1;
let tabIndex = index;
if (index < 0) {
tabIndex = tabCount;
} else if (index > tabCount) {
tabIndex = 0;
}

const tab = this.getTabAt(tabIndex);

if (tab) {
this.selectTabAt(tabIndex, onSelectionChange);
if (tab.tabAnchor) {
tab.tabAnchor.focus();
if (window.matchMedia('(min-width: 42rem)').matches) {
evt.preventDefault();
const nextIndex = this.getNextIndex(index, this.getDirection(evt));
const tab = this.getTabAt(nextIndex);
if (tab) {
this.selectTabAt(nextIndex, onSelectionChange);
if (tab.tabAnchor) {
tab.tabAnchor.focus();
}
}
}
};
Expand Down Expand Up @@ -222,7 +241,6 @@ export default class Tabs extends React.Component {
index,
selected: index === this.state.selected,
handleTabClick: this.handleTabClick(onSelectionChange),
handleTabAnchorFocus: this.handleTabAnchorFocus(onSelectionChange),
tabIndex,
ref: e => {
this.setTabAt(index, e);
Expand Down