materialuiupgraded
Version:
Material-UI's workspace package
372 lines (339 loc) • 10.9 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import keycode from 'keycode';
import warning from 'warning';
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, setRef } from '@material-ui/core/utils/reactHelpers';
import * as utils from './utils';
import clamp from '../utils/clamp';
const dialRadius = 32;
const spacingActions = 16;
export const styles = {
/* Styles applied to the root element. */
root: {
zIndex: 1050,
display: 'flex',
pointerEvents: 'none',
},
/* Styles applied to the Button component. */
fab: {
pointerEvents: 'auto',
},
/* Styles applied to the root and action container elements when direction="up" */
directionUp: {
flexDirection: 'column-reverse',
},
/* Styles applied to the root and action container elements when direction="down" */
directionDown: {
flexDirection: 'column',
},
/* Styles applied to the root and action container elements when direction="left" */
directionLeft: {
flexDirection: 'row-reverse',
},
/* Styles applied to the root and action container elements when direction="right" */
directionRight: {
flexDirection: 'row',
},
/* Styles applied to the actions (`children` wrapper) element. */
actions: {
display: 'flex',
pointerEvents: 'auto',
'&$directionUp': {
marginBottom: -dialRadius,
paddingBottom: spacingActions + dialRadius,
},
'&$directionRight': {
marginLeft: -dialRadius,
paddingLeft: spacingActions + dialRadius,
},
'&$directionDown': {
marginTop: -dialRadius,
paddingTop: spacingActions + dialRadius,
},
'&$directionLeft': {
marginRight: -dialRadius,
paddingRight: spacingActions + dialRadius,
},
},
/* Styles applied to the actions (`children` wrapper) element if `open={false}`. */
actionsClosed: {
transition: 'top 0s linear 0.2s',
pointerEvents: 'none',
},
};
class SpeedDial extends React.Component {
static initialNavigationState = {
/**
* an index in this.actions
*/
focusedAction: 0,
/**
* pressing this key while the focus is on a child SpeedDialAction focuses
* the next SpeedDialAction.
* It is equal to the first arrow key pressed while focus is on the SpeedDial
* that is not orthogonal to the direction.
* @type {utils.ArrowKey?}
*/
nextItemArrowKey: undefined,
};
static getDerivedStateFromProps(props, state) {
// actions were closed while navigation state was not reset
if (!props.open && state.nextItemArrowKey !== undefined) {
return SpeedDial.initialNavigationState;
}
return null;
}
/**
* refs to the Button that have an action associated to them in this SpeedDial
* [FAB, ...(SpeeDialActions > Button)]
* @type {HTMLButtonElement[]}
*/
actions = [];
state = SpeedDial.initialNavigationState;
handleKeyboardNavigation = event => {
const key = keycode(event);
const { direction, onKeyDown } = this.props;
const { focusedAction, nextItemArrowKey = key } = this.state;
if (key === 'esc') {
this.closeActions(event, key);
} else if (utils.sameOrientation(key, direction)) {
event.preventDefault();
const actionStep = key === nextItemArrowKey ? 1 : -1;
// stay within array indices
const nextAction = clamp(focusedAction + actionStep, 0, this.actions.length - 1);
const nextActionRef = this.actions[nextAction];
nextActionRef.focus();
this.setState({ focusedAction: nextAction, nextItemArrowKey });
}
if (onKeyDown) {
onKeyDown(event, key);
}
};
/**
* creates a ref callback for the Button in a SpeedDialAction
* Is called before the original ref callback for Button that was set in buttonProps
*
* @param dialActionIndex {number}
* @param origButtonRef {React.RefObject?}
*/
createHandleSpeedDialActionButtonRef(dialActionIndex, origButtonRef) {
return ref => {
this.actions[dialActionIndex + 1] = ref;
if (origButtonRef) {
origButtonRef(ref);
}
};
}
closeActions(event, key) {
const { onClose } = this.props;
this.actions[0].focus();
this.setState(SpeedDial.initialNavigationState);
if (onClose) {
onClose(event, key);
}
}
render() {
const {
ariaLabel,
ButtonProps: { buttonRef: origDialButtonRef, ...ButtonProps } = {},
children: childrenProp,
classes,
className: classNameProp,
hidden,
icon: iconProp,
onClick,
onClose,
onKeyDown,
open,
direction,
openIcon,
TransitionComponent,
transitionDuration,
TransitionProps,
...other
} = this.props;
// Filter the label for valid id characters.
const id = ariaLabel.replace(/^[^a-z]+|[^\w:.-]+/gi, '');
const orientation = utils.getOrientation(direction);
let totalValidChildren = 0;
React.Children.forEach(childrenProp, child => {
if (React.isValidElement(child)) totalValidChildren += 1;
});
this.actions = [];
let validChildCount = 0;
const children = React.Children.map(childrenProp, child => {
if (!React.isValidElement(child)) {
return null;
}
warning(
child.type !== React.Fragment,
[
"Material-UI: the SpeedDial component doesn't accept a Fragment as a child.",
'Consider providing an array instead.',
].join('\n'),
);
const delay = 30 * (open ? validChildCount : totalValidChildren - validChildCount);
validChildCount += 1;
const { ButtonProps: { buttonRef: origButtonRef, ...ChildButtonProps } = {} } = child.props;
const NewChildButtonProps = {
...ChildButtonProps,
buttonRef: this.createHandleSpeedDialActionButtonRef(validChildCount - 1, origButtonRef),
};
return React.cloneElement(child, {
ButtonProps: NewChildButtonProps,
delay,
onKeyDown: this.handleKeyboardNavigation,
open,
id: `${id}-item-${validChildCount}`,
});
});
const icon = () => {
if (React.isValidElement(iconProp) && isMuiElement(iconProp, ['SpeedDialIcon'])) {
return React.cloneElement(iconProp, { open });
}
return iconProp;
};
const actionsPlacementClass = {
[classes.directionUp]: direction === 'up',
[classes.directionDown]: direction === 'down',
[classes.directionLeft]: direction === 'left',
[classes.directionRight]: direction === 'right',
};
let clickProp = { onClick };
if (typeof document !== 'undefined' && 'ontouchstart' in document.documentElement) {
clickProp = { onTouchEnd: onClick };
}
return (
<div className={classNames(classes.root, actionsPlacementClass, classNameProp)} {...other}>
<TransitionComponent
in={!hidden}
timeout={transitionDuration}
unmountOnExit
{...TransitionProps}
>
<Button
variant="fab"
color="primary"
onKeyDown={this.handleKeyboardNavigation}
aria-label={ariaLabel}
aria-haspopup="true"
aria-expanded={open ? 'true' : 'false'}
aria-controls={`${id}-actions`}
className={classes.fab}
{...clickProp}
{...ButtonProps}
buttonRef={ref => {
this.actions[0] = ref;
setRef(origDialButtonRef, ref);
}}
>
{icon()}
</Button>
</TransitionComponent>
<div
id={`${id}-actions`}
role="menu"
aria-orientation={orientation}
className={classNames(
classes.actions,
{ [classes.actionsClosed]: !open },
actionsPlacementClass,
)}
>
{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`](/api/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,
/**
* The direction the actions open relative to the floating action button.
*/
direction: PropTypes.oneOf(['up', 'down', 'left', 'right']),
/**
* 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,
direction: 'up',
TransitionComponent: Zoom,
transitionDuration: {
enter: duration.enteringScreen,
exit: duration.leavingScreen,
},
};
export default withStyles(styles, { name: 'MuiSpeedDial' })(SpeedDial);