Skip to content

Commit 94c2d1b

Browse files
committed
Common dropdown component for table actions.
1 parent 97c09d9 commit 94c2d1b

File tree

10 files changed

+265
-438
lines changed

10 files changed

+265
-438
lines changed

client/components/Dropdown.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import styled from 'styled-components';
44
import { remSize, prop } from '../theme';
55
import IconButton from '../common/IconButton';
66

7-
const DropdownWrapper = styled.ul`
7+
export const DropdownWrapper = styled.ul`
88
background-color: ${prop('Modal.background')};
99
border: 1px solid ${prop('Modal.border')};
1010
box-shadow: 0 0 18px 0 ${prop('shadowColor')};
@@ -52,6 +52,7 @@ const DropdownWrapper = styled.ul`
5252
& button span,
5353
& a {
5454
padding: ${remSize(8)} ${remSize(16)};
55+
font-size: ${remSize(12)};
5556
}
5657
5758
* {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import PropTypes from 'prop-types';
2+
import React, { forwardRef, useCallback, useRef, useState } from 'react';
3+
import useModalClose from '../../common/useModalClose';
4+
import DownArrowIcon from '../../images/down-filled-triangle.svg';
5+
import { DropdownWrapper } from '../Dropdown';
6+
7+
// TODO: enable arrow keys to navigate options from list
8+
9+
const DropdownMenu = forwardRef(
10+
({ children, 'aria-label': ariaLabel, align, className, classes }, ref) => {
11+
// Note: need to use a ref instead of a state to avoid stale closures.
12+
const focusedRef = useRef(false);
13+
14+
const [isOpen, setIsOpen] = useState(false);
15+
16+
const close = useCallback(() => setIsOpen(false), [setIsOpen]);
17+
18+
const anchorRef = useModalClose(close, ref);
19+
20+
const toggle = useCallback(() => {
21+
setIsOpen((prevState) => !prevState);
22+
}, [setIsOpen]);
23+
24+
const handleFocus = () => {
25+
focusedRef.current = true;
26+
};
27+
28+
const handleBlur = () => {
29+
focusedRef.current = false;
30+
setTimeout(() => {
31+
if (!focusedRef.current) {
32+
close();
33+
}
34+
}, 200);
35+
};
36+
37+
return (
38+
<div ref={anchorRef} className={className}>
39+
<button
40+
className={classes.button}
41+
aria-label={ariaLabel}
42+
tabIndex="0"
43+
onClick={toggle}
44+
onBlur={handleBlur}
45+
onFocus={handleFocus}
46+
>
47+
<DownArrowIcon focusable="false" aria-hidden="true" />
48+
</button>
49+
{isOpen && (
50+
<DropdownWrapper
51+
className={classes.list}
52+
align={align}
53+
onMouseUp={close}
54+
onBlur={handleBlur}
55+
onFocus={handleFocus}
56+
>
57+
{children}
58+
</DropdownWrapper>
59+
)}
60+
</div>
61+
);
62+
}
63+
);
64+
65+
DropdownMenu.propTypes = {
66+
children: PropTypes.node,
67+
'aria-label': PropTypes.string.isRequired,
68+
align: PropTypes.oneOf(['left', 'right']),
69+
className: PropTypes.string,
70+
classes: PropTypes.shape({
71+
button: PropTypes.string,
72+
list: PropTypes.string
73+
})
74+
};
75+
76+
DropdownMenu.defaultProps = {
77+
children: null,
78+
align: 'right',
79+
className: '',
80+
classes: {}
81+
};
82+
83+
export default DropdownMenu;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import PropTypes from 'prop-types';
2+
import React from 'react';
3+
import ButtonOrLink from '../../common/ButtonOrLink';
4+
5+
// TODO: combine with NavMenuItem
6+
7+
function MenuItem({ hideIf, ...rest }) {
8+
if (hideIf) {
9+
return null;
10+
}
11+
12+
return (
13+
<li>
14+
<ButtonOrLink {...rest} />
15+
</li>
16+
);
17+
}
18+
19+
MenuItem.propTypes = {
20+
...ButtonOrLink.propTypes,
21+
onClick: PropTypes.func,
22+
value: PropTypes.string,
23+
/**
24+
* Provides a way to deal with optional items.
25+
*/
26+
hideIf: PropTypes.bool
27+
};
28+
29+
MenuItem.defaultProps = {
30+
onClick: null,
31+
value: null,
32+
hideIf: false
33+
};
34+
35+
export default MenuItem;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import styled from 'styled-components';
2+
import { prop, remSize } from '../../theme';
3+
import DropdownMenu from './DropdownMenu';
4+
5+
const TableDropdown = styled(DropdownMenu).attrs({ align: 'right' })`
6+
& > button {
7+
width: ${remSize(25)};
8+
height: ${remSize(25)};
9+
& polygon,
10+
& path {
11+
fill: ${prop('inactiveTextColor')};
12+
}
13+
}
14+
& ul {
15+
top: 63%;
16+
right: calc(100% - 26px);
17+
}
18+
`;
19+
20+
export default TableDropdown;

client/modules/IDE/components/AssetList.jsx

Lines changed: 49 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,68 @@
11
import PropTypes from 'prop-types';
22
import React from 'react';
3-
import { connect } from 'react-redux';
3+
import { connect, useDispatch } from 'react-redux';
44
import { bindActionCreators } from 'redux';
55
import { Link } from 'react-router-dom';
66
import { Helmet } from 'react-helmet';
77
import prettyBytes from 'pretty-bytes';
8-
import { withTranslation } from 'react-i18next';
8+
import { useTranslation, withTranslation } from 'react-i18next';
9+
import MenuItem from '../../../components/Dropdown/MenuItem';
10+
import TableDropdown from '../../../components/Dropdown/TableDropdown';
911

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

14-
class AssetListRowBase extends React.Component {
15-
constructor(props) {
16-
super(props);
17-
this.state = {
18-
isFocused: false,
19-
optionsOpen: false
20-
};
21-
}
22-
23-
onFocusComponent = () => {
24-
this.setState({ isFocused: true });
25-
};
26-
27-
onBlurComponent = () => {
28-
this.setState({ isFocused: false });
29-
setTimeout(() => {
30-
if (!this.state.isFocused) {
31-
this.closeOptions();
32-
}
33-
}, 200);
34-
};
35-
36-
openOptions = () => {
37-
this.setState({
38-
optionsOpen: true
39-
});
40-
};
16+
const AssetMenu = ({ item: asset }) => {
17+
const { t } = useTranslation();
4118

42-
closeOptions = () => {
43-
this.setState({
44-
optionsOpen: false
45-
});
46-
};
19+
const dispatch = useDispatch();
4720

48-
toggleOptions = () => {
49-
if (this.state.optionsOpen) {
50-
this.closeOptions();
51-
} else {
52-
this.openOptions();
21+
const handleAssetDelete = () => {
22+
const { key, name } = asset;
23+
if (window.confirm(t('Common.DeleteConfirmation', { name }))) {
24+
dispatch(deleteAssetRequest(key));
5325
}
5426
};
5527

56-
handleDropdownOpen = () => {
57-
this.closeOptions();
58-
this.openOptions();
59-
};
28+
return (
29+
<TableDropdown aria-label={t('AssetList.ToggleOpenCloseARIA')}>
30+
<MenuItem onClick={handleAssetDelete}>{t('AssetList.Delete')}</MenuItem>
31+
<MenuItem href={asset.url} target="_blank">
32+
{t('AssetList.OpenNewTab')}
33+
</MenuItem>
34+
</TableDropdown>
35+
);
36+
};
6037

61-
handleAssetDelete = () => {
62-
const { key, name } = this.props.asset;
63-
this.closeOptions();
64-
if (window.confirm(this.props.t('Common.DeleteConfirmation', { name }))) {
65-
this.props.deleteAssetRequest(key);
66-
}
67-
};
38+
AssetMenu.propTypes = {
39+
item: PropTypes.shape({
40+
key: PropTypes.string.isRequired,
41+
url: PropTypes.string.isRequired,
42+
name: PropTypes.string.isRequired
43+
}).isRequired
44+
};
6845

69-
render() {
70-
const { asset, username, t } = this.props;
71-
const { optionsOpen } = this.state;
72-
return (
73-
<tr className="asset-table__row" key={asset.key}>
74-
<th scope="row">
75-
<Link to={asset.url} target="_blank">
76-
{asset.name}
77-
</Link>
78-
</th>
79-
<td>{prettyBytes(asset.size)}</td>
80-
<td>
81-
{asset.sketchId && (
82-
<Link to={`/${username}/sketches/${asset.sketchId}`}>
83-
{asset.sketchName}
84-
</Link>
85-
)}
86-
</td>
87-
<td className="asset-table__dropdown-column">
88-
<button
89-
className="asset-table__dropdown-button"
90-
onClick={this.toggleOptions}
91-
onBlur={this.onBlurComponent}
92-
onFocus={this.onFocusComponent}
93-
aria-label={t('AssetList.ToggleOpenCloseARIA')}
94-
>
95-
<DownFilledTriangleIcon focusable="false" aria-hidden="true" />
96-
</button>
97-
{optionsOpen && (
98-
<ul className="asset-table__action-dialogue">
99-
<li>
100-
<button
101-
className="asset-table__action-option"
102-
onClick={this.handleAssetDelete}
103-
onBlur={this.onBlurComponent}
104-
onFocus={this.onFocusComponent}
105-
>
106-
{t('AssetList.Delete')}
107-
</button>
108-
</li>
109-
<li>
110-
<a
111-
href={asset.url}
112-
target="_blank"
113-
rel="noreferrer"
114-
onBlur={this.onBlurComponent}
115-
onFocus={this.onFocusComponent}
116-
className="asset-table__action-option"
117-
>
118-
{t('AssetList.OpenNewTab')}
119-
</a>
120-
</li>
121-
</ul>
122-
)}
123-
</td>
124-
</tr>
125-
);
126-
}
127-
}
46+
const AssetListRowBase = ({ asset, username }) => (
47+
<tr className="asset-table__row" key={asset.key}>
48+
<th scope="row">
49+
<Link to={asset.url} target="_blank">
50+
{asset.name}
51+
</Link>
52+
</th>
53+
<td>{prettyBytes(asset.size)}</td>
54+
<td>
55+
{asset.sketchId && (
56+
<Link to={`/${username}/sketches/${asset.sketchId}`}>
57+
{asset.sketchName}
58+
</Link>
59+
)}
60+
</td>
61+
<td className="asset-table__dropdown-column">
62+
<AssetMenu item={asset} />
63+
</td>
64+
</tr>
65+
);
12866

12967
AssetListRowBase.propTypes = {
13068
asset: PropTypes.shape({
@@ -135,9 +73,7 @@ AssetListRowBase.propTypes = {
13573
name: PropTypes.string.isRequired,
13674
size: PropTypes.number.isRequired
13775
}).isRequired,
138-
deleteAssetRequest: PropTypes.func.isRequired,
139-
username: PropTypes.string.isRequired,
140-
t: PropTypes.func.isRequired
76+
username: PropTypes.string.isRequired
14177
};
14278

14379
function mapStateToPropsAssetListRow(state) {

0 commit comments

Comments
 (0)