diff --git a/src/containers/Accounts/AMM/AMMAccounts/AMMAccountHeader/AMMAccountHeader.tsx b/src/containers/Accounts/AMM/AMMAccounts/AMMAccountHeader/AMMAccountHeader.tsx index 09de6edd5..872bb1263 100644 --- a/src/containers/Accounts/AMM/AMMAccounts/AMMAccountHeader/AMMAccountHeader.tsx +++ b/src/containers/Accounts/AMM/AMMAccounts/AMMAccountHeader/AMMAccountHeader.tsx @@ -1,7 +1,5 @@ import { useTranslation } from 'react-i18next' -import '../../../../shared/css/nested-menu.scss' import '../../../AccountHeader/styles.scss' -import '../../../AccountHeader/balance-selector.scss' import { formatTradingFee, localizeBalance, diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector.jsx b/src/containers/Accounts/AccountHeader/BalanceSelector.jsx deleted file mode 100644 index b073338d8..000000000 --- a/src/containers/Accounts/AccountHeader/BalanceSelector.jsx +++ /dev/null @@ -1,91 +0,0 @@ -import PropTypes from 'prop-types' -import { localizeNumber } from '../../shared/utils' -import IconDownArrow from '../../shared/images/down_arrow.svg' -import iconClose from '../../shared/images/close.png' -import Currency from '../../shared/components/Currency' -import '../../shared/css/nested-menu.scss' -import './styles.scss' -import './balance-selector.scss' - -const BalanceSelector = ({ - language, - text, - balances, - onClick, - expandMenu, - onMouseLeave, - onSetCurrencySelected, - currencySelected, -}) => { - const balanceMenuItems = Object.entries(balances).map(([currency, value]) => { - if (currency === currencySelected) { - return null - } - const formattedValue = - localizeNumber(value, language, { - style: 'currency', - currency, - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }) || '0.00' - return ( - - ) - }) - return ( -
setTimeout(onMouseLeave, 2000)} - > - - {expandMenu &&
{balanceMenuItems}
} -
- ) -} - -BalanceSelector.propTypes = { - language: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - expandMenu: PropTypes.bool.isRequired, - balances: PropTypes.shape({}).isRequired, - onClick: PropTypes.func.isRequired, - onMouseLeave: PropTypes.func.isRequired, - onSetCurrencySelected: PropTypes.func.isRequired, - currencySelected: PropTypes.string.isRequired, -} - -export default BalanceSelector diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx b/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx new file mode 100644 index 000000000..a73ff4dca --- /dev/null +++ b/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next' +import { Dropdown } from '../../../shared/components/Dropdown' +import { BalanceSelectorItem } from './BalanceSelectorItem' +import './balance-selector.scss' + +export interface BalanceSelectorProps { + balances: { [currency: string]: string } + onSetCurrencySelected: (currency: string) => void + currencySelected: string +} + +export const BalanceSelector = ({ + balances, + onSetCurrencySelected, + currencySelected, +}: BalanceSelectorProps) => { + const { t } = useTranslation() + const balanceTuples = Object.entries(balances) + const title = `${balanceTuples.length - 1} ${t('accounts.other_balances')}` + + return ( + + {balanceTuples.map(([currency, value]) => { + if (currency === currencySelected) { + return <> + } + + return ( + { + onSetCurrencySelected(currency) + }} + value={value} + /> + ) + })} + + ) +} diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx b/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx new file mode 100644 index 000000000..27a7da75c --- /dev/null +++ b/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { useLanguage } from '../../../shared/hooks' +import { CURRENCY_OPTIONS } from '../../../shared/transactionUtils' +import { localizeNumber } from '../../../shared/utils' +import { DropdownItem } from '../../../shared/components/Dropdown' +import Currency from '../../../shared/components/Currency' + +export interface BalanceSelectorItemProps { + currency: string + handler: (currency) => void + value: string +} + +export const BalanceSelectorItem = React.memo( + ({ currency, value, handler }: BalanceSelectorItemProps) => { + const language = useLanguage() + const options = { + ...CURRENCY_OPTIONS, + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + } + const formattedValue = localizeNumber(value, language, options) || '0.00' + + return ( + + + {formattedValue} + + ) + }, +) diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss b/src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss new file mode 100644 index 000000000..dc6c77018 --- /dev/null +++ b/src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss @@ -0,0 +1,32 @@ +@import 'src/containers/shared/css/variables'; + +.balance-selector { + position: relative; + display: block; + width: 100%; + + @include for-size(tablet-landscape-up) { + width: 280px; + } + + .dropdown-toggle { + padding: 12px 16px; + } + + .dropdown-menu { + width: 100%; + } + + .dropdown-item { + display: flex; + } + + .currency { + font-weight: bold; + } + + .total-balance { + margin-left: auto; + color: $black-40; + } +} diff --git a/src/containers/Accounts/AccountHeader/balance-selector.scss b/src/containers/Accounts/AccountHeader/balance-selector.scss deleted file mode 100644 index 6a0668340..000000000 --- a/src/containers/Accounts/AccountHeader/balance-selector.scss +++ /dev/null @@ -1,98 +0,0 @@ -@import '../../shared/css/variables'; - -.balance-selector { - position: relative; - display: block; - width: 100%; - - @include for-size(tablet-landscape-up) { - width: 280px; - } - - .balance-selector-button { - position: relative; - display: block; - width: 100%; - outline: inherit; - text-align: left; - - @include for-size(desktop-up) { - font-size: 16px; - } - } - - .menu-item { - width: 100%; - padding: 8px 16px; - border-style: none; - background-color: $black; - color: $white; - cursor: pointer; - font-size: 14px; - outline: none; - - &:hover { - background-color: $black-80; - color: $green; - } - - @include for-size(desktop-up) { - padding: 8px 16px; - font-size: 16px; - } - } - - .menu-item-currency { - @include bold; - } - - .menu-item > div { - overflow: hidden; - max-width: 50%; - text-overflow: ellipsis; - white-space: nowrap; - } - - .selector-text { - display: inline-block; - padding: 2px 0px; - margin-right: 8px; - @include medium; - } - - .selector-icon { - display: inline-block; - width: 18px; - height: 16px; - margin: 4px 0px; - color: $black-40; - vertical-align: middle; - - @include for-size(desktop-up) { - float: right; - } - - &.selector-icon-close { - width: 14px; - height: 14px; - } - } - - &.is-active { - .balance-selector-button { - z-index: 100001; - } - - .nested-items { - position: absolute; - z-index: 100000; - right: 0; - height: auto; - border: 1px solid $black-70; - border-radius: 9px; - background-color: $black; - box-shadow: 0px 0px 5px 0px $black-70; - color: $black-40; - } - } -} diff --git a/src/containers/Accounts/AccountHeader/index.tsx b/src/containers/Accounts/AccountHeader/index.tsx index 0017f2808..fad5ac628 100644 --- a/src/containers/Accounts/AccountHeader/index.tsx +++ b/src/containers/Accounts/AccountHeader/index.tsx @@ -1,13 +1,11 @@ -import { useContext, useState, useEffect } from 'react' +import { useContext, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { connect } from 'react-redux' import { bindActionCreators } from 'redux' import { loadAccountState } from './actions' import Loader from '../../shared/components/Loader' -import '../../shared/css/nested-menu.scss' import './styles.scss' -import './balance-selector.scss' -import BalanceSelector from './BalanceSelector' +import { BalanceSelector } from './BalanceSelector/BalanceSelector' import { Account } from '../../shared/components/Account' import { localizeNumber } from '../../shared/utils' import SocketContext from '../../shared/SocketContext' @@ -22,7 +20,7 @@ const CURRENCY_OPTIONS = { } interface AccountHeaderProps { - onSetCurrencySelected: Function + onSetCurrencySelected: (currency: string) => void currencySelected: string loading: boolean accountId: string @@ -69,7 +67,6 @@ interface AccountHeaderProps { } const AccountHeader = (props: AccountHeaderProps) => { - const [showBalanceSelector, setShowBalanceSelector] = useState(false) const { t } = useTranslation() const rippledSocket = useContext(SocketContext) const language = useLanguage() @@ -87,27 +84,14 @@ const AccountHeader = (props: AccountHeaderProps) => { actions.loadAccountState(accountId, rippledSocket) }, [accountId, actions, rippledSocket]) - function toggleBalanceSelector(force?) { - setShowBalanceSelector(force !== undefined ? force : !showBalanceSelector) - } - function renderBalancesSelector() { const { balances = {} } = data return ( Object.keys(balances).length > 1 && (
toggleBalanceSelector()} - onMouseLeave={() => toggleBalanceSelector(false)} - onSetCurrencySelected={(currency) => - onSetCurrencySelected(currency) - } + onSetCurrencySelected={onSetCurrencySelected} currencySelected={currencySelected} />
diff --git a/src/containers/Accounts/AccountHeader/styles.scss b/src/containers/Accounts/AccountHeader/styles.scss index 25c15732b..4e810d5a3 100644 --- a/src/containers/Accounts/AccountHeader/styles.scss +++ b/src/containers/Accounts/AccountHeader/styles.scss @@ -149,7 +149,6 @@ .secondary { padding: 0px 8px; margin-bottom: 20px; - color: $black-40; font-size: 12px; @include bold; diff --git a/src/containers/Accounts/test/index.test.js b/src/containers/Accounts/test/index.test.js index a071260fd..ec6ff57f0 100644 --- a/src/containers/Accounts/test/index.test.js +++ b/src/containers/Accounts/test/index.test.js @@ -52,7 +52,7 @@ describe('Account container', () => { wrapper.update() expect(wrapper.find(AccountHeader).length).toBe(1) expect(wrapper.find(AccountTransactionTable).length).toBe(1) - wrapper.find('.balance-selector-button').simulate('click') + wrapper.find('.balance-selector button').simulate('click') wrapper.unmount() }) }) diff --git a/src/containers/Header/menu.scss b/src/containers/Header/menu.scss index 066ad5265..1bdc2f09a 100644 --- a/src/containers/Header/menu.scss +++ b/src/containers/Header/menu.scss @@ -42,51 +42,6 @@ } } - .nested-menu { - position: relative; - display: inline-block; - padding: 8px 20px 18px 16px; - margin-left: 4px; - cursor: pointer; - - .title { - margin-right: 10px; - } - - .arrow { - position: relative; - top: 1px; - } - - .menu-item { - display: block; - } - - .nested-items { - position: absolute; - top: 35px; - left: -8px; - padding: 8px 1px; - border-radius: 4px; - background-color: $white; - box-shadow: 0px 0px 5px 0px rgb(35 41 47 / 24%); - - .menu-item { - text-align: left; - } - } - - .vertical { - margin-bottom: 1px; - - &:hover, - &:focus { - background-color: $black-70; - color: $black-70; - } - } - } - .horizontal-selected, .vertical-selected { color: $white; diff --git a/src/containers/NFT/NFTHeader/NFTHeader.tsx b/src/containers/NFT/NFTHeader/NFTHeader.tsx index 741d88091..c9ffa8784 100644 --- a/src/containers/NFT/NFTHeader/NFTHeader.tsx +++ b/src/containers/NFT/NFTHeader/NFTHeader.tsx @@ -2,7 +2,6 @@ import { useEffect, useContext, useState } from 'react' import { useTranslation } from 'react-i18next' import { useQuery } from 'react-query' import Loader from '../../shared/components/Loader' -import '../../shared/css/nested-menu.scss' import './styles.scss' import SocketContext from '../../shared/SocketContext' import Tooltip from '../../shared/components/Tooltip' diff --git a/src/containers/PayStrings/PayStringHeader/index.tsx b/src/containers/PayStrings/PayStringHeader/index.tsx index 182e2329a..9c85c55ed 100644 --- a/src/containers/PayStrings/PayStringHeader/index.tsx +++ b/src/containers/PayStrings/PayStringHeader/index.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next' import PayStringLogomark from '../../shared/images/PayString_Logomark.png' import QuestIcon from '../../shared/images/hover_question.svg' import Tooltip from '../../shared/components/Tooltip' -import '../../shared/css/nested-menu.scss' import './styles.scss' import { useLanguage } from '../../shared/hooks' diff --git a/src/containers/Token/TokenHeader/index.tsx b/src/containers/Token/TokenHeader/index.tsx index d83b08376..2059bde35 100644 --- a/src/containers/Token/TokenHeader/index.tsx +++ b/src/containers/Token/TokenHeader/index.tsx @@ -5,7 +5,6 @@ import { connect } from 'react-redux' import { bindActionCreators } from 'redux' import { loadTokenState } from './actions' import Loader from '../../shared/components/Loader' -import '../../shared/css/nested-menu.scss' import './styles.scss' import { localizeNumber, formatLargeNumber } from '../../shared/utils' import SocketContext from '../../shared/SocketContext' diff --git a/src/containers/shared/components/Dropdown/Dropdown.tsx b/src/containers/shared/components/Dropdown/Dropdown.tsx new file mode 100644 index 000000000..86a957500 --- /dev/null +++ b/src/containers/shared/components/Dropdown/Dropdown.tsx @@ -0,0 +1,83 @@ +import classnames from 'classnames' +import { useCallback, useEffect, useRef, useState } from 'react' +import ArrowIcon from '../../images/down_arrow.svg' +import './dropdown.scss' + +export interface DropdownProps { + title: string | JSX.Element + children: any + className?: string +} + +// TODO: Add useId after upgrading to react@18 to populate id on .dropdown-menu and aria-controlled by on .dropdown-toggle +/** + * A simple dropdown that has auto closing + * + * @param title The value in the toggle + * @param children The contents of the menu. DropdownItem is the preferred child component + * @param className + * @constructor + * + * @example + * + * alert('hello')}>Option 1 + * Option 2 + * + */ +export const Dropdown = ({ title, children, className }: DropdownProps) => { + const [expanded, setExpanded] = useState(false) + const dropdownRef = useRef(null) + + const globalClickListener = useCallback((nativeEvent) => { + // ignore click event happened inside the dropdown menu + if (dropdownRef.current && dropdownRef.current.contains(nativeEvent.target)) + return + // else hide dropdown menu + setExpanded(false) + document.removeEventListener('click', globalClickListener) + }, []) + + useEffect( + (): (() => void) => () => + // remove listener when cleaning up component + document.removeEventListener('click', globalClickListener), + [globalClickListener], + ) + + const toggleExpand = () => { + // don't de-expand if clicking in the textbox + setExpanded((prevExpanded) => !prevExpanded) + document.addEventListener('click', globalClickListener) + } + + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/src/containers/shared/components/Dropdown/DropdownItem.tsx b/src/containers/shared/components/Dropdown/DropdownItem.tsx new file mode 100644 index 000000000..2e79bee8a --- /dev/null +++ b/src/containers/shared/components/Dropdown/DropdownItem.tsx @@ -0,0 +1,30 @@ +import { PropsWithChildren } from 'react' +import classnames from 'classnames' + +export type DropdownItemProps = PropsWithChildren<{ + className?: string + handler?: (event) => void + href?: string +}> + +export const DropdownItem = ({ + children, + className, + handler, + href, +}: DropdownItemProps) => { + const Tag = handler || href ? `a` : `div` + + return ( + + {children} + + ) +} diff --git a/src/containers/shared/components/Dropdown/dropdown.scss b/src/containers/shared/components/Dropdown/dropdown.scss new file mode 100644 index 000000000..3df19eaf5 --- /dev/null +++ b/src/containers/shared/components/Dropdown/dropdown.scss @@ -0,0 +1,91 @@ +@import '../../css/variables'; + +.dropdown { + position: relative; + display: inline-block; + font-size: 14px; + white-space: nowrap; +} + +.dropdown-toggle { + display: flex; + align-items: center; + padding: 8px; + border: solid 1px $white; + border-radius: 100px; + cursor: pointer; + font-weight: 700; + gap: 0 24px; + + .arrow { + height: 1em; + margin-left: auto; + } +} + +.dropdown-menu { + position: absolute; + z-index: 100; + display: none; + min-width: max(100%, 160px); + padding: 8px; + border: 1px solid $black-80; + border-radius: 8px; + margin-top: 5px; + background: rgba($black, 0.96); +} + +.dropdown-item { + padding: 12px 8px; + border-radius: 4px; + font-weight: normal; + + @at-root { + a#{&} { + display: flex; + align-items: center; + color: $white; + gap: 0 12px; + + &::after { + display: none; + } + + &:hover { + background: $black-80; + cursor: pointer; + } + } + } + + input { + width: 100%; + padding: 8px; + border: none; + border-radius: 4px; + background: $black-80; + color: $white; + font-size: inherit; + + &::placeholder { + color: $black-40; + } + } + + .btn-remove { + padding: 8px; + margin-left: auto; + background: url('../../images/close.png') center no-repeat; + background-size: 8px; + } +} + +.dropdown-expanded { + .arrow { + transform: rotate(180deg); + } + + .dropdown-menu { + display: block; + } +} diff --git a/src/containers/shared/components/Dropdown/index.ts b/src/containers/shared/components/Dropdown/index.ts new file mode 100644 index 000000000..c57e9ce86 --- /dev/null +++ b/src/containers/shared/components/Dropdown/index.ts @@ -0,0 +1,2 @@ +export * from './Dropdown' +export * from './DropdownItem' diff --git a/src/containers/shared/components/Dropdown/test/Dropdown.test.tsx b/src/containers/shared/components/Dropdown/test/Dropdown.test.tsx new file mode 100644 index 000000000..431a213b5 --- /dev/null +++ b/src/containers/shared/components/Dropdown/test/Dropdown.test.tsx @@ -0,0 +1,91 @@ +import { mount } from 'enzyme' +import { Dropdown } from '../Dropdown' + +describe('Dropdown', () => { + let sandbox + + beforeAll(() => { + sandbox = document.createElement('div') + document.body.appendChild(sandbox) + }) + + afterAll(() => { + if (sandbox) { + document.body.removeChild(sandbox) + } + }) + + describe('prop: title', () => { + it('renders when it is jsx', () => { + const title = Woo + const wrapper = mount(Menu Contents) + expect(wrapper.find('.title-component')).toExist() + expect(wrapper.find('.title-component')).toHaveText('Woo') + wrapper.unmount() + }) + it('renders when it is a string', () => { + const title = 'Woo' + const wrapper = mount(Menu Contents) + expect(wrapper.find('.dropdown-toggle')).toIncludeText(title) + wrapper.unmount() + }) + }) + describe(`prop: className`, () => { + it('renders with custom className', () => { + const wrapper = mount( + + Menu Contents + , + ) + expect(wrapper.find('.dropdown')).toHaveClassName('dropdown-custom') + wrapper.unmount() + }) + }) + + it('shows menu when clicking toggle', () => { + const wrapper = mount(Menu Contents) + expect(wrapper.find('.dropdown')).not.toHaveClassName('dropdown-expanded') + wrapper.find('.dropdown-toggle').simulate('click') + expect(wrapper.find('.dropdown')).toHaveClassName('dropdown-expanded') + wrapper.find('.dropdown-toggle').simulate('click') + expect(wrapper.find('.dropdown')).not.toHaveClassName('dropdown-expanded') + wrapper.unmount() + }) + + it('hides menu when clicking toggle outside the component', () => { + const wrapper = mount( +
+ +
Menu Contents
+
+ +
, + { attachTo: sandbox }, + ) + expect(wrapper.find('.dropdown')).not.toHaveClassName('dropdown-expanded') + wrapper.find('.dropdown-toggle').simulate('click') + expect(wrapper.find('.dropdown')).toHaveClassName('dropdown-expanded') + wrapper.find('.child').getDOMNode().click() // simulate does not bubble + wrapper.update() + expect(wrapper.find('.dropdown')).toHaveClassName('dropdown-expanded') + wrapper.find('.outside').getDOMNode().click() // simulate does not bubble + wrapper.update() + expect(wrapper.find('.dropdown')).not.toHaveClassName('dropdown-expanded') + wrapper.unmount() + }) + + it('adds aria roles', () => { + const wrapper = mount(Menu Contents) + const toggle = wrapper.find('.dropdown-toggle') + const menu = wrapper.find('.dropdown-menu') + expect(toggle).toHaveProp('aria-haspopup', 'true') + expect(toggle).toHaveProp('tabIndex', 0) + expect(menu).toHaveProp('role', 'menu') + expect(menu).toHaveProp('tabIndex', 0) + toggle.simulate('click') + expect(wrapper.find('.dropdown-toggle')).toHaveProp('aria-expanded', true) + expect(wrapper.find('.dropdown-menu')).toHaveProp('aria-hidden', false) + }) +}) diff --git a/src/containers/shared/components/Dropdown/test/DropdownItem.test.tsx b/src/containers/shared/components/Dropdown/test/DropdownItem.test.tsx new file mode 100644 index 000000000..66c3437df --- /dev/null +++ b/src/containers/shared/components/Dropdown/test/DropdownItem.test.tsx @@ -0,0 +1,65 @@ +import { mount } from 'enzyme' +import { DropdownItem } from '../DropdownItem' +import createSpy = jasmine.createSpy + +describe('DropdownItem', () => { + describe(`prop: className`, () => { + it('renders with custom className', () => { + const wrapper = mount( + Hello, + ) + expect(wrapper.find('.dropdown-item')).toHaveClassName('custom') + }) + }) + + describe('prop: handler', () => { + let wrapper + const handler = createSpy('handler') + + beforeEach(() => { + wrapper = mount(Hello) + }) + + it('renders as an anchor tag', () => { + expect(wrapper.find('.dropdown-item')).toHaveDisplayName('a') + }) + + it('executes handler on click', () => { + wrapper.find('.dropdown-item').simulate('click') + expect(handler).toHaveBeenCalled() + }) + + it('executes handler on keyup', () => { + wrapper.find('.dropdown-item').simulate('click') + expect(handler).toHaveBeenCalled() + }) + }) + + describe('prop: href', () => { + let wrapper + + beforeEach(() => { + wrapper = mount( + Hello, + ) + }) + + it('renders as an anchor tag', () => { + expect(wrapper.find('.dropdown-item')).toHaveDisplayName('a') + }) + + it('renders href attribute on anchor', () => { + expect(wrapper.find('.dropdown-item')).toHaveProp('href') + }) + }) + + it('renders as div without handler or href', () => { + const wrapper = mount(Hello) + expect(wrapper.find('.dropdown-item')).toHaveDisplayName('div') + }) + + it('adds aria roles', () => { + const wrapper = mount(Hello) + expect(wrapper.find('.dropdown-item')).toHaveProp('role', 'menuitem') + }) +}) diff --git a/src/containers/shared/css/nested-menu.scss b/src/containers/shared/css/nested-menu.scss deleted file mode 100644 index fc68ac717..000000000 --- a/src/containers/shared/css/nested-menu.scss +++ /dev/null @@ -1,44 +0,0 @@ -@import './variables'; - -.nested-menu { - position: relative; - display: inline-block; - - .title { - margin-right: 10px; - } - - .arrow { - position: relative; - top: 1px; - } - - .nested-items { - width: 100%; - padding: 8px 0px; - border-radius: 4px; - background-color: $white; - } - - .vertical { - margin-bottom: 1px; - - &:hover, - &:focus { - background-color: $black-10; - color: $black-80; - @include bold; - } - } - - .menu-item { - display: flex; - justify-content: space-between; - font-size: 16px; - line-height: 16px; - - &:hover { - background-color: $black-10; - } - } -}