Skip to content
This repository was archived by the owner on Mar 4, 2020. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
50bda74
feat(portal): simple base implementation
bmdalex Aug 23, 2018
d0df7ed
addressed PR comments
bmdalex Aug 28, 2018
f89f9ec
feat(popup): simple base implementation
bmdalex Aug 23, 2018
01710c7
Merge branch 'master' of https://github.com/stardust-ui/react into fe…
bmdalex Sep 7, 2018
7b9e89e
addressed comments
bmdalex Sep 7, 2018
a1da665
addressed comments
bmdalex Sep 7, 2018
0b800d0
Merge branch 'feat/popup-simple' of https://github.com/stardust-ui/re…
bmdalex Sep 10, 2018
f25dd64
addressed comments
bmdalex Sep 10, 2018
cab3a3a
Add accessibility PopupBehavior, make improvements to Popup and Portal
Sep 10, 2018
03fd813
Merge latest changes from popup-simple into current branch
Sep 10, 2018
8233bf0
addressed comments
bmdalex Sep 10, 2018
2ec0fae
Merge branch 'master' of https://github.com/stardust-ui/react into fe…
bmdalex Sep 11, 2018
ce91227
fixed unit tests
bmdalex Sep 11, 2018
c57ec99
Merge branch 'feat/popup-simple' into feat/acc-popup
Sep 11, 2018
a129632
Made improvement
Sep 11, 2018
9e3a04f
Made improvement
Sep 11, 2018
37a117a
Made improvement
Sep 11, 2018
79f8205
Add accessibility for Popup / Portal
Sep 12, 2018
426b09b
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 13, 2018
8b5a1a7
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 13, 2018
fa878f2
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 17, 2018
17e0cb2
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 17, 2018
283387a
Improvements after CR
Sep 17, 2018
fdd04ba
Improvements after CR
Sep 17, 2018
a68bd9b
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 18, 2018
474779a
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 18, 2018
a53bba7
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 19, 2018
df1f55f
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 20, 2018
195882a
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 20, 2018
2300e78
Update index.ts
sophieH29 Sep 20, 2018
767fe72
Fixes after auto merge
Sep 20, 2018
dcef9cd
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 21, 2018
0b3217b
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 21, 2018
f577ef1
Updates after CR comments
Sep 21, 2018
0f3f846
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 24, 2018
b07c474
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 24, 2018
1f0edc7
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 24, 2018
df21650
improved after CR comments
Sep 24, 2018
94040bc
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 24, 2018
8ed764c
add missed actionHandlers access modifiers
Sep 24, 2018
921e20b
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 25, 2018
ffceb03
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 25, 2018
e210908
Improve typing
Sep 25, 2018
051509a
Merge branch 'master' into feat/accessibility-popup-behavior
sophieH29 Sep 25, 2018
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
2 changes: 1 addition & 1 deletion src/components/Menu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class MenuItem extends UIComponent<Extendable<IMenuItemProps>, any> {
'vertical',
]

actionHandlers: AccessibilityActionHandlers = {
protected actionHandlers: AccessibilityActionHandlers = {
performClick: event => this.handleClick(event),
}

Expand Down
89 changes: 76 additions & 13 deletions src/components/Popup/Popup.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import _ from 'lodash'
import { Popper, PopperChildrenProps } from 'react-popper'
import rtlCSSJS from 'rtl-css-js'

import { childrenExist, customPropTypes, UIComponent, IRenderResultConfig } from '../../lib'
import { ItemShorthand, Extendable, ReactChildren } from '../../../types/utils'
import { ComponentVariablesInput, ComponentPartStyle } from '../../../types/theme'
import Portal from '../Portal'
import Portal, { TriggerAccessibility } from '../Portal'
import PopupContent from './PopupContent'
import computePopupPlacement, { Alignment, Position } from './positioningHelper'
import { PopupBehavior } from '../../lib/accessibility'
import {
Accessibility,
AccessibilityActionHandlers,
IAccessibilityBehavior,
} from '../../lib/accessibility/interfaces'
import computePopupPlacement, { Alignment, Position, Placement } from './positioningHelper'

const POSITIONS: Position[] = ['above', 'below', 'before', 'after']
const ALIGNMENTS: Alignment[] = ['top', 'bottom', 'start', 'end', 'center']

export interface IPopupProps {
accessibility?: Accessibility
align?: Alignment
as?: any
basic?: boolean
Expand All @@ -28,6 +36,7 @@ export interface IPopupProps {

export interface IPopupState {
triggerRef: HTMLElement
popupOpened?: boolean
Copy link
Member

Choose a reason for hiding this comment

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

Why is Popup not an auto controlled component (with the same prop name as Portal)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, a good question :) Initial implementation of Popup doesn't expose this prop to users, and when I was making this change, I didn't want to change API of a component but leave as it is.
So only behavior and key handlers would be added. Anyway, this can be a point for discussion as well as a separate PR.

Copy link
Contributor

Choose a reason for hiding this comment

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

yes, lets address it by means of the follow up PR, will take care of that

}

/**
Expand Down Expand Up @@ -77,9 +86,13 @@ export default class Popup extends UIComponent<Extendable<IPopupProps>, IPopupSt

/** Override for theme site variables to allow modifications of component styling via themes. */
variables: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),

/** Accessibility behavior if overridden by the user. */
accessibility: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),
}

public static handledProps = [
'accessibility',
'align',
'as',
'basic',
Expand All @@ -92,60 +105,110 @@ export default class Popup extends UIComponent<Extendable<IPopupProps>, IPopupSt
'variables',
]

public static defaultProps = {
public static defaultProps: IPopupProps = {
as: Portal,
align: 'start',
position: 'above',
accessibility: PopupBehavior,
}

public state = { triggerRef: undefined }
public state = { triggerRef: undefined, popupOpened: false }

protected actionHandlers: AccessibilityActionHandlers = {
performClick: e => this.onTriggerClick(e),
closeAndFocusTrigger: e => this.handlePopupState(e, () => false, this.focusTrigger),
}

public renderComponent({
ElementType,
classes,
accessibility,
rest,
rtl,
}: IRenderResultConfig<IPopupProps>): React.ReactNode {
const { children, trigger } = this.props
const { children, trigger, position, align } = this.props

return (
<ElementType
className={classes.root}
{...rest}
open={this.state.popupOpened}
trigger={trigger}
triggerRef={this.handleTriggerRef}
triggerAccessibility={
{
attributes: accessibility.attributes.trigger,
keyHandlers: accessibility.keyHandlers.trigger,
} as TriggerAccessibility
}
onOutsideClick={e => this.handlePopupState(e, () => false)}
onTriggerClick={this.onTriggerClick}
>
{childrenExist(children) ? children : this.renderContent(rtl)}
{childrenExist(children)
? children
: this.renderContent(
computePopupPlacement({ align, position, rtl }),
this.renderPopperChildren.bind(this, rtl, accessibility),
)}
</ElementType>
)
}

private renderContent(rtl: boolean): JSX.Element {
const { align, position } = this.props
private renderContent(
popupPlacement: Placement,
renderPopperChildrenFromProps: (props: PopperChildrenProps) => React.ReactNode,
): JSX.Element {
const triggerRef = this.state.triggerRef
const placement = computePopupPlacement({ align, position, rtl })

return (
triggerRef && (
<Popper
placement={placement}
placement={popupPlacement}
referenceElement={triggerRef}
children={this.renderPopperChildren.bind(this, rtl)}
children={renderPopperChildrenFromProps}
/>
)
)
}

private renderPopperChildren = (rtl: boolean, { ref, style }: PopperChildrenProps) => {
private renderPopperChildren = (
rtl: boolean,
accessibility: IAccessibilityBehavior,
{ ref, style }: PopperChildrenProps,
) => {
const { basic, content } = this.props
const computedStyle = rtl ? rtlCSSJS(style) : style

return (
<Popup.Content innerRef={ref} basic={basic} {...rtl && { dir: 'rtl' }} styles={computedStyle}>
<Popup.Content
innerRef={ref}
basic={basic}
{...rtl && { dir: 'rtl' }}
styles={computedStyle}
{...accessibility.attributes.popup}
{...accessibility.keyHandlers.popup}
>
{content}
</Popup.Content>
)
}

private onTriggerClick = (e: Event) => {
this.handlePopupState(e, prevOpened => !prevOpened)
}

private handlePopupState = (
e: Event,
getPopupOpened: (previousOpened) => boolean,
afterRenderCb?: () => void,
) => {
e.preventDefault()
this.setState(
previousState => ({ popupOpened: getPopupOpened(previousState.popupOpened) }),
afterRenderCb,
)
}

private focusTrigger = () => _.invoke(this.state.triggerRef, 'focus')
private handleTriggerRef = (triggerRef: HTMLElement) => this.setState({ triggerRef })
}
1 change: 1 addition & 0 deletions src/components/Popup/positioningHelper.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { Placement } from 'popper.js'
import { Placement } from 'popper.js'

export type Position = 'above' | 'below' | 'before' | 'after'
Expand Down
44 changes: 40 additions & 4 deletions src/components/Portal/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ import {
import { ItemShorthand, ReactChildren } from '../../../types/utils'
import Ref from '../Ref'
import PortalInner from './PortalInner'
import { IAccessibilityAttributes, OnKeyDownHandler } from '../../lib/accessibility/interfaces'

type ReactMouseEvent = React.MouseEvent<HTMLElement>
export type TriggerAccessibility = {
attributes?: IAccessibilityAttributes
keyHandlers?: OnKeyDownHandler
}

export interface IPortalProps {
children?: ReactChildren
Expand All @@ -23,7 +28,10 @@ export interface IPortalProps {
onUnmount?: (props: IPortalProps) => void
open?: boolean
trigger?: JSX.Element
triggerAccessibility?: TriggerAccessibility
triggerRef?: (node: HTMLElement) => void
onTriggerClick?: (e: ReactMouseEvent) => void
onOutsideClick?: (e: ReactMouseEvent) => void
}

export interface IPortalState {
Expand Down Expand Up @@ -75,19 +83,43 @@ class Portal extends AutoControlledComponent<IPortalProps, IPortalState> {
* @param {JSX.Element} node - Referred node.
*/
triggerRef: PropTypes.func,

/** Accessibility behavior object to apply on trigger node. */
triggerAccessibility: PropTypes.object,

/**
* Called when trigger node was clicked.
*
* @param {object} data - All props.
*/
onTriggerClick: PropTypes.func,

/**
* Called when `click` event was invoked outside portal or trigger nodes.
*
* @param {object} data - All props.
*/
onOutsideClick: PropTypes.func,
}

public static handledProps = [
'children',
'content',
'defaultOpen',
'onMount',
'onOutsideClick',
'onTriggerClick',
'onUnmount',
'open',
'trigger',
'triggerAccessibility',
'triggerRef',
]

public static defaultProps: IPortalProps = {
triggerAccessibility: {},
}

public renderComponent(): React.ReactNode {
return (
<React.Fragment>
Expand All @@ -113,17 +145,20 @@ class Portal extends AutoControlledComponent<IPortalProps, IPortalState> {
}

private renderTrigger(): JSX.Element | undefined {
const { trigger } = this.props
const { trigger, triggerAccessibility } = this.props

return (
trigger && (
<Ref innerRef={this.handleTriggerRef}>
{React.cloneElement(trigger, { onClick: this.handleTriggerClick })}
{React.cloneElement(trigger, {
onClick: this.handleTriggerClick,
...triggerAccessibility.attributes,
...triggerAccessibility.keyHandlers,
})}
</Ref>
)
)
}

private handleMount = () => {
eventStack.sub('click', this.handleDocumentClick)
_.invoke(this.props, 'onMount', this.props)
Expand All @@ -147,6 +182,7 @@ class Portal extends AutoControlledComponent<IPortalProps, IPortalState> {
private handleTriggerClick = (e: ReactMouseEvent, ...rest) => {
const { trigger } = this.props

_.invoke(this.props, 'onTriggerClick', e) // Call handler from parent component
_.invoke(trigger, 'props.onClick', e, ...rest) // Call original event handler
this.trySetState({ open: !this.state.open })
}
Expand All @@ -159,7 +195,7 @@ class Portal extends AutoControlledComponent<IPortalProps, IPortalState> {
) {
return // ignore the click
}

_.invoke(this.props, 'onOutsideClick', e)
this.trySetState({ open: false })
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Portal/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default } from './Portal'
export { default, TriggerAccessibility } from './Portal'
62 changes: 62 additions & 0 deletions src/lib/accessibility/Behaviors/Popup/PopupBehavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Accessibility } from '../../interfaces'
Copy link
Collaborator

Choose a reason for hiding this comment

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

please add description :) and if you can use format of the description/sentences which is alrady in ohters beharviors files... Thank you :)

import * as keyboardKey from 'keyboard-key'
import _ 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'.
* Adds attribute 'aria-haspopup=true' to 'trigger' component's part.
*/
const PopupBehavior: Accessibility = (props: any) => ({
attributes: {
trigger: {
role: getAriaAttributeFromProps('role', props, 'button'),
tabIndex: getAriaAttributeFromProps('tabIndex', props, '0'),
'aria-haspopup': 'true',
'aria-disabled': !!props['disabled'],
},
},
keyActions: {
trigger: {
performClick: {
keyCombinations: [{ keyCode: keyboardKey.Enter }, { keyCode: keyboardKey.Spacebar }],
Copy link
Member

Choose a reason for hiding this comment

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

What's intended behavior here?

I understand I can open popup using Enter or Spacebar - where should the focus go after that? Now it remains on the trigger, there is no way how I could navigate inside the popup. What if there is a menu inside the popup?

And when the focus remains on the trigger, I would expect it would be closable by Esc (copying closeAndFocusTrigger from ~5 lines below to here works for me), and personally I am not sure that I expect Enter to close it.

Copy link
Contributor Author

@sophieH29 sophieH29 Sep 19, 2018

Choose a reason for hiding this comment

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

Focus management would be introduced in future PR (+ FocusTrapZone which also handles focus after popup was rendered #239). This PR just adds accessibility behavior + action handlers.

Regarding closing the popup by Esc, initially it was implemented as you mentioned. But after discussion with Milan, we came up that it is not correct from an accessibility point of view. Esc should work for a popup to close it when the focus is on the popup. For trigger element, it will behave the same way as clicking with a mouse - which will toggle popup currently.

},
},
popup: {
closeAndFocusTrigger: {
keyCombinations: [{ keyCode: keyboardKey.Escape }],
},
},
},
})

const isFocusable = propsData => {
try {
const { as, href, type } = propsData
return (
type === 'button' ||
type === 'input' ||
(type === 'a' && href !== undefined) ||
as === 'button'
)
} catch {
return false
}
}

const getAriaAttributeFromProps = (attributeName: string, props: any, defaultValue: string) => {
if (!props.trigger) return undefined
if (props.trigger.props[attributeName]) {
return props.trigger.props[attributeName]
}
const { as, href } = props.trigger.props
const { type } = props.trigger
if (isFocusable({ as, href, type })) {
return undefined
}
return defaultValue
}

export default PopupBehavior
1 change: 1 addition & 0 deletions src/lib/accessibility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export { default as ToolbarBehavior } from './Behaviors/Toolbar/ToolbarBehavior'
export { default as ToolbarButtonBehavior } from './Behaviors/Toolbar/ToolbarButtonBehavior'
export { default as RadioGroupBehavior } from './Behaviors/Radio/RadioGroupBehavior'
export { default as RadioGroupItemBehavior } from './Behaviors/Radio/RadioGroupItemBehavior'
export { default as PopupBehavior } from './Behaviors/Popup/PopupBehavior'
8 changes: 5 additions & 3 deletions src/lib/accessibility/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,11 @@ export type AccessibilityActionHandlers = {
}

export type ActionsKeyHandler = {
[partName: string]: {
onKeyDown?: KeyboardHandler
}
[partName: string]: OnKeyDownHandler
}

export type OnKeyDownHandler = {
onKeyDown?: KeyboardHandler
}

export type KeyboardHandler = (event: KeyboardEvent) => void
Expand Down