/* * The MIT License (MIT) * * Copyright (c) 2015 - present Instructure, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { Fragment, Component } from 'react' import ReactDOM from 'react-dom' import keycode from 'keycode' import { callRenderProp, withDeterministicId, passthroughProps } from '@instructure/ui-react-utils' import { CloseButton } from '@instructure/ui-buttons/latest' import { View } from '@instructure/ui-view/latest' import type { ViewOwnProps } from '@instructure/ui-view/latest' import { ScreenReaderContent } from '@instructure/ui-a11y-content' import { InfoInstUIIcon, XCircleInstUIIcon, CircleCheckInstUIIcon, TriangleAlertInstUIIcon } from '@instructure/ui-icons' import { Transition } from '@instructure/ui-motion' import { logError as error } from '@instructure/console' import { withStyle } from '@instructure/emotion' import generateStyle from './styles' import { allowedProps } from './props' import type { AlertProps, AlertState } from './props' /** --- category: components --- **/ @withDeterministicId() @withStyle(generateStyle) class Alert extends Component { static readonly componentId = 'Alert' static allowedProps = allowedProps static defaultProps = { variant: 'info', margin: 'x-small 0', timeout: 0, transition: 'fade', open: true, screenReaderOnly: false, liveRegionPoliteness: 'assertive', isLiveRegionAtomic: false, children: null, hasShadow: true } constructor(props: AlertProps) { super(props) this.srid = this.props.deterministicId!() this.state = { open: true } } _timeouts: ReturnType[] = [] srid: string variantUI = { error: XCircleInstUIIcon, info: InfoInstUIIcon, success: CircleCheckInstUIIcon, warning: TriangleAlertInstUIIcon } ref: Element | null = null handleRef = (el: Element | null) => { this.ref = el } handleTimeout = () => { if (this.props.timeout! > 0) { this._timeouts.push( setTimeout(() => { this.close() }, this.props.timeout) ) } } clearTimeouts() { this._timeouts.forEach((timeout) => clearTimeout(timeout)) this._timeouts = [] } onExitTransition = () => { if (this.props.onDismiss) { this.props.onDismiss() } } close = () => { this.clearTimeouts() this.removeScreenreaderAlert() this.setState({ open: false }, () => { if ( this.props.onDismiss && (this.props.transition === 'none' || this.props.screenReaderOnly) ) { this.props.onDismiss() } }) } // duck type for a dom node isDOMNode(n: Element | null | undefined) { return n && typeof n === 'object' && n.nodeType === 1 } getLiveRegion() { const lr = typeof this.props.liveRegion === 'function' ? this.props.liveRegion() : this.props.liveRegion return this.isDOMNode(lr) ? lr : null } initLiveRegion(liveRegion: Element) { error( liveRegion.getAttribute('role') === 'alert', `[Alert] live region must have role='alert' set on page load in order to announce content` ) if (liveRegion) { liveRegion.setAttribute('aria-live', this.props.liveRegionPoliteness!) // indicates what notifications the user agent will trigger when the // accessibility tree within a live region is modified. // additions: elements are added, text: Text content is added liveRegion.setAttribute('aria-relevant', 'additions text') liveRegion.setAttribute( 'aria-atomic', `${this.props.isLiveRegionAtomic!}` ) } } createScreenreaderContentNode() { return ( {this.props.variantScreenReaderLabel || ''} {this.props.children} ) } createScreenreaderAlert() { const liveRegion = this.getLiveRegion() if (liveRegion) { const div = document.createElement('div') div.setAttribute('id', this.srid) liveRegion.appendChild(div) } } removeScreenreaderAlert() { const liveRegion = this.getLiveRegion() if (liveRegion) { const div = document.getElementById(this.srid!) if (div) { // Accessibility attributes must be removed for the deletion of the node // and then reapplied because JAWS/IE will not respect the // "aria-relevant" attribute and read when the node is deleted if // the attributes are in place liveRegion.removeAttribute('aria-live') liveRegion.removeAttribute('aria-relevant') liveRegion.removeAttribute('aria-atomic') this.initLiveRegion(liveRegion) } } } handleKeyUp = (event: React.KeyboardEvent) => { if ( this.props.renderCloseButtonLabel && event.keyCode === keycode.codes.esc ) { this.close() } } componentDidMount() { this.props.makeStyles?.() const liveRegion = this.getLiveRegion() if (liveRegion) { this.initLiveRegion(liveRegion) } this.handleTimeout() } componentWillUnmount() { this.clearTimeouts() } componentDidUpdate(prevProps: AlertProps) { this.props.makeStyles?.() if (!!this.props.open === false && !!this.props.open !== !!prevProps.open) { // this outside world is asking us to close the alert, which needs to // take place internally so the transition runs this.close() } } renderIcon() { const { renderCustomIcon, variant, styles } = this.props const Icon = this.variantUI[variant!] return (
{renderCustomIcon ? callRenderProp(renderCustomIcon) : }
) } renderCloseButton() { const closeButtonLabel = this.props.renderCloseButtonLabel && callRenderProp(this.props.renderCloseButtonLabel) return closeButtonLabel ? (
) : null } renderAlert() { // prevent onDismiss from being passed to the View component // eslint-disable-next-line @typescript-eslint/no-unused-vars const { margin, styles, children, onDismiss, variantScreenReaderLabel, ...props } = this.props return ( {this.renderIcon()}
{variantScreenReaderLabel && ( {variantScreenReaderLabel} )} {children}
{this.renderCloseButton()}
) } createScreenReaderPortal(liveRegion: Element) { const { open } = this.state return open ? ReactDOM.createPortal(
{this.createScreenreaderContentNode()}
, liveRegion ) : null } render() { const liveRegion = this.getLiveRegion() const screenReaderContent = liveRegion ? this.createScreenReaderPortal(liveRegion) : null // Don't render anything if screen reader only if (this.props.screenReaderOnly) { error( !!this.getLiveRegion(), `[Alert] The 'screenReaderOnly' prop must be used in conjunction with 'liveRegion'.` ) return screenReaderContent } if (this.props.transition === 'none') { return this.state.open ? ( {screenReaderContent} {this.renderAlert()} ) : null } return ( {screenReaderContent} {this.renderAlert()} ) } } export default Alert export { Alert }