diff --git a/CHANGELOG.md b/CHANGELOG.md index 8509d37a19..d00cb70169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - `Ref` component extracted to a `@stardust-ui/react-component-ref` @layershifter ([#1281](https://github.com/stardust-ui/react/pull/1281)) - added `isRefObject()`, `toRefObject()` utils for React refs @layershifter ([#1281](https://github.com/stardust-ui/react/pull/1281)) - Add new callings icons in Teams theme @codepretty ([#1264](https://github.com/stardust-ui/react/pull/1264)) +- Add default aria-labelledby and aria-describedby to Dialog @silviuavram ([#1298](https://github.com/stardust-ui/react/pull/1298)) - Add `mountNode` and `mountDocument` props to allow proper multi-window rendering ([#1288](https://github.com/stardust-ui/react/pull/1288)) - Added default and brand color schemes in Teams' theme @mnajdova ([#1069](https://github.com/stardust-ui/react/pull/1069)) - Export `files-upload` SVG icon for `Teams` theme @manindr ([#1293](https://github.com/stardust-ui/react/pull/1293)) diff --git a/packages/react/src/components/Dialog/Dialog.tsx b/packages/react/src/components/Dialog/Dialog.tsx index 02250807db..a2b0cd689d 100644 --- a/packages/react/src/components/Dialog/Dialog.tsx +++ b/packages/react/src/components/Dialog/Dialog.tsx @@ -23,6 +23,11 @@ import Header from '../Header/Header' import Portal from '../Portal/Portal' import Flex from '../Flex/Flex' +export interface DialogSlotClassNames { + header: string + content: string +} + export interface DialogProps extends UIComponentProps, ContentComponentProps, @@ -93,6 +98,8 @@ class Dialog extends AutoControlledComponent, DialogStat static displayName = 'Dialog' static className = 'ui-dialog' + static slotClassNames: DialogSlotClassNames + static propTypes = { ...commonPropTypes.createCommon({ children: false, @@ -202,12 +209,16 @@ class Dialog extends AutoControlledComponent, DialogStat {Header.create(header, { defaultProps: { as: 'h2', + className: Dialog.slotClassNames.header, styles: styles.header, + ...accessibility.attributes.header, }, })} {Box.create(content, { defaultProps: { styles: styles.content, + className: Dialog.slotClassNames.content, + ...accessibility.attributes.content, }, })} @@ -257,4 +268,9 @@ class Dialog extends AutoControlledComponent, DialogStat } } +Dialog.slotClassNames = { + header: `${Dialog.className}__header`, + content: `${Dialog.className}__content`, +} + export default Dialog diff --git a/packages/react/src/lib/accessibility/Behaviors/Dialog/dialogBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Dialog/dialogBehavior.ts index d82bf11a4e..d680f27f22 100644 --- a/packages/react/src/lib/accessibility/Behaviors/Dialog/dialogBehavior.ts +++ b/packages/react/src/lib/accessibility/Behaviors/Dialog/dialogBehavior.ts @@ -1,5 +1,6 @@ import { Accessibility } from '../../types' import popupFocusTrapBehavior from '../Popup/popupFocusTrapBehavior' +import * as _ from 'lodash' /** * @description @@ -11,16 +12,59 @@ import popupFocusTrapBehavior from '../Popup/popupFocusTrapBehavior' * Adds attribute 'aria-disabled=true' to 'trigger' component's part if 'disabled' property is true. Does not set the attribute otherwise. * Adds attribute 'aria-modal=true' to 'popup' component's part. * Adds attribute 'role=dialog' to 'popup' component's part. + * Adds attribute 'aria-labelledby' based on the property 'aria-labelledby' to 'popup' component's part. + * Adds attribute 'aria-describedby' based on the property 'aria-describedby' to 'popup' component's part. + * Adds attribute 'role=dialog' to 'popup' component's part. + * Generates unique ID and adds it as attribute 'id' to the 'header' component's part if it has not been provided by the user. + * Generates unique ID and adds it as attribute 'id' to the 'content' component's part if it has not been provided by the user. * Traps focus inside component. */ const dialogBehavior: Accessibility = (props: any) => { const behaviorData = popupFocusTrapBehavior(props) + const defaultAriaLabelledBy = getDefaultAriaLabelledBy(props) + const defaultAriaDescribedBy = getDefaultAriaDescribedBy(props) behaviorData.attributes.popup = { ...behaviorData.attributes.popup, role: 'dialog', + 'aria-labelledby': defaultAriaLabelledBy || props['aria-labelledby'], + 'aria-describedby': defaultAriaDescribedBy || props['aria-describedby'], + } + behaviorData.attributes.header = { + id: defaultAriaLabelledBy, + } + behaviorData.attributes.content = { + id: defaultAriaDescribedBy, } return behaviorData } +/** + * Returns the element id of the header or generates a default one. It is + * used when user does not provide aria-label or aria-labelledby as + * props. It is also used as default value for header id if there is not + * any value provided by user as prop. + */ +const getDefaultAriaLabelledBy = (props: any) => { + const { header } = props + if (props['aria-label'] || props['aria-labelledby'] || !header) { + return undefined + } + return header['id'] || _.uniqueId('dialog-header-') +} + +/** + * Returns the element id of the content or generates a default one. It is + * used when user does not provide aria-describedby as props. It is also + * used as default value for content id if there is not any value provided by + * user as prop. + */ +const getDefaultAriaDescribedBy = (props: any) => { + const { content } = props + if (props['aria-describedby'] || !content) { + return undefined + } + return content['id'] || _.uniqueId('dialog-content-') +} + export default dialogBehavior diff --git a/packages/react/src/lib/accessibility/types.ts b/packages/react/src/lib/accessibility/types.ts index 5dc2941759..da6d05a798 100644 --- a/packages/react/src/lib/accessibility/types.ts +++ b/packages/react/src/lib/accessibility/types.ts @@ -134,6 +134,7 @@ export interface AriaRelationshipAttributes { export interface AccessibilityAttributes extends AriaWidgetAttributes, AriaRelationshipAttributes { role?: AriaRole tabIndex?: number + id?: string [IS_FOCUSABLE_ATTRIBUTE]?: boolean } diff --git a/packages/react/test/specs/behaviors/testDefinitions.ts b/packages/react/test/specs/behaviors/testDefinitions.ts index a978344210..6a10c5d7d7 100644 --- a/packages/react/test/specs/behaviors/testDefinitions.ts +++ b/packages/react/test/specs/behaviors/testDefinitions.ts @@ -127,6 +127,23 @@ definitions.push({ }, }) +// Example: Generates unique ID and adds it as attribute 'id' to the 'header' component's part if it has not been provided by the user. +definitions.push({ + regexp: /Generates unique ID and adds it as attribute '([\w-]+)' to the '([\w-]+)' component's part if it has not been provided by the user\./g, + testMethod: (parameters: TestMethod) => { + const [attributeToBeAdded, elementWhereToBeAdded] = [...parameters.props] + const property = {} + const propertyDependingOnValue = 'value of property' + property[elementWhereToBeAdded] = { id: propertyDependingOnValue } + const expectedResult = parameters.behavior(property).attributes[elementWhereToBeAdded][ + attributeToBeAdded + ] + expect(expectedResult).toEqual( + testHelper.convertToMatchingTypeIfApplicable(propertyDependingOnValue), + ) + }, +}) + // Adds attribute 'aria-selected=true' to 'anchor' component's part based on the property 'active'. This can be overriden by directly providing 'aria-selected' property to the component. definitions.push({ regexp: /Adds attribute '([\w-]+)=([\w\d]+)' to '([\w-]+)' component's part based on the property '\w+'\. This can be overriden by providing '([\w-]+)' property directly to the component\./g, diff --git a/packages/react/test/specs/components/Dialog/Dialog-test.tsx b/packages/react/test/specs/components/Dialog/Dialog-test.tsx new file mode 100644 index 0000000000..40e0ec5b85 --- /dev/null +++ b/packages/react/test/specs/components/Dialog/Dialog-test.tsx @@ -0,0 +1,117 @@ +import * as React from 'react' + +import Dialog from 'src/components/Dialog/Dialog' +import Button from 'src/components/Button/Button' +import { getRenderedAttribute } from 'test/specs/commonTests' +import { mountWithProvider } from 'test/utils' + +describe('Dialog', () => { + describe('accessibility', () => { + it('applies aria-label if provided as prop', () => { + const ariaLabel = 'super label' + const wrapper = mountWithProvider( + } aria-label={ariaLabel} />, + ) + wrapper.find('.ui-button').simulate('click') + const dialog = wrapper.find(`.${Dialog.className}`) + + expect(getRenderedAttribute(dialog, 'aria-label', '')).toBe(ariaLabel) + }) + + it('applies aria-labelledby if provided as prop', () => { + const ariaLabelledBy = 'element-id' + const wrapper = mountWithProvider( + } aria-labelledby={ariaLabelledBy} />, + ) + wrapper.find('.ui-button').simulate('click') + const dialog = wrapper.find(`.${Dialog.className}`) + + expect(getRenderedAttribute(dialog, 'aria-labelledby', '')).toBe(ariaLabelledBy) + }) + + it('applies default aria-labelledby as header id if header with id exists', () => { + const headerId = 'element-id' + const wrapper = mountWithProvider( + } header={{ id: headerId }} />, + ) + wrapper.find('.ui-button').simulate('click') + const dialog = wrapper.find(`.${Dialog.className}`) + + expect(getRenderedAttribute(dialog, 'aria-labelledby', '')).toBe(headerId) + }) + + it('applies default aria-labelledby as generated header id if header without id exists', () => { + const wrapper = mountWithProvider( + } header={'Welcome to my life'} />, + ) + wrapper.find('.ui-button').simulate('click') + const dialogHeaderId = wrapper + .find(`.${Dialog.slotClassNames.header}`) + .filterWhere(n => typeof n.type() === 'string') + .getDOMNode().id + const dialog = wrapper.find(`.${Dialog.className}`) + + expect(dialogHeaderId).toMatch(/dialog-header-\d+/) + expect(getRenderedAttribute(dialog, 'aria-labelledby', '')).toBe(dialogHeaderId) + }) + + it('does not apply default aria-labelledby as header id if aria-label is supplied as prop', () => { + const wrapper = mountWithProvider( + } + aria-label={'bla-bla-label'} + header={{ id: 'bla-bla-id' }} + />, + ) + wrapper.find('.ui-button').simulate('click') + const dialog = wrapper.find(`.${Dialog.className}`) + + expect(getRenderedAttribute(dialog, 'aria-labelledby', '')).toBe(undefined) + }) + + it('does not apply default aria-labelledby as header id if header is not supplied as prop', () => { + const wrapper = mountWithProvider(} />) + wrapper.find('.ui-button').simulate('click') + const dialog = wrapper.find(`.${Dialog.className}`) + + expect(getRenderedAttribute(dialog, 'aria-labelledby', '')).toBe(undefined) + }) + + it('applies aria-describedby if provided as prop', () => { + const ariaDescribedBy = 'element-id' + const wrapper = mountWithProvider( + } aria-describedby={ariaDescribedBy} />, + ) + wrapper.find('.ui-button').simulate('click') + const dialog = wrapper.find(`.${Dialog.className}`) + + expect(getRenderedAttribute(dialog, 'aria-describedby', '')).toBe(ariaDescribedBy) + }) + + it('applies default aria-describedby as content id if content with id exists', () => { + const contentId = 'element-id' + const wrapper = mountWithProvider( + } content={{ id: contentId }} />, + ) + wrapper.find('.ui-button').simulate('click') + const dialog = wrapper.find(`.${Dialog.className}`) + + expect(getRenderedAttribute(dialog, 'aria-describedby', '')).toBe(contentId) + }) + + it('applies default aria-describedby as generated content id if content without id exists', () => { + const wrapper = mountWithProvider( + } content={'It is so awesome.'} />, + ) + wrapper.find('.ui-button').simulate('click') + const dialogContentId = wrapper + .find(`.${Dialog.slotClassNames.content}`) + .filterWhere(n => typeof n.type() === 'string') + .getDOMNode().id + const dialog = wrapper.find(`.${Dialog.className}`) + + expect(dialogContentId).toMatch(/dialog-content-\d+/) + expect(getRenderedAttribute(dialog, 'aria-describedby', '')).toBe(dialogContentId) + }) + }) +})