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 @@ -40,6 +40,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Set default `chatBehavior` which uses Enter/Esc keys @sophieH29 ([#443](https://github.com/stardust-ui/react/pull/443))
- Add `iconPosition` property to `Input` component @mnajdova ([#442](https://github.com/stardust-ui/react/pull/442))
- Add `color`, `inverted` and `renderContent` props and `content` slot to `Segment` component @Bugaa92 ([#389](https://github.com/stardust-ui/react/pull/389))
- Add focus trap behavior to `Popup` @kuzhelov ([#457](https://github.com/stardust-ui/react/pull/457))

### Documentation
- Add all missing component descriptions and improve those existing @levithomason ([#400](https://github.com/stardust-ui/react/pull/400))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react'
import { Button, Input, Header, Popup, popupFocusTrapBehavior } from '@stardust-ui/react'

const PopupFocusTrapExample = () => (
<>
<Popup
/** Provided behavior introduces focus trap to popup content. */
accessibility={popupFocusTrapBehavior}
trigger={<Button icon="expand" content="Trap focus on appearence" />}
content={{
content: (
<>
<Header as="h4">This content traps focus on appearance.</Header>
<Input icon="search" placeholder="Search..." />
</>
),
}}
/>

{/* Default Popup behavior doesn't introduce focus trap. */}
<Popup
trigger={<Button icon="expand" content="Do not trap focus" />}
content={{
content: (
<>
<Header as="h4">Focus is not trapped for this content.</Header>
<Input icon="search" placeholder="Search..." />
</>
),
}}
/>
</>
)

export default PopupFocusTrapExample
5 changes: 5 additions & 0 deletions docs/src/examples/components/Popup/Types/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ const Types = () => (
description="Use 'content' prop of the Popup to set whether Popup content should be rendered with the default wrapper."
examplePath="components/Popup/Types/PopupContentWrapperExample"
/>
<ComponentExample
title="Focus Trap"
description="Popup content traps focus on appearance by using dedicated accessibility behavior."
examplePath="components/Popup/Types/PopupFocusTrapExample"
/>
<ComponentExample
title="Custom Target"
description="By default Popup uses trigger element as the one it is displayed for, but it is possible to provide any DOM element as popup's target."
Expand Down
54 changes: 42 additions & 12 deletions src/components/Popup/Popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import computePopupPlacement, { Alignment, Position } from './positioningHelper'
import PopupContent from './PopupContent'

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

import {
Accessibility,
AccessibilityActionHandlers,
Expand Down Expand Up @@ -172,8 +174,12 @@ export default class Popup extends AutoControlledComponent<Extendable<PopupProps
this.outsideClickSubscription.unsubscribe()
}

public renderComponent({ rtl, accessibility }: RenderResultConfig<PopupProps>): React.ReactNode {
const popupContent = this.renderPopupContent(rtl, accessibility)
public renderComponent({
classes,
rtl,
accessibility,
}: RenderResultConfig<PopupProps>): React.ReactNode {
const popupContent = this.renderPopupContent(classes.popup, rtl, accessibility)

return (
<>
Expand Down Expand Up @@ -212,7 +218,11 @@ export default class Popup extends AutoControlledComponent<Extendable<PopupProps
)
}

private renderPopupContent(rtl: boolean, accessibility: AccessibilityBehavior): JSX.Element {
private renderPopupContent(
popupPositionClasses: string,
rtl: boolean,
accessibility: AccessibilityBehavior,
): JSX.Element {
const { align, position } = this.props
const { target } = this.state

Expand All @@ -223,34 +233,54 @@ export default class Popup extends AutoControlledComponent<Extendable<PopupProps
<Popper
placement={placement}
referenceElement={target}
children={this.renderPopperChildren.bind(this, rtl, accessibility)}
children={this.renderPopperChildren.bind(this, popupPositionClasses, rtl, accessibility)}
/>
)
)
}

private renderPopperChildren = (
popupPositionClasses: string,
rtl: boolean,
accessibility: AccessibilityBehavior,
{ ref, style: popupPlacementStyles }: PopperChildrenProps,
) => {
const { content } = this.props

const popupContentAttributes = {
...(rtl && { dir: 'rtl' }),
...accessibility.attributes.popup,
...accessibility.keyHandlers.popup,

className: popupPositionClasses,
style: popupPlacementStyles,
}

const focusTrapProps = {
...(typeof accessibility.focusTrap === 'boolean' ? {} : accessibility.focusTrap),
...popupContentAttributes,
} as FocusTrapZoneProps

const popupContent = Popup.Content.create(content, {
/**
* if there is no focus trap wrapper, we should apply
* HTML attributes and positioning to popup content directly
*/
defaultProps: accessibility.focusTrap ? {} : popupContentAttributes,
})

return (
<Ref
innerRef={domElement => {
ref(domElement)
this.popupDomElement = domElement
}}
>
{Popup.Content.create(content, {
defaultProps: {
...(rtl && { dir: 'rtl' }),
style: popupPlacementStyles,
...accessibility.attributes.popup,
...accessibility.keyHandlers.popup,
},
})}
{accessibility.focusTrap ? (
<FocusTrapZone {...focusTrapProps}>{popupContent}</FocusTrapZone>
) : (
popupContent
)}
</Ref>
)
}
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ export {
default as chatMessageBehavior,
} from './lib/accessibility/Behaviors/Chat/chatMessageBehavior'
export { default as gridBehavior } from './lib/accessibility/Behaviors/Grid/gridBehavior'
export {
default as popupFocusTrapBehavior,
} from './lib/accessibility/Behaviors/Popup/popupFocusTrapBehavior'

//
// Utilities
Expand Down
17 changes: 17 additions & 0 deletions src/lib/accessibility/Behaviors/Popup/popupFocusTrapBehavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Accessibility } from '../../types'
import popupBehavior from './popupBehavior'
import * as _ from 'lodash'

/**
* @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.
* Adds attribute 'aria-disabled=true' to 'trigger' component's part based on the property 'disabled'.
* Traps focus inside component.
*/
const popupFocusTrapBehavior: Accessibility = (props: any) => ({
...popupBehavior(props),
focusTrap: true,
})

export default popupFocusTrapBehavior
1 change: 1 addition & 0 deletions src/lib/accessibility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { default as toolbarButtonBehavior } from './Behaviors/Toolbar/toolbarBut
export { default as radioGroupBehavior } from './Behaviors/Radio/radioGroupBehavior'
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 chatBehavior } from './Behaviors/Chat/chatBehavior'
export { default as chatMessageBehavior } from './Behaviors/Chat/chatMessageBehavior'
export { default as gridBehavior } from './Behaviors/Grid/gridBehavior'
6 changes: 2 additions & 4 deletions src/lib/accessibility/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,15 @@ export type FocusZoneDefinition = {
props?: FocusZoneProps
}

export type FocusTrapZoneDefinition = {
props?: FocusTrapZoneProps
}
export type FocusTrapDefinition = FocusTrapZoneProps | boolean

export type KeyActions = { [partName: string]: { [actionName: string]: KeyAction } }
export interface AccessibilityDefinition {
attributes?: AccessibilityAttributesBySlot
keyActions?: KeyActions
handledProps?: (keyof AccessibilityAttributes)[]
focusZone?: FocusZoneDefinition
focusTrapZone?: FocusTrapZoneDefinition
focusTrap?: FocusTrapDefinition
}

export interface AccessibilityBehavior extends AccessibilityDefinition {
Expand Down
1 change: 1 addition & 0 deletions src/themes/teams/componentStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export { default as ListItem } from './components/List/listItemStyles'
export { default as Menu } from './components/Menu/menuStyles'
export { default as MenuItem } from './components/Menu/menuItemStyles'

export { default as Popup } from './components/Popup/popupStyles'
export { default as PopupContent } from './components/Popup/popupContentStyles'

export { default as RadioGroupItem } from './components/RadioGroup/radioGroupItemStyles'
Expand Down
1 change: 1 addition & 0 deletions src/themes/teams/componentVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export { default as ListItem } from './components/List/listItemVariables'

export { default as Menu } from './components/Menu/menuVariables'

export { default as Popup } from './components/Popup/popupVariables'
export { default as PopupContent } from './components/Popup/popupContentVariables'

export { default as RadioGroupItem } from './components/RadioGroup/radioGroupItemVariables'
Expand Down
13 changes: 4 additions & 9 deletions src/themes/teams/components/Popup/popupContentStyles.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import { ComponentSlotStylesInput, ICSSInJSStyle } from '../../../types'
import { pxToRem } from '../../../../lib'
import { PopupContentProps } from '../../../../components/Popup/PopupContent'
import { PopupContentVariables } from './popupContentVariables'

const popupContentStyles: ComponentSlotStylesInput<PopupContentProps, any> = {
const popupContentStyles: ComponentSlotStylesInput<PopupContentProps, PopupContentVariables> = {
root: ({ props, variables }): ICSSInJSStyle => {
const { backgroundColor, borderColor, padding, zIndex } = variables
const { backgroundColor, borderColor, padding } = variables

return {
backgroundColor,
zIndex,
display: 'block',
position: 'absolute',
top: 'auto',
bottom: 'auto',
left: 'auto',
right: 'auto',
backgroundColor,
padding,
border: `1px solid ${borderColor}`,
borderRadius: pxToRem(3),
Expand Down
2 changes: 0 additions & 2 deletions src/themes/teams/components/Popup/popupContentVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@ export interface PopupContentVariables {
backgroundColor: string
borderColor: string
padding: string
zIndex: number
}

export default (siteVars: any): PopupContentVariables => {
return {
backgroundColor: siteVars.white,
borderColor: siteVars.gray06,
padding: `${pxToRem(10)} ${pxToRem(14)}`,
zIndex: 1000,
}
}
14 changes: 14 additions & 0 deletions src/themes/teams/components/Popup/popupStyles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ComponentSlotStylesInput, ICSSInJSStyle } from '../../../types'
import { PopupProps } from '../../../../components/Popup/Popup'
import { PopupVariables } from './popupVariables'

const popupStyles: ComponentSlotStylesInput<PopupProps, PopupVariables> = {
root: (): ICSSInJSStyle => ({}),

popup: ({ variables }): ICSSInJSStyle => ({
zIndex: variables.zIndex,
position: 'absolute',
}),
}

export default popupStyles
7 changes: 7 additions & 0 deletions src/themes/teams/components/Popup/popupVariables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface PopupVariables {
[key: string]: string | number

zIndex: number
}

export default (siteVars: any): PopupVariables => ({ zIndex: 1000 })
2 changes: 2 additions & 0 deletions test/specs/behaviors/behavior-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
menuBehavior,
menuItemBehavior,
popupBehavior,
popupFocusTrapBehavior,
radioGroupBehavior,
radioGroupItemBehavior,
selectableListBehavior,
Expand Down Expand Up @@ -43,6 +44,7 @@ testHelper.addBehavior('imageBehavior', imageBehavior)
testHelper.addBehavior('menuBehavior', menuBehavior)
testHelper.addBehavior('menuItemBehavior', menuItemBehavior)
testHelper.addBehavior('popupBehavior', popupBehavior)
testHelper.addBehavior('popupFocusTrapBehavior', popupFocusTrapBehavior)
testHelper.addBehavior('radioGroupBehavior', radioGroupBehavior)
testHelper.addBehavior('radioGroupItemBehavior', radioGroupItemBehavior)
testHelper.addBehavior('selectableListBehavior', selectableListBehavior)
Expand Down
17 changes: 17 additions & 0 deletions test/specs/behaviors/testDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,23 @@ definitions.push({
},
})

// [FocusTrapZone] Traps focus inside component
definitions.push({
regexp: /Traps focus inside component/,
testMethod: (parameters: TestMethod) => {
const focusTrapZoneProps = parameters.behavior({}).focusTrap

expect(focusTrapZoneProps).toBeDefined()

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

// Example: Performs 'nextItem' action on ArrowDown, ArrowRight.
definitions.push({
regexp: /Performs '([a-z A-Z]+)' action on ([a-z A-Z]+), ([a-z A-Z]+)\.+/g,
Expand Down