diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7c34f088..a8b18cc4a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/packages/react/src/components/Popup/Popup.tsx b/packages/react/src/components/Popup/Popup.tsx index 1394aeff38..8fa59ad9d4 100644 --- a/packages/react/src/components/Popup/Popup.tsx +++ b/packages/react/src/components/Popup/Popup.tsx @@ -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, @@ -475,11 +480,17 @@ export default class Popup extends AutoControlledComponent {accessibility.focusTrap ? ( {popupContent} + ) : accessibility.autoFocus ? ( + {popupContent} ) : ( popupContent )} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index da7c11c787..9d7fa97c99 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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' diff --git a/packages/react/src/lib/accessibility/Behaviors/Popup/popupAutoFocusBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/Popup/popupAutoFocusBehavior.ts new file mode 100644 index 0000000000..6c82e909d1 --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/Popup/popupAutoFocusBehavior.ts @@ -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 diff --git a/packages/react/src/lib/accessibility/FocusZone/AutoFocusZone.tsx b/packages/react/src/lib/accessibility/FocusZone/AutoFocusZone.tsx new file mode 100644 index 0000000000..572d90db56 --- /dev/null +++ b/packages/react/src/lib/accessibility/FocusZone/AutoFocusZone.tsx @@ -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 { + private root = React.createRef() + + 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 + + return ( + + {this.props.children} + + ) + } + + 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) + } +} diff --git a/packages/react/src/lib/accessibility/FocusZone/AutoFocusZone.types.tsx b/packages/react/src/lib/accessibility/FocusZone/AutoFocusZone.types.tsx new file mode 100644 index 0000000000..c12e7ec7c0 --- /dev/null +++ b/packages/react/src/lib/accessibility/FocusZone/AutoFocusZone.types.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' + +export interface AutoFocusZoneProps extends React.HTMLAttributes { + /** + * Element type the root element will use. Default is "div". + */ + as?: React.ReactType + + /** + * Indicates the selector for first focusable item. + */ + firstFocusableSelector?: string | (() => string) +} diff --git a/packages/react/src/lib/accessibility/FocusZone/index.ts b/packages/react/src/lib/accessibility/FocusZone/index.ts index d052f40c15..14ab3d8f0e 100644 --- a/packages/react/src/lib/accessibility/FocusZone/index.ts +++ b/packages/react/src/lib/accessibility/FocusZone/index.ts @@ -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' diff --git a/packages/react/src/lib/accessibility/index.ts b/packages/react/src/lib/accessibility/index.ts index 3431d36d21..af7bcc5d0c 100644 --- a/packages/react/src/lib/accessibility/index.ts +++ b/packages/react/src/lib/accessibility/index.ts @@ -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' diff --git a/packages/react/src/lib/accessibility/types.ts b/packages/react/src/lib/accessibility/types.ts index 27af843a3d..c13eede0fc 100644 --- a/packages/react/src/lib/accessibility/types.ts +++ b/packages/react/src/lib/accessibility/types.ts @@ -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' @@ -143,6 +148,7 @@ export type FocusZoneDefinition = { } export type FocusTrapDefinition = FocusTrapZoneProps | boolean +export type AutoFocusZoneDefinition = AutoFocusZoneProps | boolean export type KeyActions = { [partName: string]: { [actionName: string]: KeyAction } } export interface AccessibilityDefinition { @@ -150,6 +156,7 @@ export interface AccessibilityDefinition { keyActions?: KeyActions focusZone?: FocusZoneDefinition focusTrap?: FocusTrapDefinition + autoFocus?: AutoFocusZoneDefinition } export interface AccessibilityBehavior extends AccessibilityDefinition { diff --git a/packages/react/test/specs/behaviors/behavior-test.tsx b/packages/react/test/specs/behaviors/behavior-test.tsx index 45673860e9..b586bed109 100644 --- a/packages/react/test/specs/behaviors/behavior-test.tsx +++ b/packages/react/test/specs/behaviors/behavior-test.tsx @@ -19,6 +19,7 @@ import { submenuBehavior, popupBehavior, popupFocusTrapBehavior, + popupAutoFocusBehavior, dialogBehavior, radioGroupBehavior, radioGroupItemBehavior, @@ -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) diff --git a/packages/react/test/specs/behaviors/testDefinitions.ts b/packages/react/test/specs/behaviors/testDefinitions.ts index 2d6bb50827..93d723e912 100644 --- a/packages/react/test/specs/behaviors/testDefinitions.ts +++ b/packages/react/test/specs/behaviors/testDefinitions.ts @@ -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, diff --git a/packages/react/test/specs/lib/AutoFocusZone-test.tsx b/packages/react/test/specs/lib/AutoFocusZone-test.tsx new file mode 100644 index 0000000000..ce1748bb48 --- /dev/null +++ b/packages/react/test/specs/lib/AutoFocusZone-test.tsx @@ -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( +
+ { + autoFocusZoneRef = ftz + }} + > + + + + + + + +
, + ) as HTMLElement + + 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) + }) + }) +})