Skip to content

Common dropdown component for tables #2379

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

Merged
merged 13 commits into from
Nov 30, 2023
Merged
3 changes: 2 additions & 1 deletion client/components/Dropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import styled from 'styled-components';
import { remSize, prop } from '../theme';
import IconButton from '../common/IconButton';

const DropdownWrapper = styled.ul`
export const DropdownWrapper = styled.ul`
background-color: ${prop('Modal.background')};
border: 1px solid ${prop('Modal.border')};
box-shadow: 0 0 18px 0 ${prop('shadowColor')};
Expand Down Expand Up @@ -52,6 +52,7 @@ const DropdownWrapper = styled.ul`
& button span,
& a {
padding: ${remSize(8)} ${remSize(16)};
font-size: ${remSize(12)};
}

* {
Expand Down
96 changes: 96 additions & 0 deletions client/components/Dropdown/DropdownMenu.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import PropTypes from 'prop-types';
import React, { forwardRef, useCallback, useRef, useState } from 'react';
import useModalClose from '../../common/useModalClose';
import DownArrowIcon from '../../images/down-filled-triangle.svg';
import { DropdownWrapper } from '../Dropdown';

// TODO: enable arrow keys to navigate options from list

const DropdownMenu = forwardRef(
(
{ children, anchor, 'aria-label': ariaLabel, align, className, classes },
ref
) => {
// Note: need to use a ref instead of a state to avoid stale closures.
const focusedRef = useRef(false);

const [isOpen, setIsOpen] = useState(false);

const close = useCallback(() => setIsOpen(false), [setIsOpen]);

const anchorRef = useModalClose(close, ref);

const toggle = useCallback(() => {
setIsOpen((prevState) => !prevState);
}, [setIsOpen]);

const handleFocus = () => {
focusedRef.current = true;
};

const handleBlur = () => {
focusedRef.current = false;
setTimeout(() => {
if (!focusedRef.current) {
close();
}
}, 200);
};

return (
<div ref={anchorRef} className={className}>
<button
className={classes.button}
aria-label={ariaLabel}
tabIndex="0"
onClick={toggle}
onBlur={handleBlur}
onFocus={handleFocus}
>
{anchor ?? <DownArrowIcon focusable="false" aria-hidden="true" />}
</button>
{isOpen && (
<DropdownWrapper
className={classes.list}
align={align}
onMouseUp={() => {
setTimeout(close, 0);
}}
onBlur={handleBlur}
onFocus={handleFocus}
>
{children}
</DropdownWrapper>
)}
</div>
);
}
);

DropdownMenu.propTypes = {
/**
* Provide <MenuItem> elements as children to control the contents of the menu.
*/
children: PropTypes.node.isRequired,
/**
* Can optionally override the contents of the button which opens the menu.
* Defaults to <DownArrowIcon>
*/
anchor: PropTypes.node,
'aria-label': PropTypes.string.isRequired,
align: PropTypes.oneOf(['left', 'right']),
className: PropTypes.string,
classes: PropTypes.shape({
button: PropTypes.string,
list: PropTypes.string
})
};

DropdownMenu.defaultProps = {
anchor: null,
align: 'right',
className: '',
classes: {}
};

export default DropdownMenu;
35 changes: 35 additions & 0 deletions client/components/Dropdown/MenuItem.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import PropTypes from 'prop-types';
import React from 'react';
import ButtonOrLink from '../../common/ButtonOrLink';

// TODO: combine with NavMenuItem

function MenuItem({ hideIf, ...rest }) {
if (hideIf) {
return null;
}

return (
<li>
<ButtonOrLink {...rest} />
</li>
);
}

MenuItem.propTypes = {
...ButtonOrLink.propTypes,
onClick: PropTypes.func,
value: PropTypes.string,
/**
* Provides a way to deal with optional items.
*/
hideIf: PropTypes.bool
};

MenuItem.defaultProps = {
onClick: null,
value: null,
hideIf: false
};

export default MenuItem;
48 changes: 48 additions & 0 deletions client/components/Dropdown/TableDropdown.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';
import { useMediaQuery } from 'react-responsive';
import styled from 'styled-components';
import { prop, remSize } from '../../theme';
import DropdownMenu from './DropdownMenu';

import DownFilledTriangleIcon from '../../images/down-filled-triangle.svg';
import MoreIconSvg from '../../images/more.svg';

const DotsHorizontal = styled(MoreIconSvg)`
transform: rotate(90deg);
`;

const TableDropdownIcon = () => {
// TODO: centralize breakpoints
const isMobile = useMediaQuery({ maxWidth: 770 });

return isMobile ? (
<DotsHorizontal focusable="false" aria-hidden="true" />
) : (
<DownFilledTriangleIcon focusable="false" aria-hidden="true" />
);
};

const TableDropdown = styled(DropdownMenu).attrs({
align: 'right',
anchor: <TableDropdownIcon />
})`
& > button {
width: ${remSize(25)};
height: ${remSize(25)};
padding: 0;
& svg {
max-width: 100%;
max-height: 100%;
}
& polygon,
& path {
fill: ${prop('inactiveTextColor')};
}
}
& ul {
top: 63%;
right: calc(100% - 26px);
}
`;

export default TableDropdown;
162 changes: 49 additions & 113 deletions client/modules/IDE/components/AssetList.jsx
Original file line number Diff line number Diff line change
@@ -1,130 +1,68 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { connect, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { Link } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import prettyBytes from 'pretty-bytes';
import { withTranslation } from 'react-i18next';
import { useTranslation, withTranslation } from 'react-i18next';
import MenuItem from '../../../components/Dropdown/MenuItem';
import TableDropdown from '../../../components/Dropdown/TableDropdown';

import Loader from '../../App/components/loader';
import { deleteAssetRequest } from '../actions/assets';
import * as AssetActions from '../actions/assets';
import DownFilledTriangleIcon from '../../../images/down-filled-triangle.svg';

class AssetListRowBase extends React.Component {
constructor(props) {
super(props);
this.state = {
isFocused: false,
optionsOpen: false
};
}

onFocusComponent = () => {
this.setState({ isFocused: true });
};

onBlurComponent = () => {
this.setState({ isFocused: false });
setTimeout(() => {
if (!this.state.isFocused) {
this.closeOptions();
}
}, 200);
};

openOptions = () => {
this.setState({
optionsOpen: true
});
};
const AssetMenu = ({ item: asset }) => {
const { t } = useTranslation();

closeOptions = () => {
this.setState({
optionsOpen: false
});
};
const dispatch = useDispatch();

toggleOptions = () => {
if (this.state.optionsOpen) {
this.closeOptions();
} else {
this.openOptions();
const handleAssetDelete = () => {
const { key, name } = asset;
if (window.confirm(t('Common.DeleteConfirmation', { name }))) {
dispatch(deleteAssetRequest(key));
}
};

handleDropdownOpen = () => {
this.closeOptions();
this.openOptions();
};
return (
<TableDropdown aria-label={t('AssetList.ToggleOpenCloseARIA')}>
<MenuItem onClick={handleAssetDelete}>{t('AssetList.Delete')}</MenuItem>
<MenuItem href={asset.url} target="_blank">
{t('AssetList.OpenNewTab')}
</MenuItem>
</TableDropdown>
);
};

handleAssetDelete = () => {
const { key, name } = this.props.asset;
this.closeOptions();
if (window.confirm(this.props.t('Common.DeleteConfirmation', { name }))) {
this.props.deleteAssetRequest(key);
}
};
AssetMenu.propTypes = {
item: PropTypes.shape({
key: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired
};

render() {
const { asset, username, t } = this.props;
const { optionsOpen } = this.state;
return (
<tr className="asset-table__row" key={asset.key}>
<th scope="row">
<Link to={asset.url} target="_blank">
{asset.name}
</Link>
</th>
<td>{prettyBytes(asset.size)}</td>
<td>
{asset.sketchId && (
<Link to={`/${username}/sketches/${asset.sketchId}`}>
{asset.sketchName}
</Link>
)}
</td>
<td className="asset-table__dropdown-column">
<button
className="asset-table__dropdown-button"
onClick={this.toggleOptions}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
aria-label={t('AssetList.ToggleOpenCloseARIA')}
>
<DownFilledTriangleIcon focusable="false" aria-hidden="true" />
</button>
{optionsOpen && (
<ul className="asset-table__action-dialogue">
<li>
<button
className="asset-table__action-option"
onClick={this.handleAssetDelete}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
{t('AssetList.Delete')}
</button>
</li>
<li>
<a
href={asset.url}
target="_blank"
rel="noreferrer"
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
className="asset-table__action-option"
>
{t('AssetList.OpenNewTab')}
</a>
</li>
</ul>
)}
</td>
</tr>
);
}
}
const AssetListRowBase = ({ asset, username }) => (
<tr className="asset-table__row" key={asset.key}>
<th scope="row">
<a href={asset.url} target="_blank" rel="noopener noreferrer">
{asset.name}
</a>
</th>
<td>{prettyBytes(asset.size)}</td>
<td>
{asset.sketchId && (
<Link to={`/${username}/sketches/${asset.sketchId}`}>
{asset.sketchName}
</Link>
)}
</td>
<td className="asset-table__dropdown-column">
<AssetMenu item={asset} />
</td>
</tr>
);

AssetListRowBase.propTypes = {
asset: PropTypes.shape({
Expand All @@ -135,9 +73,7 @@ AssetListRowBase.propTypes = {
name: PropTypes.string.isRequired,
size: PropTypes.number.isRequired
}).isRequired,
deleteAssetRequest: PropTypes.func.isRequired,
username: PropTypes.string.isRequired,
t: PropTypes.func.isRequired
username: PropTypes.string.isRequired
};

function mapStateToPropsAssetListRow(state) {
Expand Down
Loading