Skip to content
This repository was archived by the owner on Mar 4, 2020. It is now read-only.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 16 additions & 0 deletions packages/react/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ShorthandValue>,
Expand Down Expand Up @@ -93,6 +98,8 @@ class Dialog extends AutoControlledComponent<ReactProps<DialogProps>, DialogStat
static displayName = 'Dialog'
static className = 'ui-dialog'

static slotClassNames: DialogSlotClassNames

static propTypes = {
...commonPropTypes.createCommon({
children: false,
Expand Down Expand Up @@ -202,12 +209,16 @@ class Dialog extends AutoControlledComponent<ReactProps<DialogProps>, 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,
},
})}

Expand Down Expand Up @@ -257,4 +268,9 @@ class Dialog extends AutoControlledComponent<ReactProps<DialogProps>, DialogStat
}
}

Dialog.slotClassNames = {
header: `${Dialog.className}__header`,
content: `${Dialog.className}__content`,
}

export default Dialog
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Accessibility } from '../../types'
import popupFocusTrapBehavior from '../Popup/popupFocusTrapBehavior'
import * as _ from 'lodash'

/**
* @description
Expand All @@ -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
1 change: 1 addition & 0 deletions packages/react/src/lib/accessibility/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export interface AriaRelationshipAttributes {
export interface AccessibilityAttributes extends AriaWidgetAttributes, AriaRelationshipAttributes {
role?: AriaRole
tabIndex?: number
id?: string
[IS_FOCUSABLE_ATTRIBUTE]?: boolean
}

Expand Down
17 changes: 17 additions & 0 deletions packages/react/test/specs/behaviors/testDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
117 changes: 117 additions & 0 deletions packages/react/test/specs/components/Dialog/Dialog-test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Dialog trigger={<Button content="Open a dialog" />} 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(
<Dialog trigger={<Button content="Open a dialog" />} 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(
<Dialog trigger={<Button content="Open a dialog" />} 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(
<Dialog trigger={<Button content="Open a dialog" />} 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(
<Dialog
trigger={<Button content="Open a dialog" />}
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(<Dialog trigger={<Button content="Open a dialog" />} />)
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(
<Dialog trigger={<Button content="Open a dialog" />} 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(
<Dialog trigger={<Button content="Open a dialog" />} 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(
<Dialog trigger={<Button content="Open a dialog" />} 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)
})
})
})