diff --git a/CHANGELOG.md b/CHANGELOG.md index 67316a2142..c160c56a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Features +- Add keyboard navigation and screen reader support for `Accordion` @silviuavram ([#1322](https://github.com/stardust-ui/react/pull/1322)) +- Add `expanded` prop to `Accordion` @silviuavram ([#1322](https://github.com/stardust-ui/react/pull/1322)) + ## [v0.31.0](https://github.com/stardust-ui/react/tree/v0.31.0) (2019-05-21) [Compare changes](https://github.com/stardust-ui/react/compare/v0.30.0...v0.31.0) diff --git a/packages/react/src/components/Accordion/Accordion.tsx b/packages/react/src/components/Accordion/Accordion.tsx index 28644b61c1..7ea982324a 100644 --- a/packages/react/src/components/Accordion/Accordion.tsx +++ b/packages/react/src/components/Accordion/Accordion.tsx @@ -10,11 +10,12 @@ import { ChildrenComponentProps, commonPropTypes, rtlTextContainer, + applyAccessibilityKeyHandlers, } from '../../lib' -import AccordionTitle from './AccordionTitle' +import { accordionBehavior } from '../../lib/accessibility' +import AccordionTitle, { AccordionTitleProps } from './AccordionTitle' import AccordionContent from './AccordionContent' -import { defaultBehavior } from '../../lib/accessibility' -import { Accessibility } from '../../lib/accessibility/types' +import { Accessibility, AccessibilityActionHandlers } from '../../lib/accessibility/types' import { ComponentEventHandler, @@ -23,6 +24,7 @@ import { ShorthandRenderFunction, withSafeTypeForAs, } from '../../types' +import { ContainerFocusHandler } from '../../lib/accessibility/FocusHandling/FocusContainer' export interface AccordionSlotClassNames { content: string @@ -39,6 +41,9 @@ export interface AccordionProps extends UIComponentProps, ChildrenComponentProps /** Only allow one panel open at a time. */ exclusive?: boolean + /** At least one panel should be expanded at any time. */ + expanded?: boolean + /** * Called when a panel title is clicked. * @@ -76,7 +81,12 @@ export interface AccordionProps extends UIComponentProps, ChildrenComponentProps accessibility?: Accessibility } -class Accordion extends AutoControlledComponent, any> { +export interface AccordionState { + activeIndex: number[] | number + focusedIndex: number +} + +class Accordion extends AutoControlledComponent, AccordionState> { static displayName = 'Accordion' static className = 'ui-accordion' @@ -99,6 +109,7 @@ class Accordion extends AutoControlledComponent, any> PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), ]), exclusive: PropTypes.bool, + expanded: PropTypes.bool, onTitleClick: customPropTypes.every([customPropTypes.disallow(['children']), PropTypes.func]), panels: customPropTypes.every([ customPropTypes.disallow(['children']), @@ -115,7 +126,8 @@ class Accordion extends AutoControlledComponent, any> } public static defaultProps = { - accessibility: defaultBehavior as Accessibility, + accessibility: accordionBehavior, + as: 'dl', } static autoControlledProps = ['activeIndex'] @@ -123,56 +135,151 @@ class Accordion extends AutoControlledComponent, any> static Title = AccordionTitle static Content = AccordionContent - getInitialAutoControlledState({ exclusive }) { - return { activeIndex: exclusive ? -1 : [-1] } + private focusHandler: ContainerFocusHandler = null + private itemRefs = [] + + actionHandlers: AccessibilityActionHandlers = { + moveNext: e => { + e.preventDefault() + this.focusHandler.moveNext() + }, + movePrevious: e => { + e.preventDefault() + this.focusHandler.movePrevious() + }, + moveFirst: e => { + e.preventDefault() + this.focusHandler.moveFirst() + }, + moveLast: e => { + e.preventDefault() + this.focusHandler.moveLast() + }, + } + + constructor(props, context) { + super(props, context) + + this.focusHandler = new ContainerFocusHandler( + this.getNavigationItemsSize, + this.handleNavigationFocus, + true, + ) + } + + private handleNavigationFocus = (index: number) => { + this.setState({ focusedIndex: index }, () => { + const targetComponent = this.itemRefs[index] && this.itemRefs[index].current + targetComponent && targetComponent.focus() + }) } - computeNewIndex = index => { + private getNavigationItemsSize = () => this.props.panels.length + + getInitialAutoControlledState({ expanded, exclusive }: AccordionProps) { + const alwaysActiveIndex = expanded ? 0 : -1 + return { activeIndex: exclusive ? alwaysActiveIndex : [alwaysActiveIndex] } + } + + private computeNewIndex = (index: number): number | number[] => { const { activeIndex } = this.state const { exclusive } = this.props + if (!this.isIndexActionable(index)) { + return activeIndex + } + if (exclusive) return index === activeIndex ? -1 : index // check to see if index is in array, and remove it, if not then add it - return _.includes(activeIndex, index) ? _.without(activeIndex, index) : [...activeIndex, index] + return _.includes(activeIndex as number[], index) + ? _.without(activeIndex as number[], index) + : [...(activeIndex as number[]), index] } - handleTitleOverrides = predefinedProps => ({ - onClick: (e, titleProps) => { + private handleTitleOverrides = (predefinedProps: AccordionTitleProps) => ({ + onClick: (e: React.SyntheticEvent, titleProps: AccordionTitleProps) => { const { index } = titleProps const activeIndex = this.computeNewIndex(index) this.trySetState({ activeIndex }) + this.setState({ focusedIndex: index }) _.invoke(predefinedProps, 'onClick', e, titleProps) _.invoke(this.props, 'onTitleClick', e, titleProps) }, + onFocus: (e: React.SyntheticEvent, titleProps: AccordionTitleProps) => { + _.invoke(predefinedProps, 'onFocus', e, titleProps) + this.setState({ focusedIndex: predefinedProps.index }) + }, }) - isIndexActive = (index): boolean => { + private isIndexActive = (index: number): boolean => { const { exclusive } = this.props const { activeIndex } = this.state - return exclusive ? activeIndex === index : _.includes(activeIndex, index) + return exclusive ? activeIndex === index : _.includes(activeIndex as number[], index) + } + + /** + * Checks if panel at index can be actioned upon. Used in the case of expanded accordion, + * when at least a panel needs to stay active. Will return false if expanded prop is true, + * index is active and either it's an exclusive accordion or if there are no other active + * panels open besides this one. + * + * @param {number} index The index of the panel. + * @returns {boolean} If the panel can be set active/inactive. + */ + private isIndexActionable = (index: number): boolean => { + if (!this.isIndexActive(index)) { + return true + } + + const { activeIndex } = this.state + const { expanded, exclusive } = this.props + + return !expanded || (!exclusive && (activeIndex as number[]).length > 1) } renderPanels = () => { const children: any[] = [] const { panels, renderPanelContent, renderPanelTitle } = this.props + const { focusedIndex } = this.state + + this.itemRefs = [] + this.focusHandler.syncFocusedIndex(focusedIndex) _.each(panels, (panel, index) => { const { content, title } = panel const active = this.isIndexActive(index) + const canBeCollapsed = this.isIndexActionable(index) + const contentRef = React.createRef() + const titleId = title['id'] || _.uniqueId('accordion-title-') + const contentId = content['id'] || _.uniqueId('accordion-content-') + this.itemRefs[index] = contentRef children.push( AccordionTitle.create(title, { - defaultProps: { className: Accordion.slotClassNames.title, active, index }, + defaultProps: { + className: Accordion.slotClassNames.title, + active, + index, + contentRef, + canBeCollapsed, + id: titleId, + accordionContentId: contentId, + }, overrideProps: this.handleTitleOverrides, render: renderPanelTitle, }), ) children.push( AccordionContent.create(content, { - defaultProps: { className: Accordion.slotClassNames.content, active }, + defaultProps: { + className: Accordion.slotClassNames.content, + active, + id: contentId, + accordionTitleId: titleId, + }, render: renderPanelContent, }), ) @@ -189,6 +296,7 @@ class Accordion extends AutoControlledComponent, any> {...accessibility.attributes.root} {...rtlTextContainer.getAttributes({ forElements: [children] })} {...unhandledProps} + {...applyAccessibilityKeyHandlers(accessibility.keyHandlers.root, unhandledProps)} className={classes.root} > {childrenExist(children) ? children : this.renderPanels()} diff --git a/packages/react/src/components/Accordion/AccordionContent.tsx b/packages/react/src/components/Accordion/AccordionContent.tsx index 169491ddaf..ad677ea734 100644 --- a/packages/react/src/components/Accordion/AccordionContent.tsx +++ b/packages/react/src/components/Accordion/AccordionContent.tsx @@ -1,5 +1,6 @@ import * as PropTypes from 'prop-types' import * as React from 'react' +import * as _ from 'lodash' import { childrenExist, @@ -12,11 +13,15 @@ import { rtlTextContainer, } from '../../lib' import { WithAsProp, ComponentEventHandler, withSafeTypeForAs } from '../../types' +import { accordionContentBehavior } from '../../lib/accessibility' export interface AccordionContentProps extends UIComponentProps, ChildrenComponentProps, ContentComponentProps { + /** Id of the title it belongs to. */ + accordionTitleId?: string + /** Whether or not the content is visible. */ active?: boolean @@ -38,16 +43,28 @@ class AccordionContent extends UIComponent, an static propTypes = { ...commonPropTypes.createCommon(), + accordionTitleId: PropTypes.string, active: PropTypes.bool, onClick: PropTypes.func, } - renderComponent({ ElementType, classes, unhandledProps }) { + static defaultProps = { + accessibility: accordionContentBehavior, + as: 'dd', + } + + private handleClick = (e: React.SyntheticEvent) => { + _.invoke(this.props, 'onClick', e, this.props) + } + + renderComponent({ ElementType, classes, unhandledProps, accessibility }) { const { children, content } = this.props return ( @@ -63,6 +80,6 @@ AccordionContent.create = createShorthandFactory({ }) /** - * A standard AccordionContent. + * A standard AccordionContent that is used to display content hosted in an accordion. */ export default withSafeTypeForAs(AccordionContent) diff --git a/packages/react/src/components/Accordion/AccordionTitle.tsx b/packages/react/src/components/Accordion/AccordionTitle.tsx index 0392020750..208fdfdcf8 100644 --- a/packages/react/src/components/Accordion/AccordionTitle.tsx +++ b/packages/react/src/components/Accordion/AccordionTitle.tsx @@ -1,3 +1,4 @@ +import { Ref } from '@stardust-ui/react-component-ref' import * as customPropTypes from '@stardust-ui/react-proptypes' import * as _ from 'lodash' import * as PropTypes from 'prop-types' @@ -12,20 +13,36 @@ import { ChildrenComponentProps, commonPropTypes, rtlTextContainer, + applyAccessibilityKeyHandlers, } from '../../lib' import { WithAsProp, ComponentEventHandler, ShorthandValue, withSafeTypeForAs } from '../../types' import Icon from '../Icon/Icon' import Layout from '../Layout/Layout' +import { accordionTitleBehavior } from '../../lib/accessibility' +import { AccessibilityActionHandlers } from '../../lib/accessibility/types' + +export interface AccordionTitleSlotClassNames { + content: string +} export interface AccordionTitleProps extends UIComponentProps, ContentComponentProps, ChildrenComponentProps { + /** Id of the content it owns. */ + accordionContentId?: string + /** Whether or not the title is in the open state. */ active?: boolean + /** If at least one panel needs to stay active and this title does not correspond to the last active one. */ + canBeCollapsed?: boolean + /** AccordionTitle index inside Accordion. */ - index?: string | number + index?: number + + /** Ref to the clickable element that contains the title. */ + contentRef: React.Ref /** * Called on click. @@ -35,6 +52,13 @@ export interface AccordionTitleProps */ onClick?: ComponentEventHandler + /** + * Called after user's focus. + * @param {SyntheticEvent} event - React's original SyntheticEvent. + * @param {object} data - All props. + */ + onFocus?: ComponentEventHandler + /** Shorthand for the active indicator. */ indicator?: ShorthandValue } @@ -46,40 +70,70 @@ class AccordionTitle extends UIComponent, any> { static className = 'ui-accordion__title' + static slotClassNames: AccordionTitleSlotClassNames + static propTypes = { ...commonPropTypes.createCommon(), + accordionContentId: PropTypes.string, active: PropTypes.bool, - index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + contentRef: customPropTypes.ref, + canBeCollapsed: PropTypes.bool, + index: PropTypes.number, onClick: PropTypes.func, indicator: customPropTypes.itemShorthand, } - handleClick = e => { + static defaultProps = { + accessibility: accordionTitleBehavior, + as: 'dt', + } + + actionHandlers: AccessibilityActionHandlers = { + performClick: e => { + e.preventDefault() + this.handleClick(e as any) + }, + } + + private handleClick = (e: React.SyntheticEvent) => { _.invoke(this.props, 'onClick', e, this.props) } - renderComponent({ ElementType, classes, unhandledProps, styles }) { - const { children, content, indicator, active } = this.props + private handleFocus = (e: React.SyntheticEvent) => { + e.stopPropagation() + _.invoke(this.props, 'onFocus', e, this.props) + } + + renderComponent({ ElementType, classes, unhandledProps, styles, accessibility }) { + const { contentRef, children, content, indicator, active } = this.props const indicatorWithDefaults = indicator === undefined ? {} : indicator const contentElement = ( - + + + ) return ( {childrenExist(children) ? children : contentElement} @@ -89,7 +143,11 @@ class AccordionTitle extends UIComponent, any> { AccordionTitle.create = createShorthandFactory({ Component: AccordionTitle, mappedProp: 'content' }) +AccordionTitle.slotClassNames = { + content: `${AccordionTitle.className}__content`, +} + /** - * A standard AccordionTitle. + * A standard AccordionTitle that is used to expand or collapse content. */ export default withSafeTypeForAs(AccordionTitle) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 666a82bc0f..93939ff355 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -13,6 +13,14 @@ export * from './themes/colorUtils' export { Ref, RefProps } from '@stardust-ui/react-component-ref' export { default as Accordion, AccordionProps } from './components/Accordion/Accordion' +export { + default as AccordionTitle, + AccordionTitleProps, +} from './components/Accordion/AccordionTitle' +export { + default as AccordionContent, + AccordionContentProps, +} from './components/Accordion/AccordionContent' export { default as Alert, AlertProps } from './components/Alert/Alert' diff --git a/packages/react/src/lib/accessibility/Behaviors/Accordion/accordionBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Accordion/accordionBehavior.ts new file mode 100644 index 0000000000..eb58f4754e --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/Accordion/accordionBehavior.ts @@ -0,0 +1,36 @@ +import { Accessibility } from '../../types' +import * as keyboardKey from 'keyboard-key' + +/** + * @specification + * Adds attribute 'role=presentation' to 'root' component's part. + * Triggers 'moveNext' action with 'ArrowDown' on 'root'. + * Triggers 'movePrevious' action with 'ArrowUp' on 'root'. + * Triggers 'moveFirst' action with 'Home' on 'root'. + * Triggers 'moveLast' action with 'End' on 'root'. + */ +const accordionBehavior: Accessibility = (props: any) => ({ + attributes: { + root: { + role: 'presentation', + }, + }, + keyActions: { + root: { + moveNext: { + keyCombinations: [{ keyCode: keyboardKey.ArrowDown }], + }, + movePrevious: { + keyCombinations: [{ keyCode: keyboardKey.ArrowUp }], + }, + moveFirst: { + keyCombinations: [{ keyCode: keyboardKey.Home }], + }, + moveLast: { + keyCombinations: [{ keyCode: keyboardKey.End }], + }, + }, + }, +}) + +export default accordionBehavior diff --git a/packages/react/src/lib/accessibility/Behaviors/Accordion/accordionContentBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Accordion/accordionContentBehavior.ts new file mode 100644 index 0000000000..444120d274 --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/Accordion/accordionContentBehavior.ts @@ -0,0 +1,20 @@ +import { Accessibility } from '../../types' + +/** + * @description + * Optionally, an accordion content can have the 'role=region'. It is not applied by default. + * + * @specification + * Adds attribute 'aria-labelledby' based on the property 'accordionTitleId' to 'root' component's part. + */ +const accordionContentBehavior: Accessibility = (props: any) => { + return { + attributes: { + root: { + 'aria-labelledby': props.accordionTitleId, + }, + }, + } +} + +export default accordionContentBehavior diff --git a/packages/react/src/lib/accessibility/Behaviors/Accordion/accordionTitleBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Accordion/accordionTitleBehavior.ts new file mode 100644 index 0000000000..7e475f024b --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/Accordion/accordionTitleBehavior.ts @@ -0,0 +1,43 @@ +import { Accessibility } from '../../types' +import * as keyboardKey from 'keyboard-key' + +/** + * @description + * Adds accessibility attributed to implement the Accordion design pattern. + * Adds 'aria-disabled' to the 'content' component's part with a value based on active and canBeCollapsed props. + * Adds role='heading' and aria-level='3' if the element type is not a header. + * + * @specification + * Adds attribute 'role=button' to 'content' component's part. + * Adds attribute 'tabIndex=0' to 'content' component's part. + * Adds attribute 'aria-expanded=true' based on the property 'active' to 'content' component's part. + * Adds attribute 'aria-controls=content-id' based on the property 'accordionContentId' to 'content' component's part. + * Triggers 'performClick' action with 'Enter' or 'Spacebar' on 'content'. + */ +const accordionTitleBehavior: Accessibility = (props: any) => { + const isHeading = /(h\d{1})$/.test(props.as) + return { + attributes: { + root: { + role: isHeading ? undefined : 'heading', + 'aria-level': isHeading ? undefined : 3, + }, + content: { + 'aria-expanded': !!props.active, + 'aria-disabled': !!(props.active && !props.canBeCollapsed), + 'aria-controls': props.accordionContentId, + role: 'button', + tabIndex: 0, + }, + }, + keyActions: { + content: { + performClick: { + keyCombinations: [{ keyCode: keyboardKey.Enter }, { keyCode: keyboardKey.Spacebar }], + }, + }, + }, + } +} + +export default accordionTitleBehavior diff --git a/packages/react/src/lib/accessibility/FocusHandling/FocusContainer.ts b/packages/react/src/lib/accessibility/FocusHandling/FocusContainer.ts index 48880945c6..295d4b296f 100644 --- a/packages/react/src/lib/accessibility/FocusHandling/FocusContainer.ts +++ b/packages/react/src/lib/accessibility/FocusHandling/FocusContainer.ts @@ -1,18 +1,22 @@ export class ContainerFocusHandler { private focusedIndex = 0 - constructor(private getItemsCount: () => number, private readonly setFocusAt: (number) => void) {} + constructor( + private getItemsCount: () => number, + private readonly setFocusAt: (number) => void, + private circular = false, + ) {} private noItems = (): boolean => this.getItemsCount() === 0 private constrainFocusedIndex(): void { + const itemsCount = this.getItemsCount() if (this.focusedIndex < 0) { - this.focusedIndex = 0 + this.focusedIndex = this.circular ? itemsCount - 1 : 0 } - const itemsCount = this.getItemsCount() if (this.focusedIndex >= itemsCount) { - this.focusedIndex = itemsCount - 1 + this.focusedIndex = this.circular ? 0 : itemsCount - 1 } } diff --git a/packages/react/src/lib/accessibility/index.ts b/packages/react/src/lib/accessibility/index.ts index a0f09930da..19ac60ba7b 100644 --- a/packages/react/src/lib/accessibility/index.ts +++ b/packages/react/src/lib/accessibility/index.ts @@ -35,3 +35,6 @@ export { default as treeTitleBehavior } from './Behaviors/Tree/treeTitleBehavior export { default as dialogBehavior } from './Behaviors/Dialog/dialogBehavior' export { default as statusBehavior } from './Behaviors/Status/statusBehavior' export { default as embedBehavior } from './Behaviors/Embed/embedBehavior' +export { default as accordionBehavior } from './Behaviors/Accordion/accordionBehavior' +export { default as accordionTitleBehavior } from './Behaviors/Accordion/accordionTitleBehavior' +export { default as accordionContentBehavior } from './Behaviors/Accordion/accordionContentBehavior' diff --git a/packages/react/src/themes/teams/components/Accordion/accordionContentStyles.ts b/packages/react/src/themes/teams/components/Accordion/accordionContentStyles.ts index 679d66c498..3803c7a5b9 100644 --- a/packages/react/src/themes/teams/components/Accordion/accordionContentStyles.ts +++ b/packages/react/src/themes/teams/components/Accordion/accordionContentStyles.ts @@ -5,6 +5,7 @@ const accordionContentStyles = { display: 'none', verticalAlign: 'middle', ...(props.active && { display: 'block' }), + marginInlineStart: 0, }), } diff --git a/packages/react/src/themes/teams/components/Accordion/accordionStyles.ts b/packages/react/src/themes/teams/components/Accordion/accordionStyles.ts index 93fd9c2670..31d8977a57 100644 --- a/packages/react/src/themes/teams/components/Accordion/accordionStyles.ts +++ b/packages/react/src/themes/teams/components/Accordion/accordionStyles.ts @@ -5,6 +5,8 @@ const accordionStyles = { verticalAlign: 'middle', display: 'flex', flexDirection: 'column', + marginBlockEnd: 0, + marginBlockStart: 0, }), } diff --git a/packages/react/test/specs/behaviors/accordionTitleBehavior-test.tsx b/packages/react/test/specs/behaviors/accordionTitleBehavior-test.tsx new file mode 100644 index 0000000000..f8cf444f4a --- /dev/null +++ b/packages/react/test/specs/behaviors/accordionTitleBehavior-test.tsx @@ -0,0 +1,32 @@ +import { accordionTitleBehavior } from 'src/lib/accessibility' + +describe('AccordionTitleBehavior.ts', () => { + test('adds role and aria-level attribute if as prop is not a heading', () => { + for (let index = 1; index <= 6; index++) { + const expectedResult = accordionTitleBehavior({ as: `h${index}` }) + expect(expectedResult.attributes.root.role).toBeUndefined() + expect(expectedResult.attributes.root['aria-level']).toBeUndefined() + } + }) + + test('adds role and aria-level attribute if as prop is not a heading', () => { + const expectedResult = accordionTitleBehavior({ as: 'div' }) + expect(expectedResult.attributes.root.role).toEqual('heading') + expect(expectedResult.attributes.root['aria-level']).toEqual(3) + }) + + test('adds aria-disabled="true" attribute if active="true" and canBeCollapsed="false"', () => { + const expectedResult = accordionTitleBehavior({ active: true, canBeCollapsed: false }) + expect(expectedResult.attributes.content['aria-disabled']).toEqual(true) + }) + + test('adds aria-disabled="false" attribute if active="true" and canBeCollapsed="true"', () => { + const expectedResult = accordionTitleBehavior({ active: true, canBeCollapsed: true }) + expect(expectedResult.attributes.content['aria-disabled']).toEqual(false) + }) + + test('adds aria-disabled="false" attribute if active="false"', () => { + const expectedResult = accordionTitleBehavior({ active: false }) + expect(expectedResult.attributes.content['aria-disabled']).toEqual(false) + }) +}) diff --git a/packages/react/test/specs/behaviors/behavior-test.tsx b/packages/react/test/specs/behaviors/behavior-test.tsx index b558eabf86..0376b4af52 100644 --- a/packages/react/test/specs/behaviors/behavior-test.tsx +++ b/packages/react/test/specs/behaviors/behavior-test.tsx @@ -36,6 +36,9 @@ import { gridBehavior, statusBehavior, alertWarningBehavior, + accordionBehavior, + accordionTitleBehavior, + accordionContentBehavior, } from 'src/lib/accessibility' import { TestHelper } from './testHelper' import definitions from './testDefinitions' @@ -76,5 +79,8 @@ testHelper.addBehavior('gridBehavior', gridBehavior) testHelper.addBehavior('dialogBehavior', dialogBehavior) testHelper.addBehavior('statusBehavior', statusBehavior) testHelper.addBehavior('alertWarningBehavior', alertWarningBehavior) +testHelper.addBehavior('accordionBehavior', accordionBehavior) +testHelper.addBehavior('accordionTitleBehavior', accordionTitleBehavior) +testHelper.addBehavior('accordionContentBehavior', accordionContentBehavior) testHelper.run(behaviorMenuItems) diff --git a/packages/react/test/specs/behaviors/testDefinitions.ts b/packages/react/test/specs/behaviors/testDefinitions.ts index 6a10c5d7d7..52c270d2a7 100644 --- a/packages/react/test/specs/behaviors/testDefinitions.ts +++ b/packages/react/test/specs/behaviors/testDefinitions.ts @@ -110,13 +110,16 @@ definitions.push({ }, }) -// Example: Adds attribute 'aria-label' based on the property 'aria-label' to 'anchor' component's part. +// Example: Adds attribute 'aria-expanded=true' based on the property 'active' to 'button' component's part. +// Adds attribute 'aria-label' based on the property 'aria-label' to 'anchor' component's part. definitions.push({ - regexp: /Adds attribute '([\w-]+)' based on the property '([\w-]+)' to '([\w-]+)' component's part\./g, + regexp: /Adds attribute '([\w-]+)=*([\w-]*)' based on the property '([\w-]+)' to '([\w-]+)' component's part\./g, testMethod: (parameters: TestMethod) => { - const [attributeToBeAdded, propertyDependingOn, elementWhereToBeAdded] = [...parameters.props] + const [attributeToBeAdded, attibuteValue, propertyDependingOn, elementWhereToBeAdded] = [ + ...parameters.props, + ] const property = {} - const propertyDependingOnValue = 'value of property' + const propertyDependingOnValue = attibuteValue || 'value of property' property[propertyDependingOn] = propertyDependingOnValue const expectedResult = parameters.behavior(property).attributes[elementWhereToBeAdded][ attributeToBeAdded @@ -338,16 +341,16 @@ definitions.push({ // Example: Adds role='button' if element type is other than 'button'. definitions.push({ - regexp: /Adds role='(\w+)' if element type is other than '\w+'\./g, + regexp: /Adds role='(\w+)' if element type is other than '(\w+)'\./g, testMethod: (parameters: TestMethod) => { - const [roleToBeAdded] = [...parameters.props] + const [roleToBeAdded, as] = [...parameters.props] const property = {} const expectedResult = parameters.behavior(property).attributes.root.role expect(testHelper.convertToMatchingTypeIfApplicable(expectedResult)).toBe( testHelper.convertToMatchingTypeIfApplicable(roleToBeAdded), ) - const propertyAsButton = { as: 'button' } + const propertyAsButton = { as } const expectedResultAsButton = parameters.behavior(propertyAsButton).attributes.root.role expect(testHelper.convertToMatchingTypeIfApplicable(expectedResultAsButton)).toBe( testHelper.convertToMatchingTypeIfApplicable(undefined), @@ -355,6 +358,31 @@ definitions.push({ }, }) +// Example: Adds attribute 'role=button' to 'button' component's part if element type is other than 'button'. +definitions.push({ + regexp: /Adds attribute '([\w-]+)=(\w+)' to '(\w+)' component's part if element type is other than '(\w+)'\./g, + testMethod: (parameters: TestMethod) => { + const [attributeToBeAdded, attributeExpectedValue, elementWhereToBeAdded, as] = [ + ...parameters.props, + ] + const property = {} + const expectedResult = parameters.behavior(property).attributes[elementWhereToBeAdded][ + attributeToBeAdded + ] + expect(testHelper.convertToMatchingTypeIfApplicable(expectedResult)).toBe( + testHelper.convertToMatchingTypeIfApplicable(attributeExpectedValue), + ) + + const propertyAsButton = { as } + const expectedResultAsButton = parameters.behavior(propertyAsButton).attributes[ + elementWhereToBeAdded + ][attributeToBeAdded] + expect(testHelper.convertToMatchingTypeIfApplicable(expectedResultAsButton)).toBe( + testHelper.convertToMatchingTypeIfApplicable(undefined), + ) + }, +}) + // Embeds FocusZone into component allowing arrow key navigation through the children of the component. definitions.push({ regexp: /Embeds FocusZone into component allowing arrow key navigation through the children of the component\./g, diff --git a/packages/react/test/specs/components/Accordion/Accordion-test.ts b/packages/react/test/specs/components/Accordion/Accordion-test.ts deleted file mode 100644 index bc1deac06b..0000000000 --- a/packages/react/test/specs/components/Accordion/Accordion-test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { isConformant } from 'test/specs/commonTests' - -import Accordion from 'src/components/Accordion/Accordion' - -describe('Accordion', () => { - isConformant(Accordion) -}) diff --git a/packages/react/test/specs/components/Accordion/Accordion-test.tsx b/packages/react/test/specs/components/Accordion/Accordion-test.tsx new file mode 100644 index 0000000000..1c376eeb2d --- /dev/null +++ b/packages/react/test/specs/components/Accordion/Accordion-test.tsx @@ -0,0 +1,211 @@ +import * as React from 'react' +import * as keyboardKey from 'keyboard-key' + +import Accordion from 'src/components/Accordion/Accordion' +import { isConformant, handlesAccessibility } from 'test/specs/commonTests' +import { mountWithProvider, mountWithProviderAndGetComponent } from 'test/utils' +import AccordionTitle from 'src/components/Accordion/AccordionTitle' +import { ReactWrapper, CommonWrapper } from 'enzyme' + +const panels = [ + { + key: 'one', + title: 'One', + content: '2 3 4', + }, + { + key: 'two', + title: 'Five', + content: '6 7 8 9', + }, + { + key: 'three', + title: "What's next?", + content: '10', + }, +] + +const getTitleButtonAtIndex = (wrapper: ReactWrapper, index: number): CommonWrapper => { + return wrapper + .find(`.${AccordionTitle.slotClassNames.content}`) + .filterWhere(n => typeof n.type() === 'string') + .at(index) +} + +describe('Accordion', () => { + isConformant(Accordion) + + describe('activeIndex', () => { + it('is -1 by default in an exclusive accordion', () => { + const accordion = mountWithProviderAndGetComponent( + Accordion, + , + ) + expect(accordion.state('activeIndex')).toBe(-1) + }) + + it('is [-1] by default in an non-exclusive accordion', () => { + const accordion = mountWithProviderAndGetComponent(Accordion, ) + expect(accordion.state('activeIndex')).toEqual(expect.arrayContaining([-1])) + }) + + it('is 0 by default in an exclusive expanded accordion', () => { + const accordion = mountWithProviderAndGetComponent( + Accordion, + , + ) + expect(accordion.state('activeIndex')).toBe(0) + }) + + it('is [0] by default in an non-exclusive expanded accordion', () => { + const accordion = mountWithProviderAndGetComponent( + Accordion, + , + ) + expect(accordion.state('activeIndex')).toEqual(expect.arrayContaining([0])) + }) + + it('is the value of prop defaultActiveIndex is passed', () => { + const defaultActiveIndex = [1, 2] + const accordion = mountWithProviderAndGetComponent( + Accordion, + , + ) + expect(accordion.state('activeIndex')).toEqual(expect.arrayContaining(defaultActiveIndex)) + }) + + it('contains the indexes clicked by the user if the panels were closed', () => { + const wrapper = mountWithProvider() + const accordion = wrapper.find(Accordion) + getTitleButtonAtIndex(wrapper, 0).simulate('click') + getTitleButtonAtIndex(wrapper, 2).simulate('click') + + expect(accordion.state('activeIndex')).toEqual(expect.arrayContaining([0, 2])) + }) + + it('contains the only one index clicked by the user if exclusive prop is passed', () => { + const wrapper = mountWithProvider() + const accordion = wrapper.find(Accordion) + getTitleButtonAtIndex(wrapper, 0).simulate('click') + expect(accordion.state('activeIndex')).toEqual(0) + + getTitleButtonAtIndex(wrapper, 2).simulate('click') + expect(accordion.state('activeIndex')).toEqual(2) + }) + + it('has indexes removed when their panels are closed by the user', () => { + const wrapper = mountWithProvider( + , + ) + const accordion = wrapper.find(Accordion) + getTitleButtonAtIndex(wrapper, 0).simulate('click') + getTitleButtonAtIndex(wrapper, 2).simulate('click') + + expect(accordion.state('activeIndex')).toEqual(expect.arrayContaining([1])) + }) + + it('keeps the at least one panel open if expanded prop is passed', () => { + const wrapper = mountWithProvider( + , + ) + const accordion = wrapper.find(Accordion) + getTitleButtonAtIndex(wrapper, 0).simulate('click') + + expect(accordion.state('activeIndex')).toEqual(expect.arrayContaining([0])) + }) + }) + + describe('focusedIndex', () => { + it('is set at title click', () => { + const wrapper = mountWithProvider() + const accordion = wrapper.find(Accordion) + getTitleButtonAtIndex(wrapper, 1).simulate('click') + expect(accordion.state('focusedIndex')).toEqual(1) + }) + + it('is changed by arrow key navigation', () => { + const wrapper = mountWithProvider() + const accordion = wrapper.find(Accordion) + getTitleButtonAtIndex(wrapper, 1).simulate('click') + getTitleButtonAtIndex(wrapper, 1).simulate('keydown', { + keyCode: keyboardKey.ArrowUp, + key: 'ArrowUp', + }) + expect(accordion.state('focusedIndex')).toEqual(0) + + getTitleButtonAtIndex(wrapper, 0).simulate('keydown', { + keyCode: keyboardKey.ArrowDown, + key: 'ArrowDown', + }) + expect(accordion.state('focusedIndex')).toEqual(1) + }) + + it('is changed by arrow key navigation in a circular way', () => { + const wrapper = mountWithProvider() + const accordion = wrapper.find(Accordion) + getTitleButtonAtIndex(wrapper, 0).simulate('click') + getTitleButtonAtIndex(wrapper, 0).simulate('keydown', { + keyCode: keyboardKey.ArrowUp, + key: 'ArrowUp', + }) + expect(accordion.state('focusedIndex')).toEqual(panels.length - 1) + + getTitleButtonAtIndex(wrapper, panels.length - 1).simulate('keydown', { + keyCode: keyboardKey.ArrowDown, + key: 'ArrowDown', + }) + expect(accordion.state('focusedIndex')).toEqual(0) + }) + + it('is changed to `0` at Home keydown', () => { + const wrapper = mountWithProvider() + const accordion = wrapper.find(Accordion) + getTitleButtonAtIndex(wrapper, 2).simulate('click') + getTitleButtonAtIndex(wrapper, 2).simulate('keydown', { + keyCode: keyboardKey.Home, + key: 'Home', + }) + expect(accordion.state('focusedIndex')).toEqual(0) + }) + + it('is changed to last index at End keydown', () => { + const wrapper = mountWithProvider() + const accordion = wrapper.find(Accordion) + getTitleButtonAtIndex(wrapper, 0).simulate('click') + getTitleButtonAtIndex(wrapper, 0).simulate('keydown', { + keyCode: keyboardKey.End, + key: 'End', + }) + expect(accordion.state('focusedIndex')).toEqual(panels.length - 1) + }) + + it('focuses the button element when is changed via focus handler', () => { + const wrapper = mountWithProvider() + const title = getTitleButtonAtIndex(wrapper, 1) + title.simulate('click') + title.simulate('keydown', { keyCode: keyboardKey.ArrowUp, key: 'ArrowUp' }) + expect(document.activeElement).toEqual(getTitleButtonAtIndex(wrapper, 0).getDOMNode()) + }) + }) + + describe('panels', () => { + it('when clicked call onClick and onTitleClick if provided by the user', () => { + const onTitleClick = jest.fn() + const panels = [ + { + key: 'one', + title: 'One', + content: '2 3 4', + }, + ] + const wrapper = mountWithProvider() + getTitleButtonAtIndex(wrapper, 0).simulate('click') + + expect(onTitleClick).toBeCalledTimes(1) + }) + }) + + describe('accessibility', () => { + handlesAccessibility(Accordion, { defaultRootRole: 'presentation' }) + }) +}) diff --git a/packages/react/test/specs/components/Accordion/AccordionContent-test.tsx b/packages/react/test/specs/components/Accordion/AccordionContent-test.tsx new file mode 100644 index 0000000000..210694ff32 --- /dev/null +++ b/packages/react/test/specs/components/Accordion/AccordionContent-test.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' + +import AccordionContent from 'src/components/Accordion/AccordionContent' +import { isConformant, handlesAccessibility, getRenderedAttribute } from 'test/specs/commonTests' +import { mountWithProviderAndGetComponent } from 'test/utils' + +describe('AccordionContent', () => { + isConformant(AccordionContent) + + describe('accessiblity', () => { + handlesAccessibility(AccordionContent) + + describe('aria-labelledby', () => { + test('takes the value of the titleId prop', () => { + const renderedComponent = mountWithProviderAndGetComponent( + AccordionContent, + , + ) + expect(getRenderedAttribute(renderedComponent, 'aria-labelledby', '')).toBe('nice-titleId') + }) + }) + }) +}) diff --git a/packages/react/test/specs/components/Accordion/AccordionTitle-test.tsx b/packages/react/test/specs/components/Accordion/AccordionTitle-test.tsx new file mode 100644 index 0000000000..3df571c55f --- /dev/null +++ b/packages/react/test/specs/components/Accordion/AccordionTitle-test.tsx @@ -0,0 +1,35 @@ +import * as _ from 'lodash' + +import AccordionTitle from 'src/components/Accordion/AccordionTitle' +import { isConformant, handlesAccessibility } from 'test/specs/commonTests' + +describe('AccordionTitle', () => { + isConformant(AccordionTitle, { + eventTargets: { + onClick: `.${AccordionTitle.slotClassNames.content}`, + }, + requiredProps: { + contentRef: _.noop, + }, + }) + + describe('accessiblity', () => { + describe('header', () => { + handlesAccessibility(AccordionTitle, { + requiredProps: { + as: 'h3', + contentRef: _.noop, + }, + defaultRootRole: undefined, + }) + }) + describe('div header', () => { + handlesAccessibility(AccordionTitle, { + requiredProps: { + contentRef: _.noop, + }, + defaultRootRole: 'heading', + }) + }) + }) +}) diff --git a/packages/react/test/specs/components/Dropdown/Dropdown-test.tsx b/packages/react/test/specs/components/Dropdown/Dropdown-test.tsx index 2708574382..1c060218d0 100644 --- a/packages/react/test/specs/components/Dropdown/Dropdown-test.tsx +++ b/packages/react/test/specs/components/Dropdown/Dropdown-test.tsx @@ -7,33 +7,36 @@ import DropdownSearchInput from 'src/components/Dropdown/DropdownSearchInput' import DropdownSelectedItem from 'src/components/Dropdown/DropdownSelectedItem' import { isConformant } from 'test/specs/commonTests' import { mountWithProvider } from 'test/utils' -import { ReactWrapper } from 'enzyme' +import { ReactWrapper, CommonWrapper } from 'enzyme' jest.dontMock('keyboard-key') jest.useFakeTimers() -const findIntrinsicElement = (wrapper: ReactWrapper, selector: string) => +const findIntrinsicElement = (wrapper: ReactWrapper, selector: string): CommonWrapper => wrapper.find(selector).filterWhere(n => typeof n.type() === 'string') -const getTriggerButtonWrapper = (wrapper: ReactWrapper) => +const getTriggerButtonWrapper = (wrapper: ReactWrapper): CommonWrapper => findIntrinsicElement(wrapper, `.${Dropdown.slotClassNames.triggerButton}`) -const getToggleIndicatorWrapper = (wrapper: ReactWrapper) => +const getToggleIndicatorWrapper = (wrapper: ReactWrapper): CommonWrapper => findIntrinsicElement(wrapper, `.${Dropdown.slotClassNames.toggleIndicator}`) -const getSearchInputWrapper = (wrapper: ReactWrapper) => +const getSearchInputWrapper = (wrapper: ReactWrapper): CommonWrapper => findIntrinsicElement(wrapper, `.${DropdownSearchInput.slotClassNames.input}`) -const getItemsListWrapper = (wrapper: ReactWrapper) => +const getItemsListWrapper = (wrapper: ReactWrapper): CommonWrapper => findIntrinsicElement(wrapper, `.${Dropdown.slotClassNames.itemsList}`) -const getItemAtIndexWrapper = (wrapper: ReactWrapper, index: number = 0) => +const getItemAtIndexWrapper = (wrapper: ReactWrapper, index: number = 0): CommonWrapper => findIntrinsicElement(wrapper, `.${Dropdown.slotClassNames.item}`).at(index) -const getSelectedItemAtIndexWrapper = (wrapper: ReactWrapper, index: number = 0) => +const getSelectedItemAtIndexWrapper = (wrapper: ReactWrapper, index: number = 0): CommonWrapper => findIntrinsicElement(wrapper, `.${Dropdown.slotClassNames.selectedItem}`).at(index) -const getSelectedItemHeaderAtIndexWrapper = (wrapper: ReactWrapper, index: number = 0) => +const getSelectedItemHeaderAtIndexWrapper = ( + wrapper: ReactWrapper, + index: number = 0, +): CommonWrapper => findIntrinsicElement(wrapper, `.${DropdownSelectedItem.slotClassNames.header}`).at(index) describe('Dropdown', () => {