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:
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}>
|
||||
<span>{isOAS3 ? "Schemas" : "Models" }</span>
|
||||
<svg width="20" height="20">
|
||||
<use xlinkHref={showModels ? "#large-arrow-down" : "#large-arrow"} />
|
||||
</svg>
|
||||
<h4>
|
||||
<button
|
||||
aria-expanded={showModels}
|
||||
className="models-control"
|
||||
onClick={() => layoutActions.show(specPathBase, !showModels)}
|
||||
>
|
||||
<span>{isOAS3 ? "Schemas" : "Models"}</span>
|
||||
<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}>
|
||||
{
|
||||
|
||||
@@ -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,17 +63,28 @@ 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} >
|
||||
<OperationSummaryMethod method={method} />
|
||||
<OperationSummaryPath getComponent={getComponent} operationProps={operationProps} specPath={specPath} />
|
||||
<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} />
|
||||
|
||||
{!showSummary ? null :
|
||||
<div className="opblock-summary-description">
|
||||
{toString(resolvedSummary || summary)}
|
||||
</div>
|
||||
}
|
||||
{!showSummary ? null :
|
||||
<div className="opblock-summary-description">
|
||||
{toString(resolvedSummary || summary)}
|
||||
</div>
|
||||
}
|
||||
|
||||
{displayOperationId && (originalOperationId || operationId) ? <span className="opblock-summary-operation-id">{originalOperationId || operationId}</span> : null}
|
||||
{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 :
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 :
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
10
src/helpers/create-html-ready-id.js
Normal file
10
src/helpers/create-html-ready-id.js
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user