-
Notifications
You must be signed in to change notification settings - Fork 1.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(components): support automatic focus sentinel #5260
Changes from 5 commits
1bbe349
b09d3d8
c0cf32d
bf7bdc7
fb8d81d
00d0804
ca1e9c3
3ccfd96
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,10 +10,13 @@ import React, { Component } from 'react'; | |
import classNames from 'classnames'; | ||
import { settings } from 'carbon-components'; | ||
import { Close20 } from '@carbon/icons-react'; | ||
import FocusTrap from 'focus-trap-react'; | ||
import toggleClass from '../../tools/toggleClass'; | ||
import Button from '../Button'; | ||
import deprecate from '../../prop-types/deprecate'; | ||
import requiredIfGivenPropExists from '../../prop-types/requiredIfGivenPropExists'; | ||
import wrapFocus, { | ||
elementOrParentIsFloatingMenu, | ||
} from '../../internal/wrapFocus'; | ||
import setupGetInstanceId from '../../tools/setupGetInstanceId'; | ||
|
||
const { prefix } = settings; | ||
|
@@ -138,10 +141,13 @@ export default class Modal extends Component { | |
size: PropTypes.oneOf(['xs', 'sm', 'lg']), | ||
|
||
/** | ||
* Specify whether the modal should use 3rd party `focus-trap-react` for the focus-wrap feature. | ||
* NOTE: by default this is true. | ||
* Deprecated; Used for advanced focus-wrapping feature using 3rd party library, | ||
* but it's now achieved without a 3rd party library. | ||
*/ | ||
focusTrap: PropTypes.bool, | ||
focusTrap: deprecate( | ||
PropTypes.bool, | ||
`\nThe prop \`focusTrap\` for Modal has been deprecated, as the feature of \`persistent\` runs by default.` | ||
), | ||
|
||
/** | ||
* Specify whether the modal contains scrolling content | ||
|
@@ -167,30 +173,18 @@ export default class Modal extends Component { | |
modalHeading: '', | ||
modalLabel: '', | ||
selectorPrimaryFocus: '[data-modal-primary-focus]', | ||
focusTrap: true, | ||
hasScrollingContent: false, | ||
}; | ||
|
||
button = React.createRef(); | ||
outerModal = React.createRef(); | ||
innerModal = React.createRef(); | ||
startSentinel = React.createRef(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a clearer plain-language way we can name this as opposed to "sentinel". It's difficult jargon to even Google a simple definition of. I assume you're meaning like a sentinel node, but there's probably a friendlier way to describe that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @dakahn Hope this link shed some light: https://developers.google.com/web/fundamentals/accessibility/focus/using-tabindex There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I understand the concept of sentinel elements in a focus-trap, but my question was if we could name these in plainer and simpler to understand language? If not that's cool too, but maybe a quick comment explaining the concept of sentinels to somebody who -- like myself -- has implemented wrapping focus but was totally unfamiliar with this term.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe "start trap" and "end trap"? |
||
endSentinel = React.createRef(); | ||
modalInstanceId = `modal-${getInstanceId()}`; | ||
modalLabelId = `${prefix}--modal-header__label--${this.modalInstanceId}`; | ||
modalHeadingId = `${prefix}--modal-header__heading--${this.modalInstanceId}`; | ||
|
||
elementOrParentIsFloatingMenu = target => { | ||
const { | ||
selectorsFloatingMenus = [ | ||
`.${prefix}--overflow-menu-options`, | ||
`.${prefix}--tooltip`, | ||
'.flatpickr-calendar', | ||
], | ||
} = this.props; | ||
if (target && typeof target.closest === 'function') { | ||
return selectorsFloatingMenus.some(selector => target.closest(selector)); | ||
} | ||
}; | ||
|
||
handleKeyDown = evt => { | ||
if (this.props.open) { | ||
if (evt.which === 27) { | ||
|
@@ -206,28 +200,32 @@ export default class Modal extends Component { | |
if ( | ||
this.innerModal.current && | ||
!this.innerModal.current.contains(evt.target) && | ||
!this.elementOrParentIsFloatingMenu(evt.target) | ||
!elementOrParentIsFloatingMenu( | ||
evt.target, | ||
this.props.selectorsFloatingMenus | ||
) | ||
) { | ||
this.props.onRequestClose(evt); | ||
} | ||
}; | ||
|
||
focusModal = () => { | ||
if (this.outerModal.current) { | ||
this.outerModal.current.focus(); | ||
} | ||
}; | ||
|
||
handleBlur = evt => { | ||
// Keyboard trap | ||
if ( | ||
this.innerModal.current && | ||
this.props.open && | ||
evt.relatedTarget && | ||
!this.innerModal.current.contains(evt.relatedTarget) && | ||
!this.elementOrParentIsFloatingMenu(evt.relatedTarget) | ||
) { | ||
this.focusModal(); | ||
handleBlur = ({ | ||
target: oldActiveNode, | ||
relatedTarget: currentActiveNode, | ||
}) => { | ||
const { open, selectorsFloatingMenus } = this.props; | ||
if (open && currentActiveNode && oldActiveNode) { | ||
const { current: modalNode } = this.innerModal; | ||
const { current: startSentinelNode } = this.startSentinel; | ||
const { current: endSentinelNode } = this.endSentinel; | ||
wrapFocus({ | ||
modalNode, | ||
startSentinelNode, | ||
endSentinelNode, | ||
currentActiveNode, | ||
oldActiveNode, | ||
selectorsFloatingMenus, | ||
}); | ||
} | ||
}; | ||
|
||
|
@@ -277,9 +275,7 @@ export default class Modal extends Component { | |
if (!this.props.open) { | ||
return; | ||
} | ||
if (!this.props.focusTrap) { | ||
this.focusButton(this.innerModal.current); | ||
} | ||
this.focusButton(this.innerModal.current); | ||
} | ||
|
||
handleTransitionEnd = evt => { | ||
|
@@ -290,9 +286,7 @@ export default class Modal extends Component { | |
this.outerModal.current.offsetHeight && | ||
this.beingOpen | ||
) { | ||
if (!this.props.focusTrap) { | ||
this.focusButton(evt.currentTarget); | ||
} | ||
this.focusButton(evt.currentTarget); | ||
this.beingOpen = false; | ||
} | ||
}; | ||
|
@@ -317,7 +311,6 @@ export default class Modal extends Component { | |
selectorsFloatingMenus, // eslint-disable-line | ||
shouldSubmitOnEnter, // eslint-disable-line | ||
size, | ||
focusTrap, | ||
hasScrollingContent, | ||
...other | ||
} = this.props; | ||
|
@@ -379,7 +372,8 @@ export default class Modal extends Component { | |
role="dialog" | ||
className={containerClasses} | ||
aria-label={ariaLabel} | ||
aria-modal="true"> | ||
aria-modal="true" | ||
tabIndex="-1"> | ||
<div className={`${prefix}--modal-header`}> | ||
{passiveModal && modalButton} | ||
{modalLabel && ( | ||
|
@@ -422,30 +416,34 @@ export default class Modal extends Component { | |
</div> | ||
); | ||
|
||
const modal = ( | ||
return ( | ||
<div | ||
{...other} | ||
onKeyDown={this.handleKeyDown} | ||
onMouseDown={this.handleMousedown} | ||
onBlur={this.handleBlur} | ||
className={modalClasses} | ||
role="presentation" | ||
tabIndex={-1} | ||
onTransitionEnd={this.props.open ? this.handleTransitionEnd : undefined} | ||
ref={this.outerModal}> | ||
{/* Non-translatable: Focus-wrap code makes this `<span>` not actually read by screen readers */} | ||
<span | ||
ref={this.startSentinel} | ||
tabIndex="0" | ||
role="link" | ||
className={`${prefix}--visually-hidden`}> | ||
Focus sentinel | ||
</span> | ||
{modalBody} | ||
{/* Non-translatable: Focus-wrap code makes this `<span>` not actually read by screen readers */} | ||
<span | ||
ref={this.endSentinel} | ||
tabIndex="0" | ||
role="link" | ||
className={`${prefix}--visually-hidden`}> | ||
Focus sentinel | ||
</span> | ||
</div> | ||
); | ||
|
||
return !focusTrap ? ( | ||
modal | ||
) : ( | ||
// `<FocusTrap>` has `active: true` in its `defaultProps` | ||
<FocusTrap | ||
active={!!open} | ||
focusTrapOptions={{ initialFocus: this.initialFocus }}> | ||
{modal} | ||
</FocusTrap> | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we're deprecating this prop, are we not going to give the user the ability to choose whether or not they want the modal focus-wrapped?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for asking @abbeyhrt - Focus-wrapping behavior runs regardless of
focusTrap
property, even before it was introduced or after it was introduced. Some time ago the codebase introduced a third-partyfocus-trap-react
library for better focus-wrapping behavior, but it introduced lots of side effects as the PR description of this PR links to.focusTrap
property was introduced to disable such third-party library to avoid the side effects. This PR removes the property because the third-party is no longer needed for focus-wrapping.