Skip to content
This repository has been archived by the owner on Mar 4, 2020. It is now read-only.

feat(AutoFocusZone): added simple AutoFocusZone component for focusing the first focusable element or the element with the provided selector #1015

Merged
merged 8 commits into from
Mar 5, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Set `aria-modal` attribute for both Dialog and Popup with focus trap @sophieH29 ([#995](https://github.com/stardust-ui/react/pull/995))
- Allow arrays as shorthand for the Components containing prop of type `CollectionShorthand` @mnajdova ([#996](https://github.com/stardust-ui/react/pull/996))
- Allow to pass `children` and `content` to `MenuDivider` @layershifter ([#1009](https://github.com/stardust-ui/react/pull/1009))
- Add `AutoFocusZone` component, for focusing inner element on mount @mnajdova ([#1015](https://github.com/stardust-ui/react/pull/1015))

### Documentation
- Add `MenuButton` prototype (only available in development mode) @layershifter ([#947](https://github.com/stardust-ui/react/pull/947))
Expand Down
19 changes: 16 additions & 3 deletions packages/react/src/components/Popup/Popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import { getPopupPlacement, applyRtlToOffset, Alignment, Position } from './posi
import PopupContent from './PopupContent'

import { popupBehavior } from '../../lib/accessibility'
import { FocusTrapZone, FocusTrapZoneProps } from '../../lib/accessibility/FocusZone'
import {
AutoFocusZone,
AutoFocusZoneProps,
FocusTrapZone,
FocusTrapZoneProps,
} from '../../lib/accessibility/FocusZone'

import {
Accessibility,
Expand Down Expand Up @@ -475,11 +480,17 @@ export default class Popup extends AutoControlledComponent<ReactProps<PopupProps
...popupWrapperAttributes,
} as FocusTrapZoneProps

const autoFocusProps = {
...(typeof accessibility.autoFocus === 'boolean' ? {} : accessibility.autoFocus),
...popupWrapperAttributes,
} as AutoFocusZoneProps

/**
* if there is no focus trap wrapper, we should apply
* if there is no focus trap or auto focus wrapper, we should apply
* HTML attributes and positioning to popup content directly
*/
const popupContentAttributes = accessibility.focusTrap ? {} : popupWrapperAttributes
const popupContentAttributes =
accessibility.focusTrap || accessibility.autoFocus ? {} : popupWrapperAttributes

const popupContent = React.isValidElement(content)
? React.cloneElement(content, popupContentAttributes)
Expand All @@ -498,6 +509,8 @@ export default class Popup extends AutoControlledComponent<ReactProps<PopupProps
>
{accessibility.focusTrap ? (
<FocusTrapZone {...focusTrapProps}>{popupContent}</FocusTrapZone>
) : accessibility.autoFocus ? (
<AutoFocusZone {...autoFocusProps}>{popupContent}</AutoFocusZone>
) : (
popupContent
)}
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ export { default as gridBehavior } from './lib/accessibility/Behaviors/Grid/grid
export {
default as popupFocusTrapBehavior,
} from './lib/accessibility/Behaviors/Popup/popupFocusTrapBehavior'
export {
default as popupAutoFocusBehavior,
} from './lib/accessibility/Behaviors/Popup/popupAutoFocusBehavior'
export { default as dialogBehavior } from './lib/accessibility/Behaviors/Dialog/dialogBehavior'
export { default as statusBehavior } from './lib/accessibility/Behaviors/Status/statusBehavior'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Accessibility } from '../../types'
import popupBehavior from './popupBehavior'

/**
* @description
* Adds role='button' to 'trigger' component's part, if it is not focusable element and no role attribute provided.
* Adds tabIndex='0' to 'trigger' component's part, if it is not tabbable element and no tabIndex attribute provided.
*
* @specification
* Adds attribute 'aria-disabled=true' to 'trigger' component's part if 'disabled' property is true. Does not set the attribute otherwise.
* Automatically focus the first focusable element inside component.
*/
const popupAutoFocusBehavior: Accessibility = (props: any) => ({
...popupBehavior(props),
autoFocus: true,
})

export default popupAutoFocusBehavior
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import * as _ from 'lodash'

import { getNextElement, focusAsync } from './focusUtilities'

import { AutoFocusZoneProps } from './AutoFocusZone.types'
import getUnhandledProps from '../../getUnhandledProps'
import getElementType from '../../getElementType'
import * as customPropTypes from '../../customPropTypes'
import callable from '../../callable'
import Ref from '../../../components/Ref/Ref'

/** AutoFocusZone is used to focus inner element on mount. */
export class AutoFocusZone extends React.Component<AutoFocusZoneProps> {
private root = React.createRef<HTMLElement>()

static propTypes = {
as: customPropTypes.as,
firstFocusableSelector: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
}

static handledProps = _.keys(AutoFocusZone.propTypes)

public componentDidMount(): void {
this.findElementAndFocusAsync()
}

public render(): JSX.Element {
const unhandledProps = getUnhandledProps(
{ handledProps: AutoFocusZone.handledProps },
this.props,
)

const ElementType = getElementType({}, this.props) as React.ComponentClass<AutoFocusZoneProps>

return (
<Ref innerRef={this.root}>
<ElementType {...unhandledProps}>{this.props.children}</ElementType>
</Ref>
)
}

private findElementAndFocusAsync = () => {
if (!this.root.current) return
const { firstFocusableSelector } = this.props

const focusSelector = callable(firstFocusableSelector)()

const firstFocusableChild = focusSelector
? (this.root.current.querySelector(`.${focusSelector}`) as HTMLElement)
: getNextElement(
this.root.current,
this.root.current.firstChild as HTMLElement,
true,
false,
false,
true,
)

firstFocusableChild && focusAsync(firstFocusableChild)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react'

export interface AutoFocusZoneProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Element type the root element will use. Default is "div".
*/
as?: React.ReactType

/**
* Indicates the selector for first focusable item.
*/
firstFocusableSelector?: string | (() => string)
}
2 changes: 2 additions & 0 deletions packages/react/src/lib/accessibility/FocusZone/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from './FocusZone.types'
export * from './FocusTrapZone'
export * from './FocusTrapZone.types'
export * from './focusUtilities'
export * from './AutoFocusZone'
export * from './AutoFocusZone.types'
1 change: 1 addition & 0 deletions packages/react/src/lib/accessibility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { default as radioGroupBehavior } from './Behaviors/Radio/radioGroupBehav
export { default as radioGroupItemBehavior } from './Behaviors/Radio/radioGroupItemBehavior'
export { default as popupBehavior } from './Behaviors/Popup/popupBehavior'
export { default as popupFocusTrapBehavior } from './Behaviors/Popup/popupFocusTrapBehavior'
export { default as popupAutoFocusBehavior } from './Behaviors/Popup/popupAutoFocusBehavior'
export { default as chatBehavior } from './Behaviors/Chat/chatBehavior'
export { default as chatMessageBehavior } from './Behaviors/Chat/chatMessageBehavior'
export { default as gridBehavior } from './Behaviors/Grid/gridBehavior'
Expand Down
9 changes: 8 additions & 1 deletion packages/react/src/lib/accessibility/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { FocusTrapZoneProps, FocusZoneProps, IS_FOCUSABLE_ATTRIBUTE } from './FocusZone'
import {
FocusTrapZoneProps,
FocusZoneProps,
AutoFocusZoneProps,
IS_FOCUSABLE_ATTRIBUTE,
} from './FocusZone'

export type AriaWidgetRole =
| 'button'
Expand Down Expand Up @@ -143,13 +148,15 @@ export type FocusZoneDefinition = {
}

export type FocusTrapDefinition = FocusTrapZoneProps | boolean
export type AutoFocusZoneDefinition = AutoFocusZoneProps | boolean

export type KeyActions = { [partName: string]: { [actionName: string]: KeyAction } }
export interface AccessibilityDefinition {
attributes?: AccessibilityAttributesBySlot
keyActions?: KeyActions
focusZone?: FocusZoneDefinition
focusTrap?: FocusTrapDefinition
autoFocus?: AutoFocusZoneDefinition
}

export interface AccessibilityBehavior extends AccessibilityDefinition {
Expand Down
2 changes: 2 additions & 0 deletions packages/react/test/specs/behaviors/behavior-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
submenuBehavior,
popupBehavior,
popupFocusTrapBehavior,
popupAutoFocusBehavior,
dialogBehavior,
radioGroupBehavior,
radioGroupItemBehavior,
Expand Down Expand Up @@ -55,6 +56,7 @@ testHelper.addBehavior('menuDividerBehavior', menuDividerBehavior)
testHelper.addBehavior('submenuBehavior', submenuBehavior)
testHelper.addBehavior('popupBehavior', popupBehavior)
testHelper.addBehavior('popupFocusTrapBehavior', popupFocusTrapBehavior)
testHelper.addBehavior('popupAutoFocusBehavior', popupAutoFocusBehavior)
testHelper.addBehavior('radioGroupBehavior', radioGroupBehavior)
testHelper.addBehavior('radioGroupItemBehavior', radioGroupItemBehavior)
testHelper.addBehavior('selectableListBehavior', selectableListBehavior)
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 @@ -438,6 +438,23 @@ definitions.push({
},
})

// [AutoFocusZone] Automatically focus the first focusable element inside component
definitions.push({
regexp: /Automatically focus the first focusable element inside component/,
testMethod: (parameters: TestMethod) => {
const autofocusZoneProps = parameters.behavior({}).autoFocus

expect(autofocusZoneProps).toBeDefined()

if (typeof autofocusZoneProps === 'boolean') {
expect(autofocusZoneProps).toBe(true)
} else {
expect(autofocusZoneProps).not.toBeNull()
expect(typeof autofocusZoneProps).toBe('object')
}
},
})

// Triggers 'click' action with 'Enter' or 'Spacebar' on 'root'.
definitions.push({
regexp: /Triggers '(\w+)' action with '(\w+)' or '(\w+)' on '(\w+)'\./g,
Expand Down
112 changes: 112 additions & 0 deletions packages/react/test/specs/lib/AutoFocusZone-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as React from 'react'
import * as ReactTestUtils from 'react-dom/test-utils'

import { FocusZone, AutoFocusZone } from '../../../src/lib/accessibility/FocusZone'

// rAF does not exist in node - let's mock it
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
const r = window.setTimeout(callback, 0)
jest.runAllTimers()
return r
}

const animationFrame = () => new Promise(resolve => window.requestAnimationFrame(resolve))
jest.useFakeTimers()

describe('AutoFocusZone', () => {
let lastFocusedElement: HTMLElement | undefined

const _onFocus = (ev: any): void => (lastFocusedElement = ev.target)

const setupElement = (
element: HTMLElement,
{
clientRect,
isVisible = true,
}: {
clientRect: {
top: number
left: number
bottom: number
right: number
}
isVisible?: boolean
},
): void => {
element.getBoundingClientRect = () => ({
top: clientRect.top,
left: clientRect.left,
bottom: clientRect.bottom,
right: clientRect.right,
width: clientRect.right - clientRect.left,
height: clientRect.bottom - clientRect.top,
})

element.setAttribute('data-is-visible', String(isVisible))
element.focus = () => ReactTestUtils.Simulate.focus(element)
}

beforeEach(() => {
lastFocusedElement = undefined
})

describe('Focusing the ATZ', () => {
function setupTest(firstFocusableSelector?: string) {
let autoFocusZoneRef: AutoFocusZone | null = null
const topLevelDiv = ReactTestUtils.renderIntoDocument(
<div onFocusCapture={_onFocus}>
<AutoFocusZone
data-is-focusable={true}
firstFocusableSelector={firstFocusableSelector}
ref={ftz => {
autoFocusZoneRef = ftz
}}
>
<button className={'f'}>f</button>
<FocusZone>
<button className={'a'}>a</button>
<button className={'b'}>b</button>
</FocusZone>
</AutoFocusZone>
<button className={'z'}>z</button>
</div>,
) as HTMLElement
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use enzyme for tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was copying and trying to keep these tests consistent with the other focus zone tests.. If we decide later to move all of them to enzyme we will do it for all of them.


const buttonF = topLevelDiv.querySelector('.f') as HTMLElement
const buttonA = topLevelDiv.querySelector('.a') as HTMLElement
const buttonB = topLevelDiv.querySelector('.b') as HTMLElement
const buttonZ = topLevelDiv.querySelector('.z') as HTMLElement

// Assign bounding locations to buttons.
setupElement(buttonF, { clientRect: { top: 0, bottom: 10, left: 0, right: 10 } })
setupElement(buttonA, { clientRect: { top: 10, bottom: 20, left: 0, right: 10 } })
setupElement(buttonB, { clientRect: { top: 20, bottom: 30, left: 0, right: 10 } })
setupElement(buttonZ, { clientRect: { top: 30, bottom: 40, left: 0, right: 10 } })

return { autoFocusZone: autoFocusZoneRef, buttonF, buttonA, buttonB, buttonZ }
}

it('goes to first focusable element when focusing the ATZ', async () => {
expect.assertions(1)

const { autoFocusZone, buttonF } = setupTest()

// By calling `componentDidMount`, AFZ will behave as just initialized and focus needed element
// Focus within should go to 1st focusable inner element.
autoFocusZone.componentDidMount()
await animationFrame()
expect(lastFocusedElement).toBe(buttonF)
})

it('goes to the element with containing the firstFocusableSelector if provided when focusing the ATZ', async () => {
expect.assertions(1)
const { autoFocusZone, buttonB } = setupTest('b')

// By calling `componentDidMount`, AFZ will behave as just initialized and focus needed element
// Focus within should go to the element containing the selector.
autoFocusZone.componentDidMount()
await animationFrame()
expect(lastFocusedElement).toBe(buttonB)
})
})
})