1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 01:47:00 +01:00

Feat/19274/ds popover update account list menu (#19534)

* update account list menu to use ds popover and fix accessibility issue

* close popover if user continues to tab to next items

* remove disable logic not doing anything

* add stylesheet

* add refs to track last menuitem

* cleaned up ref version for MenuItems

* fix test

* add click away option and fix test

* fix e2e test

* undo e2e test

* add account e2e

* fix click outside component

* remove additional line break

* remove commented out code

* add isOpen to story

* set width to 225px
This commit is contained in:
Garrett Bear 2023-06-16 09:25:13 -07:00 committed by GitHub
parent a8a61ebc33
commit 70d86ee67c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 283 additions and 181 deletions

View File

@ -216,7 +216,7 @@ describe('Add account', function () {
);
// Create 3rd account with private key
await driver.clickElement('.menu__background');
await driver.clickElement('.mm-text-field');
await driver.clickElement({ text: 'Import account', tag: 'button' });
await driver.fill('#private-key-box', testPrivateKey);

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
@ -23,8 +23,15 @@ import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils
///: END:ONLY_INCLUDE_IN
import { findKeyringForAddress } from '../../../ducks/metamask/metamask';
import { NETWORKS_ROUTE } from '../../../helpers/constants/routes';
import { Menu, MenuItem } from '../../ui/menu';
import { Text, IconName } from '../../component-library';
import { MenuItem } from '../../ui/menu';
import {
Text,
IconName,
Popover,
PopoverPosition,
ModalFocus,
PopoverRole,
} from '../../component-library';
import {
MetaMetricsEventCategory,
MetaMetricsEventLinkType,
@ -42,6 +49,7 @@ export const AccountListItemMenu = ({
closeMenu,
isRemovable,
identity,
isOpen,
}) => {
const t = useI18nContext();
const trackEvent = useContext(MetaMetricsContext);
@ -82,116 +90,178 @@ export const AccountListItemMenu = ({
};
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
const isCustodial = keyring?.type ? /Custody/u.test(keyring.type) : false;
const accounts = useSelector(getMetaMaskAccountsOrdered);
const isCustodial = /Custody/u.test(keyring.type);
const mmiActions = mmiActionsFactory();
///: END:ONLY_INCLUDE_IN
return (
<Menu
anchorElement={anchorElement}
className="account-list-item-menu"
onHide={onClose}
>
<MenuItem
onClick={() => {
blockExplorerLinkText.firstPart === 'addBlockExplorer'
? routeToAddBlockExplorerUrl()
: openBlockExplorer();
// Handle Tab key press for accessibility inside the popover and will close the popover on the last MenuItem
const lastItemRef = useRef(null);
const accountDetailsItemRef = useRef(null);
const removeAccountItemRef = useRef(null);
const removeJWTItemRef = useRef(null);
trackEvent({
event: MetaMetricsEventName.BlockExplorerLinkClicked,
category: MetaMetricsEventCategory.Accounts,
properties: {
location: 'Account Options',
chain_id: chainId,
},
});
}}
subtitle={blockExplorerUrlSubTitle || null}
iconName={IconName.Export}
data-testid="account-list-menu-open-explorer"
>
<Text variant={TextVariant.bodySm}>{t('viewOnExplorer')}</Text>
</MenuItem>
<MenuItem
onClick={() => {
dispatch(setAccountDetailsAddress(identity.address));
trackEvent({
event: MetaMetricsEventName.NavAccountDetailsOpened,
category: MetaMetricsEventCategory.Navigation,
properties: {
location: 'Account Options',
},
});
onClose();
closeMenu?.();
}}
iconName={IconName.ScanBarcode}
data-testid="account-list-menu-details"
>
<Text variant={TextVariant.bodySm}>{t('accountDetails')}</Text>
</MenuItem>
{isRemovable ? (
<MenuItem
data-testid="account-list-menu-remove"
onClick={() => {
dispatch(
showModal({
name: 'CONFIRM_REMOVE_ACCOUNT',
identity,
}),
);
trackEvent({
event: MetaMetricsEventName.AccountRemoved,
category: MetaMetricsEventCategory.Accounts,
properties: {
account_hardware_type: deviceName,
chain_id: chainId,
account_type: accountType,
},
});
onClose();
closeMenu?.();
}}
iconName={IconName.Trash}
>
<Text variant={TextVariant.bodySm}>{t('removeAccount')}</Text>
</MenuItem>
) : null}
{
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
isCustodial ? (
// Checks the MenuItems from the bottom to top to set lastItemRef on the last MenuItem that is not disabled
useEffect(() => {
if (removeJWTItemRef.current) {
lastItemRef.current = removeJWTItemRef.current;
} else if (removeAccountItemRef.current) {
lastItemRef.current = removeAccountItemRef.current;
} else {
lastItemRef.current = accountDetailsItemRef.current;
}
}, [
removeJWTItemRef.current,
removeAccountItemRef.current,
accountDetailsItemRef.current,
]);
const handleKeyDown = (event) => {
if (event.key === 'Tab' && event.target === lastItemRef.current) {
// If Tab is pressed at the last item to close popover and focus to next element in DOM
onClose();
}
};
// Handle click outside of the popover to close it
const popoverDialogRef = useRef(null);
const handleClickOutside = (event) => {
if (
popoverDialogRef?.current &&
!popoverDialogRef.current.contains(event.target)
) {
onClose();
}
};
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<Popover
className="multichain-account-list-item-menu__popover"
referenceElement={anchorElement}
role={PopoverRole.Dialog}
position={PopoverPosition.Bottom}
offset={[0, 0]}
padding={0}
isOpen={isOpen}
isPortal
preventOverflow
>
<ModalFocus restoreFocus initialFocusRef={anchorElement}>
<div onKeyDown={handleKeyDown} ref={popoverDialogRef}>
<MenuItem
data-testid="account-options-menu__remove-jwt"
onClick={async () => {
const token = await dispatch(mmiActions.getCustodianToken());
const custodyAccountDetails = await dispatch(
mmiActions.getAllCustodianAccountsWithToken(
keyring.type.split(' - ')[1],
token,
),
);
dispatch(
showModal({
name: 'CONFIRM_REMOVE_JWT',
token,
custodyAccountDetails,
accounts,
selectedAddress: toChecksumHexAddress(identity.address),
}),
);
onClick={() => {
blockExplorerLinkText.firstPart === 'addBlockExplorer'
? routeToAddBlockExplorerUrl()
: openBlockExplorer();
trackEvent({
event: MetaMetricsEventName.BlockExplorerLinkClicked,
category: MetaMetricsEventCategory.Accounts,
properties: {
location: 'Account Options',
chain_id: chainId,
},
});
}}
subtitle={blockExplorerUrlSubTitle || null}
iconName={IconName.Export}
data-testid="account-list-menu-open-explorer"
>
<Text variant={TextVariant.bodySm}>{t('viewOnExplorer')}</Text>
</MenuItem>
<MenuItem
ref={accountDetailsItemRef}
onClick={() => {
dispatch(setAccountDetailsAddress(identity.address));
trackEvent({
event: MetaMetricsEventName.NavAccountDetailsOpened,
category: MetaMetricsEventCategory.Navigation,
properties: {
location: 'Account Options',
},
});
onClose();
closeMenu?.();
}}
iconName={IconName.Trash}
iconName={IconName.ScanBarcode}
data-testid="account-list-menu-details"
>
<Text variant={TextVariant.bodySm}>{t('removeJWT')}</Text>
<Text variant={TextVariant.bodySm}>{t('accountDetails')}</Text>
</MenuItem>
) : null
///: END:ONLY_INCLUDE_IN
}
</Menu>
{isRemovable ? (
<MenuItem
ref={removeAccountItemRef}
data-testid="account-list-menu-remove"
onClick={() => {
dispatch(
showModal({
name: 'CONFIRM_REMOVE_ACCOUNT',
identity,
}),
);
trackEvent({
event: MetaMetricsEventName.AccountRemoved,
category: MetaMetricsEventCategory.Accounts,
properties: {
account_hardware_type: deviceName,
chain_id: chainId,
account_type: accountType,
},
});
onClose();
closeMenu?.();
}}
iconName={IconName.Trash}
>
<Text variant={TextVariant.bodySm}>{t('removeAccount')}</Text>
</MenuItem>
) : null}
{
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
isCustodial ? (
<MenuItem
ref={removeJWTItemRef}
data-testid="account-options-menu__remove-jwt"
onClick={async () => {
const token = await dispatch(mmiActions.getCustodianToken());
const custodyAccountDetails = await dispatch(
mmiActions.getAllCustodianAccountsWithToken(
keyring.type.split(' - ')[1],
token,
),
);
dispatch(
showModal({
name: 'CONFIRM_REMOVE_JWT',
token,
custodyAccountDetails,
accounts,
selectedAddress: toChecksumHexAddress(identity.address),
}),
);
onClose();
closeMenu?.();
}}
iconName={IconName.Trash}
>
<Text variant={TextVariant.bodySm}>{t('removeJWT')}</Text>
</MenuItem>
) : null
///: END:ONLY_INCLUDE_IN
}
</div>
</ModalFocus>
</Popover>
);
};
@ -204,6 +274,12 @@ AccountListItemMenu.propTypes = {
* Function that executes when the menu is closed
*/
onClose: PropTypes.func.isRequired,
/**
* Represents if the menu is open or not
*
* @type {boolean}
*/
isOpen: PropTypes.bool.isRequired,
/**
* Function that closes the menu
*/

View File

@ -23,6 +23,9 @@ export default {
identity: {
control: 'object',
},
isOpen: {
control: 'boolean',
},
},
args: {
anchorElement: null,
@ -34,6 +37,7 @@ export default {
},
isRemovable: true,
blockExplorerUrlSubTitle: 'etherscan.io',
isOpen: true,
},
};

View File

@ -17,6 +17,7 @@ const DEFAULT_PROPS = {
onClose: jest.fn(),
onHide: jest.fn(),
isRemovable: false,
isOpen: true,
};
const render = (props = {}) => {

View File

@ -0,0 +1,6 @@
.multichain-account-list-item-menu__popover {
z-index: $popover-in-modal-z-index;
overflow: hidden;
min-width: 225px;
max-width: 225px;
}

View File

@ -3,7 +3,7 @@
exports[`AccountListItem renders AccountListItem component and shows account name, address, and balance 1`] = `
<div>
<div
class="box multichain-account-list-item box--padding-4 box--display-flex box--flex-direction-row box--background-color-transparent"
class="mm-box multichain-account-list-item mm-box--padding-4 mm-box--display-flex mm-box--background-color-transparent"
>
<div
class="box mm-text mm-avatar-base mm-avatar-base--size-sm mm-avatar-account mm-text--body-sm mm-text--text-transform-uppercase box--margin-inline-end-2 box--display-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-text-default box--background-color-background-alternative box--rounded-full box--border-color-transparent box--border-style-solid box--border-width-1"
@ -49,13 +49,13 @@ exports[`AccountListItem renders AccountListItem component and shows account nam
</div>
</div>
<div
class="box multichain-account-list-item__content box--display-flex box--flex-direction-column"
class="mm-box multichain-account-list-item__content mm-box--display-flex mm-box--flex-direction-column"
>
<div
class="box box--display-flex box--flex-direction-column"
class="mm-box mm-box--display-flex mm-box--flex-direction-column"
>
<div
class="box box--display-flex box--flex-direction-row box--justify-content-space-between"
class="mm-box mm-box--display-flex mm-box--justify-content-space-between"
>
<div
class="box mm-text multichain-account-list-item__account-name mm-text--body-md mm-text--ellipsis box--margin-inline-end-2 box--flex-direction-row box--color-text-default"
@ -95,10 +95,10 @@ exports[`AccountListItem renders AccountListItem component and shows account nam
</div>
</div>
<div
class="box box--display-flex box--flex-direction-row box--justify-content-space-between"
class="mm-box mm-box--display-flex mm-box--justify-content-space-between"
>
<div
class="box box--display-flex box--flex-direction-row box--align-items-center"
class="mm-box mm-box--display-flex mm-box--align-items-center"
>
<p
class="box mm-text mm-text--body-sm box--flex-direction-row box--color-text-alternative"
@ -130,18 +130,16 @@ exports[`AccountListItem renders AccountListItem component and shows account nam
</div>
</div>
</div>
<div>
<button
aria-label="Test Account Options"
class="box mm-button-icon mm-button-icon--size-sm box--display-inline-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-icon-default box--background-color-transparent box--rounded-lg"
data-testid="account-list-item-menu-button"
>
<span
class="box mm-icon mm-icon--size-sm box--display-inline-block box--flex-direction-row box--color-inherit"
style="mask-image: url('./images/icons/more-vertical.svg');"
/>
</button>
</div>
<button
aria-label="Test Account Options"
class="box mm-button-icon mm-button-icon--size-sm box--display-inline-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-icon-default box--background-color-transparent box--rounded-lg"
data-testid="account-list-item-menu-button"
>
<span
class="box mm-icon mm-icon--size-sm box--display-inline-block box--flex-direction-row box--color-inherit"
style="mask-image: url('./images/icons/more-vertical.svg');"
/>
</button>
</div>
</div>
`;

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useContext } from 'react';
import React, { useState, useContext } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
@ -8,9 +8,9 @@ import { getRpcPrefsForCurrentProvider } from '../../../selectors';
import { getURLHostName, shortenAddress } from '../../../helpers/utils/util';
import { AccountListItemMenu } from '..';
import Box from '../../ui/box/box';
import {
AvatarAccount,
Box,
Text,
AvatarFavicon,
Tag,
@ -24,13 +24,13 @@ import {
Color,
TextAlign,
AlignItems,
DISPLAY,
TextVariant,
FLEX_DIRECTION,
FlexDirection,
BorderRadius,
JustifyContent,
Size,
BorderColor,
Display,
} from '../../../helpers/constants/design-system';
import { HardwareKeyringNames } from '../../../../shared/constants/hardware-wallets';
import { KeyringType } from '../../../../shared/constants/keyring';
@ -75,9 +75,14 @@ export const AccountListItem = ({
}) => {
const t = useI18nContext();
const [accountOptionsMenuOpen, setAccountOptionsMenuOpen] = useState(false);
const ref = useRef(false);
const [accountListItemMenuElement, setAccountListItemMenuElement] =
useState();
const useBlockie = useSelector((state) => state.metamask.useBlockie);
const setAccountListItemMenuRef = (ref) => {
setAccountListItemMenuElement(ref);
};
const keyring = useSelector((state) =>
findKeyringForAddress(state, identity.address),
);
@ -91,7 +96,7 @@ export const AccountListItem = ({
return (
<Box
display={DISPLAY.FLEX}
display={Display.Flex}
padding={4}
backgroundColor={selected ? Color.primaryMuted : Color.transparent}
className={classnames('multichain-account-list-item', {
@ -125,13 +130,13 @@ export const AccountListItem = ({
marginInlineEnd={2}
></AvatarAccount>
<Box
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
display={Display.Flex}
flexDirection={FlexDirection.Column}
className="multichain-account-list-item__content"
>
<Box display={DISPLAY.FLEX} flexDirection={FLEX_DIRECTION.COLUMN}>
<Box display={Display.Flex} flexDirection={FlexDirection.Column}>
<Box
display={DISPLAY.FLEX}
display={Display.Flex}
justifyContent={JustifyContent.spaceBetween}
>
<Text
@ -165,8 +170,8 @@ export const AccountListItem = ({
<Text
as="div"
className="multichain-account-list-item__asset"
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.ROW}
display={Display.Flex}
flexDirection={FlexDirection.Row}
alignItems={AlignItems.center}
ellipsis
textAlign={TextAlign.End}
@ -180,10 +185,10 @@ export const AccountListItem = ({
</Box>
</Box>
<Box
display={DISPLAY.FLEX}
display={Display.Flex}
justifyContent={JustifyContent.spaceBetween}
>
<Box display={DISPLAY.FLEX} alignItems={AlignItems.center}>
<Box display={Display.Flex} alignItems={AlignItems.center}>
{connectedAvatar ? (
<AvatarFavicon
size={Size.XS}
@ -219,13 +224,14 @@ export const AccountListItem = ({
/>
) : null}
</Box>
<div ref={ref}>
<ButtonIcon
ariaLabel={`${identity.name} ${t('options')}`}
iconName={IconName.MoreVertical}
size={IconSize.Sm}
onClick={(e) => {
e.stopPropagation();
<ButtonIcon
ariaLabel={`${identity.name} ${t('options')}`}
iconName={IconName.MoreVertical}
size={IconSize.Sm}
ref={setAccountListItemMenuRef}
onClick={(e) => {
e.stopPropagation();
if (!accountOptionsMenuOpen) {
trackEvent({
event: MetaMetricsEventName.AccountDetailMenuOpened,
category: MetaMetricsEventCategory.Navigation,
@ -233,21 +239,20 @@ export const AccountListItem = ({
location: 'Account Options',
},
});
setAccountOptionsMenuOpen(true);
}}
data-testid="account-list-item-menu-button"
/>
{accountOptionsMenuOpen ? (
<AccountListItemMenu
anchorElement={ref.current}
blockExplorerUrlSubTitle={blockExplorerUrlSubTitle}
identity={identity}
onClose={() => setAccountOptionsMenuOpen(false)}
isRemovable={keyring?.type !== KeyringType.hdKeyTree}
closeMenu={closeMenu}
/>
) : null}
</div>
}
setAccountOptionsMenuOpen(!accountOptionsMenuOpen);
}}
data-testid="account-list-item-menu-button"
/>
<AccountListItemMenu
anchorElement={accountListItemMenuElement}
blockExplorerUrlSubTitle={blockExplorerUrlSubTitle}
identity={identity}
onClose={() => setAccountOptionsMenuOpen(false)}
isOpen={accountOptionsMenuOpen}
isRemovable={keyring?.type !== KeyringType.hdKeyTree}
closeMenu={closeMenu}
/>
</Box>
);
};

View File

@ -68,7 +68,9 @@ describe('AccountListItem', () => {
);
expect(optionsButton).toBeInTheDocument();
fireEvent.click(optionsButton);
expect(document.querySelector('.menu__background')).toBeInTheDocument();
expect(
document.querySelector('.multichain-account-list-item-menu__popover'),
).toBeInTheDocument();
});
it('executes the action when the item is clicked', () => {

View File

@ -6,6 +6,7 @@
**/
@import 'address-copy-button/index';
@import 'account-list-item/index';
@import 'account-list-item-menu/index';
@import 'account-list-menu/index';
@import 'account-picker/index';
@import 'app-header/app-header';

View File

@ -5,29 +5,35 @@ import classnames from 'classnames';
import { Text, Icon, IconSize } from '../../component-library';
import { TextVariant } from '../../../helpers/constants/design-system';
const MenuItem = ({
children,
className,
'data-testid': dataTestId,
iconName,
onClick,
subtitle,
disabled = false,
}) => (
<button
className={classnames('menu-item', className)}
data-testid={dataTestId}
onClick={onClick}
disabled={disabled}
>
{iconName ? (
<Icon name={iconName} size={IconSize.Sm} marginRight={2} />
) : null}
<div>
<div>{children}</div>
{subtitle ? <Text variant={TextVariant.bodyXs}>{subtitle}</Text> : null}
</div>
</button>
const MenuItem = React.forwardRef(
(
{
children,
className,
'data-testid': dataTestId,
iconName,
onClick,
subtitle,
disabled = false,
},
ref,
) => (
<button
className={classnames('menu-item', className)}
data-testid={dataTestId}
onClick={onClick}
ref={ref}
disabled={disabled}
>
{iconName ? (
<Icon name={iconName} size={IconSize.Sm} marginRight={2} />
) : null}
<div>
<div>{children}</div>
{subtitle ? <Text variant={TextVariant.bodyXs}>{subtitle}</Text> : null}
</div>
</button>
),
);
MenuItem.propTypes = {
@ -40,4 +46,6 @@ MenuItem.propTypes = {
disabled: PropTypes.bool,
};
MenuItem.displayName = 'MenuItem';
export default MenuItem;

View File

@ -11,3 +11,4 @@ $send-card-z-index: 20;
$sidebar-z-index: 26;
$sidebar-overlay-z-index: 25;
$modal-z-index: 1050;
$popover-in-modal-z-index: 1051;