mdc-react
Version:
Material Components for the web implemented in React
277 lines (237 loc) • 8.99 kB
JSX
import { useRef, useState, useEffect, useCallback, Children, forwardRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { isUndefined, isArray } from '../types';
import { Clone, create, clone } from '../component';
import FloatingLabel from '../floating-label';
import Icon from '../icon';
import LineRipple from '../line-ripple';
import NotchedOutline from '../notched-outline';
import Menu, { MenuOrigin } from '../menu';
import { cssClasses } from './constants';
import DropdownIcon from './DropdownIcon';
import SelectOption from './SelectOption';
import HelperText from './HelperText';
const Select = forwardRef(({
name,
value,
options,
label,
icon,
leadingIcon = icon,
helperText,
filled = false,
outlined = false,
multiple = isArray(value),
disabled = false,
required = false,
onChange = Function.prototype,
menuProps = {},
listProps = {},
helperTextProps = {},
className,
children = options?.map(option => create(SelectOption, option)),
...props
}, ref) => {
const anchorRef = useRef();
const inputRef = useRef();
const menuRef = useRef();
const [activated, setActivated] = useState(false);
const [focused, setFocused] = useState(false);
const [touched, setTouched] = useState(false);
const [selectedText, setSelectedText] = useState();
const [interactionCoords, setInteractionCoords] = useState();
useEffect(() => {
if (multiple) {
const selectedOptions = (Children.toArray(children).map(option => option.props))
.filter(option => value.includes(option.value));
const selectedText = selectedOptions.map(option => option.text || option.children).join(', ');
setSelectedText(selectedText);
} else {
const selectedOption = (Children.toArray(children).map(option => option.props))
.find(option => option.value === value);
if (selectedOption) {
setSelectedText(selectedOption.text || selectedOption.children);
}
}
}, [value, multiple, children]);
const handleAnchorClick = useCallback(event => {
if (activated) {
setActivated(false);
setFocused(false);
} else {
const targetClientRect = event.target.getBoundingClientRect();
setInteractionCoords({
x: event.clientX - targetClientRect.left,
y: event.clientY - targetClientRect.top
});
setActivated(true);
setFocused(true);
}
}, [activated]);
const handleOptionInteraction = useCallback(event => {
if (
event.type === 'keydown' &&
event.key !== ' ' &&
event.key !== 'Enter'
) return;
const option = event.currentTarget.dataset;
if (option.disabled) return;
if (multiple) {
const values = new Set(value);
values.has(option.value) ?
values.delete(option.value) :
values.add(option.value);
event.target = { name, value: [...values] };
onChange(event, [...values]);
} else {
event.target = { name, value: option.value };
onChange(event, option.value);
setActivated(false);
setFocused(false);
}
}, [value, name, multiple, onChange]);
const handleMenuClose = useCallback(event => {
if (event.type === 'click' && event.target === anchorRef.current) return;
if (multiple && event.type === 'click' && event.path.includes(menuRef.current)) return;
setActivated(false);
setFocused(false);
setTouched(true);
}, [multiple]);
const handleKeyDown = useCallback(event => {
if (
event.key === ' ' ||
event.key === 'Enter' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowUp'
) {
event.preventDefault();
event.stopPropagation();
setActivated(true);
}
}, []);
const hasValue = isArray(value) ? value.length > 0 : Boolean(value);
const focusedOrHasValue = focused || hasValue;
const isInvalid = touched && required && !hasValue;
const classNames = classnames(cssClasses.ROOT, {
[cssClasses.FILLED]: filled,
[cssClasses.OUTLINED]: outlined,
[cssClasses.ACTIVATED]: activated,
[cssClasses.DISABLED]: disabled,
[cssClasses.FOCUSED]: focused,
[cssClasses.REQUIRED]: required,
[cssClasses.INVALID]: isInvalid,
[cssClasses.NO_LABEL]: !label,
[cssClasses.WITH_LEADING_ICON]: leadingIcon
}, className);
return (<>
<div ref={ref} className={classNames}>
{name &&
<input
ref={inputRef}
type="hidden"
name={name}
value={value}
required={required}
disabled={disabled}
{...props}
/>
}
<div
ref={anchorRef}
className={cssClasses.ANCHOR}
aria-required={required || undefined}
tabIndex={!disabled ? 0 : undefined}
onKeyDown={handleKeyDown}
onClick={handleAnchorClick}
>
{filled &&
<div className={cssClasses.RIPPLE} />
}
{filled && label &&
<FloatingLabel
label={label}
float={focusedOrHasValue}
/>
}
{outlined &&
<NotchedOutline notched={focusedOrHasValue}>
{label &&
<FloatingLabel
label={label}
float={focusedOrHasValue}
/>
}
</NotchedOutline>
}
{leadingIcon &&
<Clone
component={leadingIcon}
fallback={Icon}
className={cssClasses.ICON}
tabIndex="0"
role="button"
/>
}
<span className={cssClasses.SELECTED_TEXT_CONTAINER}>
<span className={cssClasses.SELECTED_TEXT}>{selectedText}</span>
</span>
<DropdownIcon />
{filled &&
<LineRipple
active={focused}
transformOrigin={interactionCoords?.x}
/>
}
</div>
<Menu
ref={menuRef}
anchorRef={anchorRef}
className={cssClasses.MENU}
open={activated}
anchorOrigin={MenuOrigin.BOTTOM_CENTER}
transformOrigin={MenuOrigin.TOP_CENTER}
fullWidth
listProps={listProps}
onClose={handleMenuClose}
{...menuProps}
>
{Children.map(children, option => {
const optionValue = option.props.value;
return clone(option, {
value: undefined,
'data-value': optionValue,
selected: !isUndefined(value) && (multiple ? value.includes(optionValue) : optionValue === value),
checkbox: multiple,
onClick: handleOptionInteraction,
onKeyDown: handleOptionInteraction
});
})}
</Menu>
</div>
{helperText &&
<HelperText {...helperTextProps}>{helperText}</HelperText>
}
</>);
});
Select.displayName = 'MDCSelect';
Select.propTypes = {
value: PropTypes.any,
options: PropTypes.arrayOf(PropTypes.object),
label: PropTypes.string,
leadingIcon: PropTypes.element,
helperText: PropTypes.string,
filled: PropTypes.bool,
outlined: PropTypes.bool,
multiple: PropTypes.bool,
disabled: PropTypes.bool,
required: PropTypes.bool,
children: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
menuProps: PropTypes.object,
helperTextProps: PropTypes.object,
onChange: PropTypes.func
};
export default Select;