@activelylearn/material-ui
Version:
Material-UI's workspace package
261 lines (245 loc) • 7.42 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import ReactDOM from 'react-dom';
import keycode from 'keycode';
import { withStyles } from '@material-ui/core/styles';
import Zoom from '@material-ui/core/Zoom';
import { duration } from '@material-ui/core/styles/transitions';
import Button from '@material-ui/core/Button';
import { isMuiElement } from '@material-ui/core/utils/reactHelpers';
const styles = theme => ({
root: {
zIndex: 1050,
display: 'flex',
flexDirection: 'column-reverse', // Place the Actions above the FAB.
},
actions: {
display: 'flex',
flexDirection: 'column-reverse', // Display the first action at the bottom.
marginBottom: theme.spacing.unit * 2,
},
actionsClosed: {
transition: 'top 0s linear 0.2s',
},
});
class SpeedDial extends React.Component {
state = {
nextKey: null,
prevKey: null,
};
handleKeyDown = event => {
const actions = ReactDOM.findDOMNode(this.actions);
const fab = ReactDOM.findDOMNode(this.fab);
const key = keycode(event);
const currentFocus = document.activeElement;
const { open, onClose, onKeyDown } = this.props;
const { nextKey, prevKey } = this.state;
const firstKeyPress = (key === 'up' || key === 'down') && nextKey == null;
if (key === 'up' || key === 'down') {
event.preventDefault();
}
// If not actions, SpeedDial must be focused, so focus the first action.
if (currentFocus.parentElement.parentElement !== actions) {
if (open && (firstKeyPress || key === nextKey)) {
actions.firstChild.firstChild.focus();
// This determines which key focuses the next / previous action.
// For example, if a visually impaired user presses down to select the first action
// (i.e. following DOM ordering), down will select the next action, and up the previous.
if (nextKey == null) {
this.setState({ nextKey: key });
this.setState({ prevKey: key === 'up' ? 'down' : 'up' });
}
}
// Select the previous action or SpeedDial
} else if (key === prevKey) {
event.preventDefault();
if (currentFocus.parentElement.previousElementSibling) {
currentFocus.parentElement.previousElementSibling.firstChild.focus();
} else {
ReactDOM.findDOMNode(this.fab).focus();
}
// Select the next action
} else if (key === nextKey) {
event.preventDefault();
if (currentFocus.parentElement.nextElementSibling) {
currentFocus.parentElement.nextElementSibling.firstChild.focus();
}
// Close the SpeedDial
} else if (key === 'esc') {
fab.focus();
if (onClose) {
onClose(event, key);
}
}
// Forward the event
if (onKeyDown) {
onKeyDown(event, key);
}
};
render() {
const {
ariaLabel,
ButtonProps,
children: childrenProp,
classes,
className: classNameProp,
hidden,
icon: iconProp,
onClick,
onClose,
onKeyDown,
open,
openIcon,
TransitionComponent,
transitionDuration,
TransitionProps,
...other
} = this.props;
// Filter the label for valid id characters.
const id = ariaLabel.replace(/^[^a-z]+|[^\w:.-]+/gi, '');
let totalValidChildren = 0;
React.Children.forEach(childrenProp, child => {
if (React.isValidElement(child)) totalValidChildren += 1;
});
let validChildCount = 0;
const children = React.Children.map(childrenProp, child => {
if (!React.isValidElement(child)) return null;
const delay = 30 * (open ? validChildCount : totalValidChildren - validChildCount);
validChildCount += 1;
return React.cloneElement(child, {
delay,
open,
onKeyDown: this.handleKeyDown,
id: `${id}-item-${validChildCount}`,
});
});
const icon = () => {
if (!React.isValidElement(iconProp)) {
return iconProp;
}
if (isMuiElement(iconProp, ['SpeedDialIcon'])) {
return React.cloneElement(iconProp, { open });
}
return icon;
};
return (
<div className={classNames(classes.root, classNameProp)} {...other}>
<TransitionComponent
in={!hidden}
timeout={transitionDuration}
unmountOnExit
{...TransitionProps}
>
<Button
variant="fab"
color="primary"
onClick={onClick}
onKeyDown={this.handleKeyDown}
aria-label={ariaLabel}
aria-haspopup="true"
aria-expanded={open ? 'true' : 'false'}
aria-controls={`${id}-actions`}
ref={node => {
this.fab = node;
}}
data-mui-test="SpeedDial"
{...ButtonProps}
>
{icon()}
</Button>
</TransitionComponent>
<div
id={`${id}-actions`}
className={classNames(classes.actions, { [classes.actionsClosed]: !open })}
ref={node => {
this.actions = node;
}}
>
{children}
</div>
</div>
);
}
}
SpeedDial.propTypes = {
/**
* The aria-label of the `Button` element.
* Also used to provide the `id` for the `SpeedDial` element and its children.
*/
ariaLabel: PropTypes.string.isRequired,
/**
* Properties applied to the `Button` element.
*/
ButtonProps: PropTypes.object,
/**
* SpeedDialActions to display when the SpeedDial is `open`.
*/
children: PropTypes.node.isRequired,
/**
* Override or extend the styles applied to the component.
* See [CSS API](#css-api) below for more details.
*/
classes: PropTypes.object.isRequired,
/**
* @ignore
*/
className: PropTypes.string,
/**
* If `true`, the SpeedDial will be hidden.
*/
hidden: PropTypes.bool,
/**
* The icon to display in the SpeedDial Floating Action Button. The `SpeedDialIcon` component
* provides a default Icon with animation.
*/
icon: PropTypes.element.isRequired,
/**
* @ignore
*/
onClick: PropTypes.func,
/**
* Callback fired when the component requests to be closed.
*
* @param {object} event The event source of the callback
* @param {string} key The key pressed
*/
onClose: PropTypes.func,
/**
* @ignore
*/
onKeyDown: PropTypes.func,
/**
* If `true`, the SpeedDial is open.
*/
open: PropTypes.bool.isRequired,
/**
* The icon to display in the SpeedDial Floating Action Button when the SpeedDial is open.
*/
openIcon: PropTypes.node,
/**
* Transition component.
*/
TransitionComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
/**
* The duration for the transition, in milliseconds.
* You may specify a single timeout for all transitions, or individually with an object.
*/
transitionDuration: PropTypes.oneOfType([
PropTypes.number,
PropTypes.shape({ enter: PropTypes.number, exit: PropTypes.number }),
]),
/**
* Properties applied to the `Transition` element.
*/
TransitionProps: PropTypes.object,
};
SpeedDial.defaultProps = {
hidden: false,
TransitionComponent: Zoom,
transitionDuration: {
enter: duration.enteringScreen,
exit: duration.leavingScreen,
},
};
export default withStyles(styles)(SpeedDial);