Skip to content

Commit f2a5eb2

Browse files
authored
Merge pull request #2379 from lindapaiste/refactor/dropdown
Common dropdown component for tables
2 parents f725abd + 2b592b9 commit f2a5eb2

File tree

10 files changed

+329
-445
lines changed

10 files changed

+329
-445
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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
(
11+
{ children, anchor, 'aria-label': ariaLabel, align, className, classes },
12+
ref
13+
) => {
14+
// Note: need to use a ref instead of a state to avoid stale closures.
15+
const focusedRef = useRef(false);
16+
17+
const [isOpen, setIsOpen] = useState(false);
18+
19+
const close = useCallback(() => setIsOpen(false), [setIsOpen]);
20+
21+
const anchorRef = useModalClose(close, ref);
22+
23+
const toggle = useCallback(() => {
24+
setIsOpen((prevState) => !prevState);
25+
}, [setIsOpen]);
26+
27+
const handleFocus = () => {
28+
focusedRef.current = true;
29+
};
30+
31+
const handleBlur = () => {
32+
focusedRef.current = false;
33+
setTimeout(() => {
34+
if (!focusedRef.current) {
35+
close();
36+
}
37+
}, 200);
38+
};
39+
40+
return (
41+
<div ref={anchorRef} className={className}>
42+
<button
43+
className={classes.button}
44+
aria-label={ariaLabel}
45+
tabIndex="0"
46+
onClick={toggle}
47+
onBlur={handleBlur}
48+
onFocus={handleFocus}
49+
>
50+
{anchor ?? <DownArrowIcon focusable="false" aria-hidden="true" />}
51+
</button>
52+
{isOpen && (
53+
<DropdownWrapper
54+
className={classes.list}
55+
align={align}
56+
onMouseUp={() => {
57+
setTimeout(close, 0);
58+
}}
59+
onBlur={handleBlur}
60+
onFocus={handleFocus}
61+
>
62+
{children}
63+
</DropdownWrapper>
64+
)}
65+
</div>
66+
);
67+
}
68+
);
69+
70+
DropdownMenu.propTypes = {
71+
/**
72+
* Provide <MenuItem> elements as children to control the contents of the menu.
73+
*/
74+
children: PropTypes.node.isRequired,
75+
/**
76+
* Can optionally override the contents of the button which opens the menu.
77+
* Defaults to <DownArrowIcon>
78+
*/
79+
anchor: PropTypes.node,
80+
'aria-label': PropTypes.string.isRequired,
81+
align: PropTypes.oneOf(['left', 'right']),
82+
className: PropTypes.string,
83+
classes: PropTypes.shape({
84+
button: PropTypes.string,
85+
list: PropTypes.string
86+
})
87+
};
88+
89+
DropdownMenu.defaultProps = {
90+
anchor: null,
91+
align: 'right',
92+
className: '',
93+
classes: {}
94+
};
95+
96+
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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from 'react';
2+
import { useMediaQuery } from 'react-responsive';
3+
import styled from 'styled-components';
4+
import { prop, remSize } from '../../theme';
5+
import DropdownMenu from './DropdownMenu';
6+
7+
import DownFilledTriangleIcon from '../../images/down-filled-triangle.svg';
8+
import MoreIconSvg from '../../images/more.svg';
9+
10+
const DotsHorizontal = styled(MoreIconSvg)`
11+
transform: rotate(90deg);
12+
`;
13+
14+
const TableDropdownIcon = () => {
15+
// TODO: centralize breakpoints
16+
const isMobile = useMediaQuery({ maxWidth: 770 });
17+
18+
return isMobile ? (
19+
<DotsHorizontal focusable="false" aria-hidden="true" />
20+
) : (
21+
<DownFilledTriangleIcon focusable="false" aria-hidden="true" />
22+
);
23+
};
24+
25+
const TableDropdown = styled(DropdownMenu).attrs({
26+
align: 'right',
27+
anchor: <TableDropdownIcon />
28+
})`
29+
& > button {
30+
width: ${remSize(25)};
31+
height: ${remSize(25)};
32+
padding: 0;
33+
& svg {
34+
max-width: 100%;
35+
max-height: 100%;
36+
}
37+
& polygon,
38+
& path {
39+
fill: ${prop('inactiveTextColor')};
40+
}
41+
}
42+
& ul {
43+
top: 63%;
44+
right: calc(100% - 26px);
45+
}
46+
`;
47+
48+
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+
<a href={asset.url} target="_blank" rel="noopener noreferrer">
50+
{asset.name}
51+
</a>
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)