Merge pull request #7299 from bocoup/issue-7198

CE-292: Confusing tab navigation on explore page for screen readers
This commit is contained in:
cori hudson 2023-04-07 14:39:47 -04:00 committed by GitHub
commit 7dff6e3a5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 226 additions and 92 deletions

View file

@ -20,6 +20,7 @@ const SubNavigation = props => (
'sub-nav-align-right': props.align === 'right' 'sub-nav-align-right': props.align === 'right'
} }
)} )}
role={props.role}
> >
{props.children} {props.children}
</div> </div>
@ -27,6 +28,7 @@ const SubNavigation = props => (
SubNavigation.propTypes = { SubNavigation.propTypes = {
align: PropTypes.string, align: PropTypes.string,
role: PropTypes.string,
children: PropTypes.node, children: PropTypes.node,
className: PropTypes.string className: PropTypes.string
}; };

View file

@ -1,5 +1,5 @@
const classNames = require('classnames');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const {useRef} = require('react');
const React = require('react'); const React = require('react');
const SubNavigation = require('../../components/subnavigation/subnavigation.jsx'); const SubNavigation = require('../../components/subnavigation/subnavigation.jsx');
@ -10,17 +10,94 @@ require('./tabs.scss');
* Container for a custom, horizontal list of navigation elements * Container for a custom, horizontal list of navigation elements
* that can be displayed within a view or component. * that can be displayed within a view or component.
*/ */
const Tabs = props => ( const Tabs = ({items, activeTabName}) => {
<div className="tab-background"> const tabElementRefs = useRef({});
<SubNavigation className={classNames('tabs', props.className)}>
{props.children} const itemsRendered = items.map(({name, onTrigger, getContent}) => {
const isActive = name === activeTabName;
let tabRef;
if (tabElementRefs.current[name]) {
tabRef = tabElementRefs.current[name];
} else {
tabRef = React.createRef();
tabElementRefs.current[name] = tabRef;
}
return (
<button
role="tab"
aria-selected={`${isActive ? 'true' : 'false'}`}
className={`${isActive ? 'active' : ''}`}
onClick={onTrigger}
tabIndex={isActive ? 0 : -1}
key={name}
ref={tabRef}
>
{getContent(isActive)}
</button>
);
});
const handleKeyDown = event => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' '].includes(event.key)) {
return;
}
event.preventDefault();
const focusedIndex = Object.values(tabElementRefs.current)
.findIndex(tabElementRef =>
document.activeElement === tabElementRef.current
);
if (event.key === 'ArrowLeft') {
let nextIndex;
if (focusedIndex === 0) {
nextIndex = Object.values(tabElementRefs.current).length - 1;
} else {
nextIndex = focusedIndex - 1;
}
Object.values(tabElementRefs.current)[nextIndex].current.focus();
} else if (event.key === 'ArrowRight') {
let nextIndex;
if (focusedIndex === Object.values(tabElementRefs.current).length - 1) {
nextIndex = 0;
} else {
nextIndex = focusedIndex + 1;
}
Object.values(tabElementRefs.current)[nextIndex].current.focus();
} else if (event.key === 'Home') {
Object.values(tabElementRefs.current)[0].current.focus();
} else if (event.key === 'End') {
const lastTab = Object.values(tabElementRefs.current).length - 1;
Object.values(tabElementRefs.current)[lastTab].current.focus();
} else if (event.key === 'Enter' || event.key === ' ') {
items[focusedIndex].onTrigger();
}
};
return (
<div
className="tab-background"
onKeyDown={handleKeyDown}// eslint-disable-line
>
<SubNavigation
role="tablist"
className="tabs"
>
{itemsRendered}
</SubNavigation> </SubNavigation>
</div> </div>
); );
};
Tabs.propTypes = { Tabs.propTypes = {
children: PropTypes.node, items: PropTypes.arrayOf(
className: PropTypes.string PropTypes.shape({
name: PropTypes.string.isRequired,
onTrigger: PropTypes.func.isRequired,
getContent: PropTypes.func.isRequired
})
).isRequired,
activeTabName: PropTypes.string.isRequired
}; };
module.exports = Tabs; module.exports = Tabs;

View file

@ -14,13 +14,14 @@
justify-content: center; justify-content: center;
} }
.tabs li { .tabs button {
margin: 0; margin: 0;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
width: $cols2; width: $cols2;
text-align: center; text-align: center;
color: $header-gray; color: $header-gray;
background-color: transparent;
&.active { &.active {
border-bottom: 3px solid $ui-aqua; border-bottom: 3px solid $ui-aqua;

View file

@ -74,6 +74,8 @@
"general.download": "Download", "general.download": "Download",
"general.password": "Password", "general.password": "Password",
"general.press": "Press", "general.press": "Press",
"general.projectsSelected": "Projects Tab Selected",
"general.projectsNotS": "Projects",
"general.privacyPolicy": "Privacy Policy", "general.privacyPolicy": "Privacy Policy",
"general.projects": "Projects", "general.projects": "Projects",
"general.profile": "Profile", "general.profile": "Profile",
@ -91,6 +93,8 @@
"general.startOver": "Start over", "general.startOver": "Start over",
"general.statistics": "Statistics", "general.statistics": "Statistics",
"general.studios": "Studios", "general.studios": "Studios",
"general.studiosSelected": "Studios Tab Selected",
"general.studiosNotS": "Studios",
"general.support": "Resources", "general.support": "Resources",
"general.ideas": "Ideas", "general.ideas": "Ideas",
"general.tipsWindow": "Tips Window", "general.tipsWindow": "Tips Window",
@ -111,13 +115,21 @@
"general.seeAllComments": "See all comments", "general.seeAllComments": "See all comments",
"general.all": "All", "general.all": "All",
"general.allSelected": "All Selected",
"general.animations": "Animations", "general.animations": "Animations",
"general.animationsSelected": "Animations Selected",
"general.art": "Art", "general.art": "Art",
"general.artSelected": "Art Selected",
"general.games": "Games", "general.games": "Games",
"general.gamesSelected": "Games Selected",
"general.music": "Music", "general.music": "Music",
"general.musicSelected": "Music Selected",
"general.results": "Results", "general.results": "Results",
"general.resultsSelected": "Results Selected",
"general.stories": "Stories", "general.stories": "Stories",
"general.storiesSelected": "Stories Selected",
"general.tutorials": "Tutorials", "general.tutorials": "Tutorials",
"general.tutorialsSelected": "Tutorials Selected",
"general.teacherAccounts": "Teacher Accounts", "general.teacherAccounts": "Teacher Accounts",

View file

@ -26,10 +26,8 @@ class Explore extends React.Component {
bindAll(this, [ bindAll(this, [
'getExploreState', 'getExploreState',
'handleGetExploreMore', 'handleGetExploreMore',
'changeItemType',
'handleChangeSortMode', 'handleChangeSortMode',
'getBubble', 'getBubble'
'getTab'
]); ]);
this.state = this.getExploreState(); this.state = this.getExploreState();
@ -96,16 +94,7 @@ class Explore extends React.Component {
} }
}); });
} }
changeItemType () {
let newType;
for (const t of this.state.acceptableTypes) {
if (this.state.itemType !== t) {
newType = t;
break;
}
}
window.location = `${window.location.origin}/explore/${newType}/${this.state.tab}/${this.state.mode}`;
}
handleChangeSortMode (name, value) { handleChangeSortMode (name, value) {
if (this.state.acceptableModes.indexOf(value) !== -1) { if (this.state.acceptableModes.indexOf(value) !== -1) {
window.location = window.location =
@ -124,31 +113,7 @@ class Explore extends React.Component {
</a> </a>
); );
} }
getTab (type) {
const classes = classNames({
active: (this.state.itemType === type)
});
return (
<a href={`/explore/${type}/${this.state.category}/${this.state.mode}`}>
<li className={classes}>
{this.state.itemType === type ? [
<img
className={`tab-icon ${type}`}
key={`tab-${type}`}
src={`/svgs/tabs/${type}-active.svg`}
/>
] : [
<img
className={`tab-icon ${type}`}
key={`tab-${type}`}
src={`/svgs/tabs/${type}-inactive.svg`}
/>
]}
<FormattedMessage id={`general.${type}`} />
</li>
</a>
);
}
render () { render () {
return ( return (
<div> <div>
@ -160,10 +125,63 @@ class Explore extends React.Component {
</h1> </h1>
</div> </div>
</TitleBanner> </TitleBanner>
<Tabs> <Tabs
{this.getTab('projects')} items={[
{this.getTab('studios')} {
</Tabs> name: 'projects',
onTrigger: () => {
window.location = `${window.location.origin}/explore/projects/` +
`${this.state.category}/${this.state.mode}`;
},
getContent: isActive => (
<div>
{isActive ? (
<img
className="tab-icon projects"
src="/svgs/tabs/projects-active.svg"
alt=""
/>
) : (
<img
className="tab-icon projects"
src="/svgs/tabs/projects-inactive.svg"
alt=""
/>
)
}
<FormattedMessage id="general.projects" />
</div>
)
},
{
name: 'studios',
onTrigger: () => {
window.location = `${window.location.origin}/explore/studios/` +
`${this.state.category}/${this.state.mode}`;
},
getContent: isActive => (
<div>
{isActive ? (
<img
className="tab-icon studios"
src="/svgs/tabs/studios-active.svg"
alt=""
/>
) : (
<img
className="tab-icon studios"
src="/svgs/tabs/studios-inactive.svg"
alt=""
/>
)
}
<FormattedMessage id="general.studios" />
</div>
)
}
]}
activeTabName={this.state.itemType}
/>
<div className="sort-controls"> <div className="sort-controls">
<SubNavigation className="categories"> <SubNavigation className="categories">
{this.getBubble('all')} {this.getBubble('all')}

View file

@ -31,8 +31,7 @@ class Search extends React.Component {
bindAll(this, [ bindAll(this, [
'getSearchState', 'getSearchState',
'handleChangeSortMode', 'handleChangeSortMode',
'handleGetSearchMore', 'handleGetSearchMore'
'getTab'
]); ]);
this.state = this.getSearchState(); this.state = this.getSearchState();
this.state.loaded = []; this.state.loaded = [];
@ -151,38 +150,6 @@ class Search extends React.Component {
}); });
}); });
} }
getTab (type) {
const termText = this.encodeSearchTerm();
let targetUrl = `/search/${type}`;
if (termText) {
targetUrl += `?q=${termText}`;
}
let allTab = (
<a href={targetUrl}>
<li>
<img
className={`tab-icon ${type}`}
src={`/svgs/tabs/${type}-inactive.svg`}
/>
<FormattedMessage id={`general.${type}`} />
</li>
</a>
);
if (this.state.tab === type) {
allTab = (
<a href={targetUrl}>
<li className="active">
<img
className={`tab-icon ${type}`}
src={`/svgs/tabs/${type}-active.svg`}
/>
<FormattedMessage id={`general.${type}`} />
</li>
</a>
);
}
return allTab;
}
getProjectBox () { getProjectBox () {
const results = ( const results = (
<Grid <Grid
@ -228,10 +195,67 @@ class Search extends React.Component {
</h1> </h1>
</div> </div>
</TitleBanner> </TitleBanner>
<Tabs> <Tabs
{this.getTab('projects')} items={[
{this.getTab('studios')} {
</Tabs> name: 'projects',
onTrigger: () => {
const termText = this.encodeSearchTerm();
let targetUrl = `/search/projects`;
if (termText) targetUrl += `?q=${termText}`;
window.location = targetUrl;
},
getContent: isActive => (
<div>
{isActive ? (
<img
className="tab-icon projects"
src="/svgs/tabs/projects-active.svg"
alt=""
/>
) : (
<img
className="tab-icon projects"
src="/svgs/tabs/projects-inactive.svg"
alt=""
/>
)
}
<FormattedMessage id="general.projects" />
</div>
)
},
{
name: 'studios',
onTrigger: () => {
const termText = this.encodeSearchTerm();
let targetUrl = `/search/studios`;
if (termText) targetUrl += `?q=${termText}`;
window.location = targetUrl;
},
getContent: isActive => (
<div>
{isActive ? (
<img
className="tab-icon studios"
src="/svgs/tabs/studios-active.svg"
alt=""
/>
) : (
<img
className="tab-icon studios"
src="/svgs/tabs/studios-inactive.svg"
alt=""
/>
)
}
<FormattedMessage id="general.studios" />
</div>
)
}
]}
activeTabName={this.state.tab}
/>
<div className="sort-controls"> <div className="sort-controls">
<Form className="sort-mode"> <Form className="sort-mode">
<Select <Select

View file

@ -1 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><title>Icons</title><path d="M13.39,3.29V9.4a0.41,0.41,0,0,1-.14.31,4.18,4.18,0,0,1-5.51,0,3.42,3.42,0,0,0-2.23-.84,3.35,3.35,0,0,0-2.07.72v4.05a0.42,0.42,0,1,1-.84,0V3.29A0.41,0.41,0,0,1,2.87,2.9,4.17,4.17,0,0,1,8.27,3a3.39,3.39,0,0,0,4.45,0,0.39,0.39,0,0,1,.43-0.06A0.4,0.4,0,0,1,13.39,3.29Z" fill="#26d9bb" stroke="#22b296" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg aria-label="Check Check" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><title>Icons</title><path d="M13.39,3.29V9.4a0.41,0.41,0,0,1-.14.31,4.18,4.18,0,0,1-5.51,0,3.42,3.42,0,0,0-2.23-.84,3.35,3.35,0,0,0-2.07.72v4.05a0.42,0.42,0,1,1-.84,0V3.29A0.41,0.41,0,0,1,2.87,2.9,4.17,4.17,0,0,1,8.27,3a3.39,3.39,0,0,0,4.45,0,0.39,0.39,0,0,1,.43-0.06A0.4,0.4,0,0,1,13.39,3.29Z" fill="#26d9bb" stroke="#22b296" stroke-linecap="round" stroke-linejoin="round"/></svg>

Before

Width:  |  Height:  |  Size: 474 B

After

Width:  |  Height:  |  Size: 499 B