diff --git a/README.md b/README.md index d642d652..5a566df0 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,9 @@ import 'react-accessible-accordion/dist/fancy-example.css'; We recommend that you copy them into your own app and modify them to suit your needs, particularly if you're using your own `className`s. +The accordion trigger is built using native button and heading elements which +have default browser styling, these can be overridden in your stylesheet. + ## Component API ### Accordion @@ -137,9 +140,9 @@ Class(es) to apply to the 'heading' element. #### aria-level : `number` [*optional*, default: `3`] -Semantics to apply to the 'heading' element. A value of `1` would make your -heading element hierarchically equivalent to an `

` tag, and likewise a value -of `6` would make it equivalent to an `

` tag. +Will determine which 'heading' element is used in the markup. A value of `1` +would make your element an `

` tag, and likewise a value of `6` would make it +an `

` tag. ### AccordionItemButton @@ -185,7 +188,8 @@ you, including: - Applying appropriate aria attributes (`aria-expanded`, `aria-controls`, `aria-disabled`, `aria-hidden` and `aria-labelledby`). -- Applying appropriate `role` attributes (`button`, `heading`, `region`). +- Applying appropriate `role` attributes (`region`). +- Using semantic HTML elements (`h1` - `h6`, `button`). - Applying appropriate `tabindex` attributes. - Applying keyboard interactivity ('space', 'end', 'tab', 'up', 'down', 'home' and 'end' keys). @@ -196,10 +200,10 @@ spec-compliant: - Only ever use [phrasing content](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content) inside of your `AccordionItemHeading` component. If in doubt, use text only. -- Always provide an `aria-level` prop to your `AccordionItemHeading` - component, _especially_ if you are nesting accordions. This attribute is a - signal used by assistive technologies (eg. screenreaders) to determine which - heading level (ie. `h1`-`h6`) to treat your heading as. +- Remember to provide an `aria-level` prop to your `AccordionItemHeading` + component, when you are nesting accordions. The levels are used by assistive + technologies (eg. screenreaders) to infer structure, by default each heading + uses `h3` . If you have any questions about your implementation, then please don't be afraid to get in touch via our @@ -225,8 +229,8 @@ description, as written above. By "accordion-like", we mean components which have collapsible items but require bespoke interactive mechanisms in order to expand, collapse and 'disable' them. This includes (but is not limited to) multi-step forms, like those seen in many cart/checkout flows, which we believe -require (other) complex markup in order to be considered 'accessible'. -This also includes disclosure widgets. +require (other) complex markup in order to be considered 'accessible'. This also +includes disclosure widgets. ### How do I disable an item? diff --git a/demo/src/main.css b/demo/src/main.css index 75a215fb..b3a47012 100644 --- a/demo/src/main.css +++ b/demo/src/main.css @@ -134,11 +134,14 @@ footer { margin-top: 10px; } .code__button { - font-weight: bold; cursor: pointer; display: inline-block; padding: 10px 0; color: var(--colorPageLinks); + background-color: transparent; + font: inherit; + font-weight: bold; + border: none; text-decoration: underline solid var(--colorGreenShadow); } .code__button:hover { diff --git a/integration/wai-aria.spec.js b/integration/wai-aria.spec.js index b7b2141c..0bbf748f 100644 --- a/integration/wai-aria.spec.js +++ b/integration/wai-aria.spec.js @@ -237,42 +237,16 @@ describe('WAI ARIA Spec', () => { }); describe('WAI-ARIA Roles, States, and Properties', () => { - it(`The title of each accordion header is contained in an element with - role button.`, async () => { - // TODO: Use 'title' elements inside the headings. - - const { browser, page, buttonsHandles } = await setup(); - expect(buttonsHandles).toHaveLength(3); - for (const buttonHandle of buttonsHandles) { - expect( - await page.evaluate( - (button) => button.getAttribute('role'), - buttonHandle, - ), - ).toBe('button'); - } - }); - - it(`Each accordion header button is wrapped in an element with role - heading that has a value set for aria-level that is appropriate for - the information architecture of the page.`, async () => { - const { browser, page, buttonsHandles } = await setup(); - expect(buttonsHandles).toHaveLength(3); - for (const buttonHandle of buttonsHandles) { - expect( - await page.evaluate( - (button) => button.parentElement.getAttribute('role'), - buttonHandle, - ), - ).toBe('heading'); - + it(`The title of each accordion header is contained in a heading tag`, async () => { + const { browser, page, headingsHandles } = await setup(); + expect(headingsHandles).toHaveLength(3); + for (const headingsHandle of headingsHandles) { expect( await page.evaluate( - (button) => - button.parentElement.getAttribute('aria-level'), - buttonHandle, + (heading) => heading.tagName, + headingsHandle, ), - ).toBeTruthy(); + ).toContain('H3'); } }); diff --git a/src/components/AccordionContext.tsx b/src/components/AccordionContext.tsx index 1509fed2..a7f6936d 100644 --- a/src/components/AccordionContext.tsx +++ b/src/components/AccordionContext.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import AccordionStore, { InjectedButtonAttributes, - InjectedHeadingAttributes, InjectedPanelAttributes, } from '../helpers/AccordionStore'; import { ID } from './ItemContext'; @@ -28,7 +27,6 @@ export interface AccordionContext { uuid: ID, dangerouslySetExpanded?: boolean, ): InjectedPanelAttributes; - getHeadingAttributes(uuid: ID): InjectedHeadingAttributes; getButtonAttributes( uuid: ID, dangerouslySetExpanded?: boolean, @@ -78,11 +76,6 @@ export class Provider extends React.PureComponent< return this.state.getPanelAttributes(key, dangerouslySetExpanded); }; - getHeadingAttributes = (): InjectedHeadingAttributes => { - // uuid: UUID - return this.state.getHeadingAttributes(); - }; - getButtonAttributes = ( key: ID, dangerouslySetExpanded?: boolean, @@ -102,7 +95,6 @@ export class Provider extends React.PureComponent< isItemDisabled: this.isItemDisabled, isItemExpanded: this.isItemExpanded, getPanelAttributes: this.getPanelAttributes, - getHeadingAttributes: this.getHeadingAttributes, getButtonAttributes: this.getButtonAttributes, }} > diff --git a/src/components/AccordionItemButton.tsx b/src/components/AccordionItemButton.tsx index 253a19e9..e5321e5d 100644 --- a/src/components/AccordionItemButton.tsx +++ b/src/components/AccordionItemButton.tsx @@ -7,12 +7,12 @@ import { focusPreviousSiblingOf, } from '../helpers/focus'; import keycodes from '../helpers/keycodes'; -import { DivAttributes } from '../helpers/types'; import { assertValidHtmlId } from '../helpers/id'; +import { ButtonAttributes } from '../helpers/types'; import { Consumer as ItemConsumer, ItemContext } from './ItemContext'; -type Props = DivAttributes & { +type Props = ButtonAttributes & { toggleExpanded(): void; }; @@ -21,7 +21,9 @@ const AccordionItemButton = ({ className = 'accordion__button', ...rest }: Props) => { - const handleKeyPress = (evt: React.KeyboardEvent): void => { + const handleKeyPress = ( + evt: React.KeyboardEvent, + ): void => { const keyCode = evt.key; if ( @@ -74,11 +76,9 @@ const AccordionItemButton = ({ } return ( -
+ ButtonAttributes, + Exclude >; const AccordionItemButtonWrapper: React.SFC = ( diff --git a/src/components/AccordionItemHeading.spec.tsx b/src/components/AccordionItemHeading.spec.tsx index 6999560e..ccf72f50 100644 --- a/src/components/AccordionItemHeading.spec.tsx +++ b/src/components/AccordionItemHeading.spec.tsx @@ -59,6 +59,39 @@ describe('AccordionItem', () => { }); }); + describe('aria-level prop', () => { + it('is h3 by default', () => { + const { getByTestId } = render( + + + + + + + , + ); + + expect(getByTestId(UUIDS.FOO).tagName).toEqual('H3'); + }); + + it('can be overridden', () => { + const { getByTestId } = render( + + + + + + + , + ); + + expect(getByTestId(UUIDS.FOO).tagName).toEqual('H4'); + }); + }); + describe('children prop', () => { it('is respected', () => { const { getByText } = render( diff --git a/src/components/AccordionItemHeading.tsx b/src/components/AccordionItemHeading.tsx index e71b4876..28dd4577 100644 --- a/src/components/AccordionItemHeading.tsx +++ b/src/components/AccordionItemHeading.tsx @@ -1,17 +1,12 @@ import * as React from 'react'; -import { InjectedHeadingAttributes } from '../helpers/AccordionStore'; import DisplayName from '../helpers/DisplayName'; -import { DivAttributes } from '../helpers/types'; import { assertValidHtmlId } from '../helpers/id'; +import { HeadingAttributes } from '../helpers/types'; -import { Consumer as ItemConsumer, ItemContext } from './ItemContext'; - -type Props = DivAttributes; - -const defaultProps = { - className: 'accordion__heading', - 'aria-level': 3, -}; +interface AccordionItemHeadingProps extends HeadingAttributes { + className?: string; + 'aria-level'?: number; +} export const SPEC_ERROR = `AccordionItemButton may contain only one child element, which must be an instance of AccordionItemButton. @@ -21,12 +16,30 @@ From the WAI-ARIA spec (https://www.w3.org/TR/wai-aria-practices-1.1/#accordion) `; -export class AccordionItemHeading extends React.PureComponent { - static defaultProps: typeof defaultProps = defaultProps; +const Heading = React.forwardRef( + ( + { + 'aria-level': ariaLevel = 3, + className = 'accordion__heading', + ...props + }: AccordionItemHeadingProps, + ref, + ) => { + const headingTag = `h${ariaLevel}`; + return React.createElement(headingTag, { + className, + ...props, + ref, + 'data-accordion-component': 'AccordionItemHeading', + }); + }, +); +Heading.displayName = 'Heading'; - ref: HTMLDivElement | undefined; +export class AccordionItemHeading extends React.PureComponent { + ref: HTMLHeadingElement | undefined; - static VALIDATE(ref: HTMLDivElement | undefined): void | never { + static VALIDATE(ref: HTMLHeadingElement | undefined): void | never { if (ref === undefined) { throw new Error('ref is undefined'); } @@ -43,7 +56,7 @@ export class AccordionItemHeading extends React.PureComponent { } } - setRef = (ref: HTMLDivElement): void => { + setRef = (ref: HTMLHeadingElement): void => { this.ref = ref; }; @@ -56,36 +69,19 @@ export class AccordionItemHeading extends React.PureComponent { } render(): JSX.Element { - return ( -
- ); + return ; } } -type WrapperProps = Pick< - DivAttributes, - Exclude ->; - -const AccordionItemHeadingWrapper: React.SFC = ( - props: WrapperProps, -): JSX.Element => ( - - {(itemContext: ItemContext): JSX.Element => { - const { headingAttributes } = itemContext; - - if (props.id) { - assertValidHtmlId(props.id); - } - - return ; - }} - -); +const AccordionItemHeadingWrapper: React.FC = ( + props: AccordionItemHeadingProps, +): JSX.Element => { + if (props.id) { + assertValidHtmlId(props.id); + } + + return ; +}; AccordionItemHeadingWrapper.displayName = DisplayName.AccordionItemHeading; diff --git a/src/components/ItemContext.tsx b/src/components/ItemContext.tsx index 99310a56..c6e15e9d 100644 --- a/src/components/ItemContext.tsx +++ b/src/components/ItemContext.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { InjectedButtonAttributes, - InjectedHeadingAttributes, InjectedPanelAttributes, } from '../helpers/AccordionStore'; import { @@ -30,7 +29,6 @@ export type ItemContext = { expanded: boolean; disabled: boolean; panelAttributes: InjectedPanelAttributes; - headingAttributes: InjectedHeadingAttributes; buttonAttributes: InjectedButtonAttributes; toggleExpanded(): void; }; @@ -57,7 +55,6 @@ const Provider = ({ uuid, dangerouslySetExpanded, ); - const headingAttributes = accordionContext.getHeadingAttributes(uuid); const buttonAttributes = accordionContext.getButtonAttributes( uuid, dangerouslySetExpanded, @@ -71,7 +68,6 @@ const Provider = ({ disabled, toggleExpanded: toggleExpanded, panelAttributes, - headingAttributes, buttonAttributes, }} > diff --git a/src/css/fancy-example.css b/src/css/fancy-example.css index dd75d55a..c13d3462 100644 --- a/src/css/fancy-example.css +++ b/src/css/fancy-example.css @@ -11,7 +11,10 @@ .accordion__item + .accordion__item { border-top: 1px solid rgba(0, 0, 0, 0.1); } - +.accordion__heading { + margin: 0; + font: inherit; +} .accordion__button { background-color: #f4f4f4; color: #444; @@ -20,6 +23,8 @@ width: 100%; text-align: left; border: none; + font: inherit; + margin: 0; } .accordion__button:hover { diff --git a/src/helpers/AccordionStore.ts b/src/helpers/AccordionStore.ts index 3c85e3da..a52352d0 100644 --- a/src/helpers/AccordionStore.ts +++ b/src/helpers/AccordionStore.ts @@ -8,17 +8,11 @@ export interface InjectedPanelAttributes { hidden: boolean | undefined; } -export interface InjectedHeadingAttributes { - role: string; -} - export interface InjectedButtonAttributes { id: string; 'aria-controls': string; 'aria-expanded': boolean; 'aria-disabled': boolean; - role: string; - tabIndex: number; } export default class AccordionStore { @@ -96,13 +90,6 @@ export default class AccordionStore { }; }; - public readonly getHeadingAttributes = (): // uuid: UUID, - InjectedHeadingAttributes => { - return { - role: 'heading', - }; - }; - public readonly getButtonAttributes = ( uuid: ID, dangerouslySetExpanded?: boolean, @@ -115,8 +102,6 @@ export default class AccordionStore { 'aria-disabled': disabled, 'aria-expanded': expanded, 'aria-controls': this.getPanelId(uuid), - role: 'button', - tabIndex: 0, }; }; diff --git a/src/helpers/types.ts b/src/helpers/types.ts index 7a23b7ce..414e93de 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -1,3 +1,5 @@ import * as React from 'react'; export type DivAttributes = React.HTMLAttributes; +export type ButtonAttributes = React.HTMLAttributes; +export type HeadingAttributes = React.HTMLAttributes;