feat: Accessibility improvements (#7224)

* feat: adds a11y for ContentType & Responses region

* feat: adds a11y to expandable/collapsible elements

* fix: add aria label to select element for content types

* fix: add aria label prop to contentType component

* Change optag to h3 for better tag hierarchy


Co-authored-by: ediiotero <eddie.otero@oddball.io>
Co-authored-by: Mike Lumetta <mike.lumetta@adhocteam.us>
Co-authored-by: Alexander Valencia <alex.valencia@adhocteam.us>
This commit is contained in:
Giles Wells
2021-05-12 12:40:31 -04:00
committed by GitHub
parent 8872d0e2ed
commit 72811bd827
12 changed files with 96 additions and 55 deletions

View File

@@ -8,7 +8,9 @@ const noop = ()=>{}
export default class ContentType extends React.Component {
static propTypes = {
ariaControls: PropTypes.string,
contentTypes: PropTypes.oneOfType([ImPropTypes.list, ImPropTypes.set, ImPropTypes.seq]),
controlId: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
className: PropTypes.string,
@@ -41,14 +43,14 @@ export default class ContentType extends React.Component {
onChangeWrapper = e => this.props.onChange(e.target.value)
render() {
let { contentTypes, className, value, ariaLabel } = this.props
let { ariaControls, ariaLabel, className, contentTypes, controlId, value } = this.props
if ( !contentTypes || !contentTypes.size )
return null
return (
<div className={ "content-type-wrapper " + ( className || "" ) }>
<select className="content-type" aria-label={ariaLabel} value={value || ""} onChange={this.onChangeWrapper} >
<select aria-controls={ariaControls} aria-label={ariaLabel} className="content-type" id={controlId} onChange={this.onChangeWrapper} value={value || ""} >
{ contentTypes.map( (val) => {
return <option key={ val } value={ val }>{ val }</option>
}).toArray()}

View File

@@ -86,15 +86,13 @@ export default class ModelCollapse extends Component {
return (
<span className={classes || ""} ref={this.onLoad}>
{ title && <span onClick={this.toggleCollapsed} className="pointer">{title}</span> }
<span onClick={ this.toggleCollapsed } className="pointer">
<button aria-expanded={this.state.expanded} className="model-box-control" onClick={this.toggleCollapsed}>
{ title && <span className="pointer">{title}</span> }
<span className={ "model-toggle" + ( this.state.expanded ? "" : " collapsed" ) }></span>
</span>
{
this.state.expanded
? this.props.children
: <span onClick={this.toggleCollapsed} className="pointer">{this.state.collapsedContent}</span>
}
{ !this.state.expanded && <span>{this.state.collapsedContent}</span> }
</button>
{ this.state.expanded && this.props.children }
</span>
)
}

View File

@@ -58,11 +58,17 @@ export default class Models extends Component {
const JumpToPath = getComponent("JumpToPath")
return <section className={ showModels ? "models is-open" : "models"} ref={this.onLoadModels}>
<h4 onClick={() => layoutActions.show(specPathBase, !showModels)}>
<h4>
<button
aria-expanded={showModels}
className="models-control"
onClick={() => layoutActions.show(specPathBase, !showModels)}
>
<span>{isOAS3 ? "Schemas" : "Models"}</span>
<svg width="20" height="20">
<use xlinkHref={showModels ? "#large-arrow-down" : "#large-arrow"} />
<svg width="20" height="20" aria-hidden="true" focusable="false">
<use xlinkHref={showModels ? "#large-arrow-up" : "#large-arrow-down"} />
</svg>
</button>
</h4>
<Collapse isOpened={showModels}>
{

View File

@@ -10,6 +10,7 @@ export default class OperationSummary extends PureComponent {
static propTypes = {
specPath: ImPropTypes.list.isRequired,
operationProps: PropTypes.instanceOf(Iterable).isRequired,
isShown: PropTypes.bool.isRequired,
toggleShown: PropTypes.func.isRequired,
getComponent: PropTypes.func.isRequired,
getConfigs: PropTypes.func.isRequired,
@@ -26,6 +27,7 @@ export default class OperationSummary extends PureComponent {
render() {
let {
isShown,
toggleShown,
getComponent,
authActions,
@@ -40,6 +42,7 @@ export default class OperationSummary extends PureComponent {
method,
op,
showSummary,
path,
operationId,
originalOperationId,
displayOperationId,
@@ -60,7 +63,13 @@ export default class OperationSummary extends PureComponent {
const securityIsOptional = hasSecurity && security.size === 1 && security.first().isEmpty()
const allowAnonymous = !hasSecurity || securityIsOptional
return (
<div className={`opblock-summary opblock-summary-${method}`} onClick={toggleShown} >
<div className={`opblock-summary opblock-summary-${method}`} >
<button
aria-label={`${method} ${path.replace(/\//g, "\u200b/")}`}
aria-expanded={isShown}
className="opblock-summary-control"
onClick={toggleShown}
>
<OperationSummaryMethod method={method} />
<OperationSummaryPath getComponent={getComponent} operationProps={operationProps} specPath={specPath} />
@@ -72,6 +81,11 @@ export default class OperationSummary extends PureComponent {
{displayOperationId && (originalOperationId || operationId) ? <span className="opblock-summary-operation-id">{originalOperationId || operationId}</span> : null}
<svg className="arrow" width="20" height="20" aria-hidden="true" focusable="false">
<use href={isShown ? "#large-arrow-up" : "#large-arrow-down"} xlinkHref={isShown ? "#large-arrow-up" : "#large-arrow-down"} />
</svg>
</button>
{
allowAnonymous ? null :
<AuthorizeOperationBtn

View File

@@ -70,7 +70,7 @@ export default class OperationTag extends React.Component {
return (
<div className={showTag ? "opblock-tag-section is-open" : "opblock-tag-section"} >
<h4
<h3
onClick={() => layoutActions.show(isShownKey, !showTag)}
className={!tagDescription ? "opblock-tag no-desc" : "opblock-tag" }
id={isShownKey.map(v => escapeDeepLinkPath(v)).join("-")}
@@ -105,15 +105,16 @@ export default class OperationTag extends React.Component {
</div>
<button
aria-expanded={showTag}
className="expand-operation"
title={showTag ? "Collapse operation": "Expand operation"}
onClick={() => layoutActions.show(isShownKey, !showTag)}>
<svg className="arrow" width="20" height="20">
<use href={showTag ? "#large-arrow-down" : "#large-arrow"} xlinkHref={showTag ? "#large-arrow-down" : "#large-arrow"} />
<svg className="arrow" width="20" height="20" aria-hidden="true" focusable="false">
<use href={showTag ? "#large-arrow-up" : "#large-arrow-down"} xlinkHref={showTag ? "#large-arrow-up" : "#large-arrow-down"} />
</svg>
</button>
</h4>
</h3>
<Collapse isOpened={showTag}>
{children}

View File

@@ -114,7 +114,7 @@ export default class Operation extends PureComponent {
return (
<div className={deprecated ? "opblock opblock-deprecated" : isShown ? `opblock opblock-${method} is-open` : `opblock opblock-${method}`} id={escapeDeepLinkPath(isShownKey.join("-"))} >
<OperationSummary operationProps={operationProps} toggleShown={toggleShown} getComponent={getComponent} authActions={authActions} authSelectors={authSelectors} specPath={specPath} />
<OperationSummary operationProps={operationProps} isShown={isShown} toggleShown={toggleShown} getComponent={getComponent} authActions={authActions} authSelectors={authSelectors} specPath={specPath} />
<Collapse isOpened={isShown}>
<div className="opblock-body">
{ (operation && operation.size) || operation === null ? null :

View File

@@ -3,6 +3,7 @@ import { fromJS, Iterable } from "immutable"
import PropTypes from "prop-types"
import ImPropTypes from "react-immutable-proptypes"
import { defaultStatusCode, getAcceptControllingResponse } from "core/utils"
import createHtmlReadyId from "../../helpers/create-html-ready-id"
export default class Responses extends React.Component {
static propTypes = {
@@ -86,17 +87,22 @@ export default class Responses extends React.Component {
const acceptControllingResponse = isSpecOAS3 ?
getAcceptControllingResponse(responses) : null
const regionId = createHtmlReadyId(`${method}${path}_responses`)
const controlId = `${regionId}_select`
return (
<div className="responses-wrapper">
<div className="opblock-section-header">
<h4>Responses</h4>
{ specSelectors.isOAS3() ? null : <label>
{ specSelectors.isOAS3() ? null : <label htmlFor={controlId}>
<span>Response content type</span>
<ContentType value={producesValue}
onChange={this.onChangeProducesWrapper}
contentTypes={produces}
ariaControls={regionId}
ariaLabel="Response content type"
className="execute-content-type"
ariaLabel="Response content type" />
contentTypes={produces}
controlId={controlId}
onChange={this.onChangeProducesWrapper} />
</label> }
</div>
<div className="responses-inner">
@@ -115,7 +121,7 @@ export default class Responses extends React.Component {
}
<table className="responses-table">
<table aria-live="polite" className="responses-table" id={regionId} role="region">
<thead>
<tr className="responses-header">
<td className="col_header response-col_status">Code</td>

View File

@@ -23,6 +23,9 @@ const SvgAssets = () =>
<path d="M17.418 6.109c.272-.268.709-.268.979 0s.271.701 0 .969l-7.908 7.83c-.27.268-.707.268-.979 0l-7.908-7.83c-.27-.268-.27-.701 0-.969.271-.268.709-.268.979 0L10 13.25l7.418-7.141z"/>
</symbol>
<symbol viewBox="0 0 20 20" id="large-arrow-up">
<path d="M 17.418 14.908 C 17.69 15.176 18.127 15.176 18.397 14.908 C 18.667 14.64 18.668 14.207 18.397 13.939 L 10.489 6.109 C 10.219 5.841 9.782 5.841 9.51 6.109 L 1.602 13.939 C 1.332 14.207 1.332 14.64 1.602 14.908 C 1.873 15.176 2.311 15.176 2.581 14.908 L 10 7.767 L 17.418 14.908 Z"/>
</symbol>
<symbol viewBox="0 0 24 24" id="jump-to">
<path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.41L5.83 13H21V7z"/>

View File

@@ -0,0 +1,10 @@
/**
* Replace invalid characters from a string to create an html-ready ID
*
* @param {string} id A string that may contain invalid characters for the HTML ID attribute
* @param {string} [replacement=_] The string to replace invalid characters with; "_" by default
* @return {string} Information about the parameter schema
*/
export default function createHtmlReadyId(id, replacement = "_") {
return id.replace(/[^\w-]/g, replacement)
}

View File

@@ -110,6 +110,21 @@
}
}
.opblock-summary-control,
.models-control,
.model-box-control
{
all: inherit;
flex: 1;
border-bottom: 0;
padding: 0;
cursor: pointer;
&:focus {
outline: auto;
}
}
.expand-methods,
.expand-operation
{
@@ -143,11 +158,9 @@
}
}
button
{
cursor: pointer;
outline: none;
&.invalid
{

View File

@@ -21,13 +21,13 @@ function ModelCollapseTest(baseUrl, urlFragment) {
it("Models section should collapse and expand when toggled", () => {
cy.visit(baseUrl)
.get(".models h4")
.get(".models h4 .models-control")
.click()
.get(".models")
.should("not.have.class", "is-open")
.get("#model-Order")
.should("not.exist")
.get(".models h4")
.get(".models h4 .models-control")
.click()
.get(".models")
.should("have.class", "is-open")
@@ -35,28 +35,15 @@ function ModelCollapseTest(baseUrl, urlFragment) {
.should("exist")
})
it("Model should collapse and expand when toggled clicking title", () => {
it("Model should collapse and expand when toggled clicking button", () => {
cy.visit(baseUrl)
.get("#model-User .model-box .pointer:nth-child(1)")
.get("#model-User .model-box .model-box-control")
.click()
.get("#model-User .model-box .model .inner-object")
.should("exist")
.get("#model-User .model-box .pointer:nth-child(1)")
.click()
.get("#model-User .model-box .model .inner-object")
.should("not.exist")
})
it("Model should collapse and expand when toggled clicking arrow", () => {
cy.visit(baseUrl)
.get("#model-User .model-box .pointer:nth-child(2)")
.click()
.get("#model-User .model-box .model .inner-object")
.should("exist")
.get("#model-User .model-box .pointer:nth-child(2)")
.get("#model-User .model-box .model-box-control")
.click()
.get("#model-User .model-box .model .inner-object")
.should("not.exist")
})
}

View File

@@ -44,6 +44,7 @@ describe("<OperationTag/>", function(){
const opblockTag = wrapper.find(".opblock-tag")
expect(opblockTag.length).toEqual(1)
expect(opblockTag.getNode().type).toEqual("h3")
const renderedLink = wrapper.find("Link")
expect(renderedLink.length).toEqual(1)