Remove sui popup (#5924)
* Add custom popup * Render popup window with react portal * Handle scroll * Handle pointer scroll * Refactor types * Add useOutsideClick * Restore button styles * Update types * Add tests * Set multiple refs * Cleanup * Cleanup * Move offset const to the utils * Add useCallback in useScroll * Remove empty lines in useElementPosition * Change styled components to functional * Make sure popup window stays in view * Highlight trigger button when popup is open * Add uesPopupWindow hook * Make triangle larger * Use full trigger height * Apply new lint rules Co-authored-by: Jaco Greeff <jacogr@gmail.com>
This commit is contained in:
parent
ead840ea74
commit
b6c827a908
@ -127,7 +127,6 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
|
||||
const [isPasswordOpen, togglePassword] = useToggle();
|
||||
const [isRecoverAccountOpen, toggleRecoverAccount] = useToggle();
|
||||
const [isRecoverSetupOpen, toggleRecoverSetup] = useToggle();
|
||||
const [isSettingsOpen, toggleSettings] = useToggle();
|
||||
const [isTransferOpen, toggleTransfer] = useToggle();
|
||||
const [isDelegateOpen, toggleDelegate] = useToggle();
|
||||
const [isUndelegateOpen, toggleUndelegate] = useToggle();
|
||||
@ -626,24 +625,16 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
|
||||
)}
|
||||
<Popup
|
||||
className={`theme--${theme}`}
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={toggleSettings}
|
||||
trigger={
|
||||
<Button
|
||||
icon='ellipsis-v'
|
||||
isDisabled={!menuItems.length}
|
||||
onClick={toggleSettings}
|
||||
/>
|
||||
isDisabled={!menuItems.length}
|
||||
value={
|
||||
<Menu
|
||||
text
|
||||
vertical
|
||||
>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Menu
|
||||
onClick={toggleSettings}
|
||||
text
|
||||
vertical
|
||||
>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
</Popup>
|
||||
/>
|
||||
</td>
|
||||
<td className='links media--1400'>
|
||||
<LinkExternal
|
||||
@ -654,9 +645,11 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
|
||||
/>
|
||||
</td>
|
||||
<td className='button'>
|
||||
<div className='table-column-icon'
|
||||
<div
|
||||
className='table-column-icon'
|
||||
data-testid='row-toggle'
|
||||
onClick={toggleIsExpanded}>
|
||||
onClick={toggleIsExpanded}
|
||||
>
|
||||
<Icon icon={
|
||||
isExpanded
|
||||
? 'caret-up'
|
||||
@ -669,8 +662,10 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
|
||||
<tr className={`${className} ${isExpanded ? 'isExpanded' : 'isCollapsed'} ${isEven ? 'isEven' : 'isOdd'}`}>
|
||||
<td colSpan={2} />
|
||||
<td>
|
||||
<div className='tags'
|
||||
data-testid='tags'>
|
||||
<div
|
||||
className='tags'
|
||||
data-testid='tags'
|
||||
>
|
||||
<Tags
|
||||
value={tags}
|
||||
withTitle
|
||||
|
||||
@ -40,7 +40,6 @@ function Address ({ address, className = '', filter, isFavorite, toggleFavorite
|
||||
const [current, setCurrent] = useState<KeyringAddress | null>(null);
|
||||
const [genesisHash, setGenesisHash] = useState<string | null>(null);
|
||||
const [isForgetOpen, setIsForgetOpen] = useState(false);
|
||||
const [isSettingPopupOpen, setIsSettingPopupOpen] = useState(false);
|
||||
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
@ -113,11 +112,6 @@ function Address ({ address, className = '', filter, isFavorite, toggleFavorite
|
||||
[isForgetOpen]
|
||||
);
|
||||
|
||||
const _toggleSettingPopup = useCallback(
|
||||
(): void => setIsSettingPopupOpen(!isSettingPopupOpen),
|
||||
[isSettingPopupOpen]
|
||||
);
|
||||
|
||||
const _toggleTransfer = useCallback(
|
||||
(): void => setIsTransferOpen(!isTransferOpen),
|
||||
[isTransferOpen]
|
||||
@ -148,6 +142,30 @@ function Address ({ address, className = '', filter, isFavorite, toggleFavorite
|
||||
return null;
|
||||
}
|
||||
|
||||
const PopupDropdown = (
|
||||
<Menu
|
||||
text
|
||||
vertical
|
||||
>
|
||||
<Menu.Item
|
||||
disabled={!isEditable}
|
||||
onClick={_toggleForget}
|
||||
>
|
||||
{t<string>('Forget this address')}
|
||||
</Menu.Item>
|
||||
{isEditable && !api.isDevelopment && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<ChainLock
|
||||
className='addresses--network-toggle'
|
||||
genesisHash={genesisHash}
|
||||
onChange={_onGenesisChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className={className}>
|
||||
<td className='favorite'>
|
||||
@ -207,38 +225,8 @@ function Address ({ address, className = '', filter, isFavorite, toggleFavorite
|
||||
)}
|
||||
<Popup
|
||||
className={`theme--${theme}`}
|
||||
isOpen={isSettingPopupOpen}
|
||||
onClose={_toggleSettingPopup}
|
||||
trigger={
|
||||
<Button
|
||||
icon='ellipsis-v'
|
||||
onClick={_toggleSettingPopup}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Menu
|
||||
onClick={_toggleSettingPopup}
|
||||
text
|
||||
vertical
|
||||
>
|
||||
<Menu.Item
|
||||
disabled={!isEditable}
|
||||
onClick={_toggleForget}
|
||||
>
|
||||
{t<string>('Forget this address')}
|
||||
</Menu.Item>
|
||||
{isEditable && !api.isDevelopment && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<ChainLock
|
||||
className='addresses--network-toggle'
|
||||
genesisHash={genesisHash}
|
||||
onChange={_onGenesisChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</Popup>
|
||||
value={PopupDropdown}
|
||||
/>
|
||||
</td>
|
||||
<td className='links media--1400'>
|
||||
<LinkExternal
|
||||
|
||||
@ -28,7 +28,6 @@ interface Props {
|
||||
}
|
||||
|
||||
function Index ({ bestNumber, className, description, index, proposals, status }: Props): React.ReactElement<Props> | null {
|
||||
const [isPopupOpen, togglePopupOpen] = useToggle();
|
||||
const [isCloseBountyOpen, toggleCloseBounty] = useToggle();
|
||||
const [isRejectCuratorOpen, toggleRejectCurator] = useToggle();
|
||||
const [isSlashCuratorOpen, toggleSlashCurator] = useToggle();
|
||||
@ -114,63 +113,60 @@ function Index ({ bestNumber, className, description, index, proposals, status }
|
||||
/>
|
||||
}
|
||||
<Popup
|
||||
isOpen={isPopupOpen}
|
||||
onClose={togglePopupOpen}
|
||||
trigger={
|
||||
<Button
|
||||
dataTestId='extra-actions'
|
||||
icon='ellipsis-v'
|
||||
onClick={togglePopupOpen}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Menu
|
||||
className='settings-menu'
|
||||
onClick={togglePopupOpen}
|
||||
text
|
||||
vertical
|
||||
>
|
||||
{showCloseBounty &&
|
||||
value={
|
||||
<Menu
|
||||
className='settings-menu'
|
||||
text
|
||||
vertical
|
||||
>
|
||||
{showCloseBounty &&
|
||||
<Menu.Item
|
||||
key='closeBounty'
|
||||
onClick={toggleCloseBounty}
|
||||
>
|
||||
{t<string>('Close')}
|
||||
</Menu.Item>
|
||||
}
|
||||
{showRejectCurator &&
|
||||
}
|
||||
{showRejectCurator &&
|
||||
<Menu.Item
|
||||
key='rejectCurator'
|
||||
onClick={toggleRejectCurator}
|
||||
>
|
||||
{t<string>('Reject curator')}
|
||||
</Menu.Item>
|
||||
}
|
||||
{showExtendExpiry &&
|
||||
}
|
||||
{showExtendExpiry &&
|
||||
<Menu.Item
|
||||
key='extendExpiry'
|
||||
onClick={toggleExtendExpiry}
|
||||
>
|
||||
{t<string>('Extend expiry')}
|
||||
</Menu.Item>
|
||||
}
|
||||
{showGiveUpCurator &&
|
||||
}
|
||||
{showGiveUpCurator &&
|
||||
<Menu.Item
|
||||
key='giveUpCurator'
|
||||
onClick={toggleGiveUpCurator}
|
||||
>
|
||||
{t<string>('Give up')}
|
||||
</Menu.Item>
|
||||
}
|
||||
{showSlashCurator && availableSlashActions.map((actionName) =>
|
||||
<Menu.Item
|
||||
key={actionName}
|
||||
onClick={slashCurator(actionName)}
|
||||
>
|
||||
{slashCuratorActionNames.current[actionName]}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
}
|
||||
{showSlashCurator && availableSlashActions.map((actionName) =>
|
||||
<Menu.Item
|
||||
key={actionName}
|
||||
onClick={slashCurator(actionName)}
|
||||
>
|
||||
{slashCuratorActionNames.current[actionName]}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
dataTestId='extra-actions'
|
||||
icon='ellipsis-v'
|
||||
isReadOnly={false}
|
||||
/>
|
||||
</Popup>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -20,7 +20,6 @@ interface Props {
|
||||
|
||||
function ActionButtons ({ className = '', isCustomExample, isRunning, removeSnippet, runJs, saveSnippet, stopJs }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [snippetName, setSnippetName] = useState('');
|
||||
|
||||
const _onChangeName = useCallback(
|
||||
@ -28,15 +27,9 @@ function ActionButtons ({ className = '', isCustomExample, isRunning, removeSnip
|
||||
[]
|
||||
);
|
||||
|
||||
const _onPopupOpen = useCallback(
|
||||
() => setIsOpen(true),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onPopupClose = useCallback(
|
||||
(): void => {
|
||||
setSnippetName('');
|
||||
setIsOpen(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@ -61,31 +54,31 @@ function ActionButtons ({ className = '', isCustomExample, isRunning, removeSnip
|
||||
: (
|
||||
<Popup
|
||||
className='popup-local'
|
||||
isOpen={isOpen}
|
||||
on='click'
|
||||
onClose={_onPopupClose}
|
||||
trigger={
|
||||
<Button
|
||||
icon='save'
|
||||
onClick={_onPopupOpen}
|
||||
/>
|
||||
onCloseAction={_onPopupClose}
|
||||
value={
|
||||
<>
|
||||
<Input
|
||||
autoFocus
|
||||
maxLength={50}
|
||||
min={1}
|
||||
onChange={_onChangeName}
|
||||
onEnter={_saveSnippet}
|
||||
placeholder={t<string>('Name your example')}
|
||||
value={snippetName}
|
||||
withLabel={false}
|
||||
/>
|
||||
<Button
|
||||
icon='save'
|
||||
isDisabled={!snippetName.length}
|
||||
label={t<string>('Save snippet to local storage')}
|
||||
onClick={_saveSnippet}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
maxLength={50}
|
||||
min={1}
|
||||
onChange={_onChangeName}
|
||||
onEnter={_saveSnippet}
|
||||
placeholder={t<string>('Name your example')}
|
||||
value={snippetName}
|
||||
withLabel={false}
|
||||
/>
|
||||
<Button
|
||||
icon='save'
|
||||
isDisabled={!snippetName.length}
|
||||
label={t<string>('Save snippet to local storage')}
|
||||
onClick={_saveSnippet}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
</Popup>
|
||||
)
|
||||
|
||||
@ -79,7 +79,6 @@ function Account ({ allSlashes, className = '', info: { controllerId, destinatio
|
||||
const [isRewardDestinationOpen, toggleRewardDestination] = useToggle();
|
||||
const [isSetControllerOpen, toggleSetController] = useToggle();
|
||||
const [isSetSessionOpen, toggleSetSession] = useToggle();
|
||||
const [isSettingsOpen, toggleSettings] = useToggle();
|
||||
const [isUnbondOpen, toggleUnbond] = useToggle();
|
||||
const [isValidateOpen, toggleValidate] = useToggle();
|
||||
const { balancesAll, spanCount, stakingAccount } = useStashCalls(api, stashId);
|
||||
@ -280,95 +279,87 @@ function Account ({ allSlashes, className = '', info: { controllerId, destinatio
|
||||
)
|
||||
}
|
||||
<Popup
|
||||
isOpen={isSettingsOpen}
|
||||
isDisabled={isDisabled}
|
||||
key='settings'
|
||||
onClose={toggleSettings}
|
||||
trigger={
|
||||
<Button
|
||||
icon='ellipsis-v'
|
||||
isDisabled={isDisabled}
|
||||
onClick={toggleSettings}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Menu
|
||||
onClick={toggleSettings}
|
||||
text
|
||||
vertical
|
||||
>
|
||||
<Menu.Item
|
||||
disabled={!isOwnStash || !balancesAll?.freeBalance.gtn(0)}
|
||||
onClick={toggleBondExtra}
|
||||
value={
|
||||
<Menu
|
||||
text
|
||||
vertical
|
||||
>
|
||||
{t<string>('Bond more funds')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={!isOwnController || !stakingAccount || !stakingAccount.stakingLedger || stakingAccount.stakingLedger.active?.isEmpty}
|
||||
onClick={toggleUnbond}
|
||||
>
|
||||
{t<string>('Unbond funds')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={!isOwnController || !stakingAccount || !stakingAccount.redeemable || !stakingAccount.redeemable.gtn(0)}
|
||||
onClick={withdrawFunds}
|
||||
>
|
||||
{t<string>('Withdraw unbonded funds')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
disabled={!isOwnStash}
|
||||
onClick={toggleSetController}
|
||||
>
|
||||
{t<string>('Change controller account')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={!isOwnController}
|
||||
onClick={toggleRewardDestination}
|
||||
>
|
||||
{t<string>('Change reward destination')}
|
||||
</Menu.Item>
|
||||
{isStashValidating && (
|
||||
<>
|
||||
<Menu.Item
|
||||
disabled={!isOwnController}
|
||||
onClick={toggleValidate}
|
||||
>
|
||||
{t<string>('Change validator preferences')}
|
||||
</Menu.Item>
|
||||
{isFunction(api.tx.staking.kick) && (
|
||||
<Menu.Item
|
||||
disabled={!isOwnStash || !balancesAll?.freeBalance.gtn(0)}
|
||||
onClick={toggleBondExtra}
|
||||
>
|
||||
{t<string>('Bond more funds')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={!isOwnController || !stakingAccount || !stakingAccount.stakingLedger || stakingAccount.stakingLedger.active?.isEmpty}
|
||||
onClick={toggleUnbond}
|
||||
>
|
||||
{t<string>('Unbond funds')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={!isOwnController || !stakingAccount || !stakingAccount.redeemable || !stakingAccount.redeemable.gtn(0)}
|
||||
onClick={withdrawFunds}
|
||||
>
|
||||
{t<string>('Withdraw unbonded funds')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
disabled={!isOwnStash}
|
||||
onClick={toggleSetController}
|
||||
>
|
||||
{t<string>('Change controller account')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={!isOwnController}
|
||||
onClick={toggleRewardDestination}
|
||||
>
|
||||
{t<string>('Change reward destination')}
|
||||
</Menu.Item>
|
||||
{isStashValidating && (
|
||||
<>
|
||||
<Menu.Item
|
||||
disabled={!isOwnController}
|
||||
onClick={toggleKick}
|
||||
onClick={toggleValidate}
|
||||
>
|
||||
{t<string>('Remove nominees')}
|
||||
{t<string>('Change validator preferences')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{!isStashNominating &&
|
||||
{isFunction(api.tx.staking.kick) && (
|
||||
<Menu.Item
|
||||
disabled={!isOwnController}
|
||||
onClick={toggleKick}
|
||||
>
|
||||
{t<string>('Remove nominees')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{!isStashNominating &&
|
||||
<Menu.Item
|
||||
disabled={!isOwnController}
|
||||
onClick={toggleSetSession}
|
||||
>
|
||||
{t<string>('Change session keys')}
|
||||
</Menu.Item>
|
||||
}
|
||||
{isStashNominating &&
|
||||
}
|
||||
{isStashNominating &&
|
||||
<Menu.Item
|
||||
disabled={!isOwnController || !targets.validators?.length}
|
||||
onClick={toggleNominate}
|
||||
>
|
||||
{t<string>('Set nominees')}
|
||||
</Menu.Item>
|
||||
}
|
||||
{!isStashNominating &&
|
||||
}
|
||||
{!isStashNominating &&
|
||||
<Menu.Item onClick={toggleInject}>
|
||||
{t<string>('Inject session keys (advanced)')}
|
||||
</Menu.Item>
|
||||
}
|
||||
</Menu>
|
||||
</Popup>
|
||||
}
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@ -9,7 +9,7 @@ import styled from 'styled-components';
|
||||
import Icon from '../Icon';
|
||||
import Spinner from '../Spinner';
|
||||
|
||||
function Button ({ children, className = '', dataTestId = '', icon, isBasic, isBusy, isCircular, isDisabled, isFull, isIcon, isSelected, isToplevel, label, onClick, onMouseEnter, onMouseLeave, tabIndex, withoutLink }: ButtonProps): React.ReactElement<ButtonProps> {
|
||||
function Button ({ children, className = '', dataTestId = '', icon, isBasic, isBusy, isCircular, isDisabled, isFull, isIcon, isSelected, isToplevel, label, onClick, isReadOnly = !onClick, onMouseEnter, onMouseLeave, tabIndex, withoutLink }: ButtonProps): React.ReactElement<ButtonProps> {
|
||||
const _onClick = useCallback(
|
||||
() => !(isBusy || isDisabled) && onClick && onClick(),
|
||||
[isBusy, isDisabled, onClick]
|
||||
@ -17,7 +17,7 @@ function Button ({ children, className = '', dataTestId = '', icon, isBasic, isB
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`ui--Button${label ? ' hasLabel' : ''}${isBasic ? ' isBasic' : ''}${isCircular ? ' isCircular' : ''}${isFull ? ' isFull' : ''}${isIcon ? ' isIcon' : ''}${(isBusy || isDisabled) ? ' isDisabled' : ''}${isBusy ? ' isBusy' : ''}${!onClick ? ' isReadOnly' : ''}${isSelected ? ' isSelected' : ''}${isToplevel ? ' isToplevel' : ''}${withoutLink ? ' withoutLink' : ''} ${className}`}
|
||||
className={`ui--Button${label ? ' hasLabel' : ''}${isBasic ? ' isBasic' : ''}${isCircular ? ' isCircular' : ''}${isFull ? ' isFull' : ''}${isIcon ? ' isIcon' : ''}${(isBusy || isDisabled) ? ' isDisabled' : ''}${isBusy ? ' isBusy' : ''}${isReadOnly ? ' isReadOnly' : ''}${isSelected ? ' isSelected' : ''}${isToplevel ? ' isToplevel' : ''}${withoutLink ? ' withoutLink' : ''} ${className}`}
|
||||
data-testid={dataTestId}
|
||||
onClick={_onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ButtonProps {
|
||||
isDisabled?: boolean;
|
||||
isFull?: boolean;
|
||||
isIcon?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isSelected?: boolean;
|
||||
isToplevel?: boolean;
|
||||
label?: React.ReactNode;
|
||||
|
||||
@ -14,10 +14,14 @@ interface ParentAccountProps {
|
||||
|
||||
function ParentAccount ({ address, className }: ParentAccountProps): React.ReactElement<ParentAccountProps> {
|
||||
return (
|
||||
<div className={className}
|
||||
data-testid='parent'>
|
||||
<Icon className='parent-icon'
|
||||
icon='code-branch'/>
|
||||
<div
|
||||
className={className}
|
||||
data-testid='parent'
|
||||
>
|
||||
<Icon
|
||||
className='parent-icon'
|
||||
icon='code-branch'
|
||||
/>
|
||||
|
||||
<AccountName
|
||||
value={address}
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
// Copyright 2017-2021 @polkadot/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PopupProps } from 'semantic-ui-react';
|
||||
|
||||
import React from 'react';
|
||||
import { Popup as SUIPopup } from 'semantic-ui-react';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isOpen?: boolean;
|
||||
on?: PopupProps['on'];
|
||||
onClose?: () => void;
|
||||
position?: PopupProps['position'];
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Popup ({ children, className = '', isOpen, on = 'click', onClose, position = 'bottom right', trigger }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<SUIPopup
|
||||
className={className}
|
||||
on={on}
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
position={position}
|
||||
trigger={trigger}
|
||||
>
|
||||
{children}
|
||||
</SUIPopup>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Popup);
|
||||
67
packages/react-components/src/Popup/Popup.spec.tsx
Normal file
67
packages/react-components/src/Popup/Popup.spec.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
// Copyright 2017-2021 @polkadot/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Popup } from '@polkadot/react-components';
|
||||
|
||||
function TestPopup () {
|
||||
return (
|
||||
<>
|
||||
<h1>Test outside text</h1>
|
||||
<Popup
|
||||
value={
|
||||
<div>
|
||||
Test popup content
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPopup () {
|
||||
return render(
|
||||
<TestPopup/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Popup Component', () => {
|
||||
it('opens and closes', async () => {
|
||||
renderPopup();
|
||||
|
||||
await expectPopupToBeClosed();
|
||||
await togglePopup();
|
||||
await expectPopupToBeOpen();
|
||||
await togglePopup();
|
||||
await expectPopupToBeClosed();
|
||||
});
|
||||
|
||||
it('closes popup with outside click', async () => {
|
||||
renderPopup();
|
||||
|
||||
await expectPopupToBeClosed();
|
||||
await togglePopup();
|
||||
await expectPopupToBeOpen();
|
||||
await clickOutside();
|
||||
await expectPopupToBeClosed();
|
||||
});
|
||||
});
|
||||
|
||||
async function expectPopupToBeClosed () {
|
||||
await screen.findByRole('button');
|
||||
expect(screen.queryAllByText('Test popup content')).toHaveLength(0);
|
||||
}
|
||||
|
||||
async function expectPopupToBeOpen () {
|
||||
await screen.findByText('Test popup content');
|
||||
}
|
||||
|
||||
async function togglePopup () {
|
||||
fireEvent.click(await screen.findByRole('button'));
|
||||
}
|
||||
|
||||
async function clickOutside () {
|
||||
fireEvent.click(await screen.findByText('Test outside text'));
|
||||
}
|
||||
84
packages/react-components/src/Popup/PopupWindow.tsx
Normal file
84
packages/react-components/src/Popup/PopupWindow.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
// Copyright 2017-2021 @polkadot/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PopupWindowProps } from './types';
|
||||
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { usePopupWindow } from '@polkadot/react-hooks/usePopupWindow';
|
||||
|
||||
function PopupWindow ({ children, className = '', position, triggerRef, windowRef }: PopupWindowProps) {
|
||||
const { renderWindowPosition, verticalPosition } = usePopupWindow(windowRef, triggerRef, position);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${className}${verticalPosition === 'top' ? ' pointerTop' : ' pointerBottom'}`}
|
||||
ref={windowRef}
|
||||
style={renderWindowPosition && { transform: `translate3d(${renderWindowPosition.x}px, ${renderWindowPosition.y}px, 0)`, zIndex: 300 }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(PopupWindow)`
|
||||
position: absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
z-index: -1;
|
||||
|
||||
margin: 0.7rem 0;
|
||||
padding: 0.85rem 1rem;
|
||||
|
||||
color: var(--color-text);
|
||||
background-color: var(--bg-menu);
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d4d4d5;
|
||||
box-shadow: 0 2px 4px 0 rgb(34 36 38 / 12%), 0 2px 10px 0 rgb(34 36 38 / 15%);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
right: 50%;
|
||||
top: unset;
|
||||
bottom: -0.45rem;
|
||||
box-shadow: 1px 1px 0 0 #bababc;
|
||||
|
||||
${({ position }) => position === 'left' && css`
|
||||
left: unset;
|
||||
right: 0.8rem;
|
||||
`}
|
||||
|
||||
${({ position }) => position === 'right' && css`
|
||||
left: 0.8rem;
|
||||
right: unset;
|
||||
`}
|
||||
|
||||
content: '';
|
||||
|
||||
background-color: var(--bg-menu);
|
||||
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transform: rotate(45deg);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&.pointerBottom::before {
|
||||
box-shadow: -1px -1px 0 0 #bababc;
|
||||
|
||||
top: -0.45rem;
|
||||
bottom: unset;
|
||||
}
|
||||
|
||||
.ui.text.menu .item {
|
||||
color: var(--color-text) !important;
|
||||
text-align: left;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
`);
|
||||
61
packages/react-components/src/Popup/index.tsx
Normal file
61
packages/react-components/src/Popup/index.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
// Copyright 2017-2021 @polkadot/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PopupProps } from './types';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Button } from '@polkadot/react-components/index';
|
||||
import { useOutsideClick, useToggle } from '@polkadot/react-hooks';
|
||||
|
||||
import PopupWindow from './PopupWindow';
|
||||
|
||||
function Popup ({ children, className = '', isDisabled = false, onCloseAction, position = 'left', value }: PopupProps) {
|
||||
const [isOpen, toggleIsOpen, setIsOpen] = useToggle(false);
|
||||
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick([triggerRef, dropdownRef], () => setIsOpen(false));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen && onCloseAction) {
|
||||
onCloseAction();
|
||||
}
|
||||
}, [isOpen, onCloseAction]);
|
||||
|
||||
return (
|
||||
<div className={`ui--Popup ${className}`}>
|
||||
{isOpen && (
|
||||
<PopupWindow
|
||||
position={position}
|
||||
triggerRef={triggerRef}
|
||||
windowRef={dropdownRef}
|
||||
>
|
||||
{value}
|
||||
</PopupWindow>
|
||||
)}
|
||||
<div
|
||||
onClick={toggleIsOpen}
|
||||
ref={triggerRef}
|
||||
>
|
||||
{children ?? (
|
||||
<Button
|
||||
className={isOpen ? 'isOpen' : ''}
|
||||
icon='ellipsis-v'
|
||||
isDisabled={isDisabled}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(Popup)`
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
`);
|
||||
24
packages/react-components/src/Popup/types.ts
Normal file
24
packages/react-components/src/Popup/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
// Copyright 2017-2021 @polkadot/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export type HorizontalPosition = 'left' | 'middle' | 'right'
|
||||
export type VerticalPosition = 'top' | 'bottom'
|
||||
|
||||
export interface PopupWindowProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
windowRef: React.RefObject<HTMLDivElement>;
|
||||
triggerRef: React.RefObject<HTMLDivElement>;
|
||||
position: HorizontalPosition;
|
||||
}
|
||||
|
||||
export interface PopupProps {
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
value?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
position?: HorizontalPosition;
|
||||
onCloseAction?: () => void;
|
||||
}
|
||||
76
packages/react-components/src/Popup/utils.ts
Normal file
76
packages/react-components/src/Popup/utils.ts
Normal file
@ -0,0 +1,76 @@
|
||||
// Copyright 2017-2021 @polkadot/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ElementPosition } from '@polkadot/react-hooks/useElementPosition';
|
||||
|
||||
import { WindowSize } from '@polkadot/react-hooks/useWindowSize';
|
||||
|
||||
import { HorizontalPosition, VerticalPosition } from './types';
|
||||
|
||||
// 0.8rem
|
||||
const POINTER_OFFSET = 14 * 0.8;
|
||||
// we don't want our popup window to cover apps menu
|
||||
const MENU_BAR_HEIGHT = 56;
|
||||
// adds 1rem margin between browsers window and popup
|
||||
const FIXED_VERTICAL_OFFSET_MARGIN = 14;
|
||||
|
||||
export function getPosition (
|
||||
triggerPosition: ElementPosition,
|
||||
positionX: HorizontalPosition,
|
||||
positionY: VerticalPosition,
|
||||
windowPosition: ElementPosition,
|
||||
scrollY: number,
|
||||
windowSize: WindowSize
|
||||
): {x: number, y: number} {
|
||||
const globalX = triggerPosition.x + (triggerPosition.width / 2);
|
||||
const globalY = triggerPosition.y + scrollY + (triggerPosition.height / 2);
|
||||
|
||||
return {
|
||||
x: globalX - getHorizontalOffset(windowPosition.width, positionX),
|
||||
y: fitsInView(positionY, triggerPosition, windowPosition.height, windowSize, scrollY)
|
||||
? globalY + getVerticalOffset(triggerPosition.height, positionY, windowPosition.height)
|
||||
: getFixedVerticalPosition(scrollY, positionY, windowSize, windowPosition.height)
|
||||
};
|
||||
}
|
||||
|
||||
function getHorizontalOffset (popupWindowWidth: number, position: HorizontalPosition): number {
|
||||
if (position === 'left') {
|
||||
return popupWindowWidth - POINTER_OFFSET;
|
||||
}
|
||||
|
||||
if (position === 'right') {
|
||||
return POINTER_OFFSET;
|
||||
}
|
||||
|
||||
return (popupWindowWidth / 2);
|
||||
}
|
||||
|
||||
function fitsInView (positionY: VerticalPosition, trigger: ElementPosition, popupWindowHeight: number, windowSize: WindowSize, scrollY: number): boolean {
|
||||
const { height: triggerHeight, y: triggerY } = trigger;
|
||||
|
||||
if (positionY === 'bottom') {
|
||||
return windowSize.height - triggerHeight - triggerY - FIXED_VERTICAL_OFFSET_MARGIN > popupWindowHeight;
|
||||
}
|
||||
|
||||
return scrollY < MENU_BAR_HEIGHT
|
||||
? triggerY - (MENU_BAR_HEIGHT - scrollY) > popupWindowHeight
|
||||
: triggerY > popupWindowHeight;
|
||||
}
|
||||
|
||||
function getVerticalOffset (triggerHeight: number, position: VerticalPosition, windowHeight: number): number {
|
||||
if (position === 'bottom') {
|
||||
return triggerHeight / 2;
|
||||
}
|
||||
|
||||
return (triggerHeight / 2 + windowHeight + POINTER_OFFSET) * -1;
|
||||
}
|
||||
|
||||
function getFixedVerticalPosition (scrollY: number, position: VerticalPosition, windowSize: WindowSize, popupWindowHeight: number): number {
|
||||
if (position === 'bottom') {
|
||||
return scrollY + windowSize.height - popupWindowHeight - FIXED_VERTICAL_OFFSET_MARGIN;
|
||||
}
|
||||
|
||||
return scrollY < MENU_BAR_HEIGHT
|
||||
? MENU_BAR_HEIGHT
|
||||
: scrollY;
|
||||
}
|
||||
@ -255,6 +255,16 @@ export default createGlobalStyle<Props & ThemeProps>(({ theme, uiHighlight }: Pr
|
||||
}
|
||||
}
|
||||
|
||||
.ui--Popup .ui--Button.isOpen:not(.isDisabled):not(.isReadOnly) {
|
||||
background: ${getHighlight(uiHighlight)} !important;
|
||||
color: ${getContrast(uiHighlight)} !important;
|
||||
|
||||
.ui--Icon {
|
||||
background: transparent !important;
|
||||
color: ${getContrast(uiHighlight)} !important;
|
||||
}
|
||||
}
|
||||
|
||||
.theme--dark,
|
||||
.theme--light {
|
||||
.ui--Tabs .tabLinkActive .tabLinkText::after{
|
||||
|
||||
@ -127,23 +127,6 @@ export default (_theme: ThemeDef): string => `
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.ui.popup {
|
||||
background: var(--bg-menu);
|
||||
color: var(--color-text);
|
||||
|
||||
.ui.text.menu .item {
|
||||
color: var(--color-text) !important;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&&::before {
|
||||
background: var(--bg-menu);
|
||||
}
|
||||
}
|
||||
|
||||
.ui.secondary.vertical.menu > .item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -52,3 +52,8 @@ export { useTxBatch } from './useTxBatch';
|
||||
export { useVotingStatus } from './useVotingStatus';
|
||||
export { useWeight } from './useWeight';
|
||||
export { useWeightFee } from './useWeightFee';
|
||||
export { useElementPosition } from './useElementPosition';
|
||||
export { useScroll } from './useScroll';
|
||||
export { useWindowSize } from './useWindowSize';
|
||||
export { useOutsideClick } from './useOutsideClick';
|
||||
export { usePopupWindow } from './usePopupWindow';
|
||||
|
||||
35
packages/react-hooks/src/useElementPosition.ts
Normal file
35
packages/react-hooks/src/useElementPosition.ts
Normal file
@ -0,0 +1,35 @@
|
||||
// Copyright 2017-2021 @polkadot/react-hooks authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useScroll } from '@polkadot/react-hooks/useScroll';
|
||||
import { useWindowSize } from '@polkadot/react-hooks/useWindowSize';
|
||||
|
||||
export interface ElementPosition {
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
}
|
||||
|
||||
export function useElementPosition (ref: React.MutableRefObject<HTMLElement | undefined | null>): ElementPosition | undefined {
|
||||
const [elementPosition, setElementPosition] = useState<ElementPosition>();
|
||||
const windowSize = useWindowSize();
|
||||
const scrollY = useScroll();
|
||||
|
||||
useEffect(() => {
|
||||
if (ref && ref.current) {
|
||||
const { height, width, x, y } = ref.current?.getBoundingClientRect();
|
||||
|
||||
setElementPosition({
|
||||
height,
|
||||
width,
|
||||
x,
|
||||
y
|
||||
});
|
||||
}
|
||||
}, [ref, scrollY, windowSize]);
|
||||
|
||||
return elementPosition;
|
||||
}
|
||||
24
packages/react-hooks/src/useOutsideClick.ts
Normal file
24
packages/react-hooks/src/useOutsideClick.ts
Normal file
@ -0,0 +1,24 @@
|
||||
// Copyright 2017-2021 @polkadot/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { RefObject, useCallback, useEffect } from 'react';
|
||||
|
||||
function getClickedElement (refs: React.RefObject<HTMLDivElement>[], e: MouseEvent) {
|
||||
return refs.find((ref) => ref.current && ref.current.contains(e.target as HTMLElement));
|
||||
}
|
||||
|
||||
export const useOutsideClick = (elements: RefObject<HTMLDivElement>[], callback: () => void): void => {
|
||||
const handleClick = useCallback((e: MouseEvent) => {
|
||||
if (elements.length && !getClickedElement(elements, e)) {
|
||||
callback();
|
||||
}
|
||||
}, [elements, callback]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [handleClick, callback]);
|
||||
};
|
||||
39
packages/react-hooks/src/usePopupWindow.ts
Normal file
39
packages/react-hooks/src/usePopupWindow.ts
Normal file
@ -0,0 +1,39 @@
|
||||
// Copyright 2017-2021 @polkadot/react-hooks authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { HorizontalPosition, VerticalPosition } from '@polkadot/react-components/Popup/types';
|
||||
import { getPosition } from '@polkadot/react-components/Popup/utils';
|
||||
|
||||
import { useElementPosition } from './useElementPosition';
|
||||
import { useScroll } from './useScroll';
|
||||
import { useWindowSize } from './useWindowSize';
|
||||
|
||||
export function usePopupWindow (
|
||||
windowRef: React.RefObject<HTMLDivElement>,
|
||||
triggerRef: React.RefObject<HTMLDivElement>,
|
||||
position: HorizontalPosition
|
||||
): {renderWindowPosition: {x: number, y: number} | undefined, verticalPosition: VerticalPosition | undefined} {
|
||||
const [renderWindowPosition, setRenderWindowPosition] = useState<{x: number, y: number}>();
|
||||
const [verticalPosition, setVerticalPosition] = useState<VerticalPosition>();
|
||||
const windowPosition = useElementPosition(windowRef);
|
||||
const triggerPosition = useElementPosition(triggerRef);
|
||||
|
||||
const scrollY = useScroll();
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
useEffect(() => {
|
||||
if (windowSize && triggerPosition) {
|
||||
setVerticalPosition((triggerPosition.y > windowSize.height / 2) ? 'top' : 'bottom');
|
||||
}
|
||||
}, [triggerPosition, windowSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowPosition && triggerPosition && verticalPosition) {
|
||||
setRenderWindowPosition(getPosition(triggerPosition, position, verticalPosition, windowPosition, scrollY, windowSize));
|
||||
}
|
||||
}, [position, scrollY, triggerPosition, verticalPosition, windowPosition, windowSize]);
|
||||
|
||||
return { renderWindowPosition, verticalPosition };
|
||||
}
|
||||
23
packages/react-hooks/src/useScroll.ts
Normal file
23
packages/react-hooks/src/useScroll.ts
Normal file
@ -0,0 +1,23 @@
|
||||
// Copyright 2017-2021 @polkadot/react-hooks authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export function useScroll (): number {
|
||||
const [scrollY, setScrollY] = useState(window.pageYOffset);
|
||||
const setYOffset = useCallback((): void => setScrollY(window.pageYOffset), []);
|
||||
|
||||
useEffect(() => {
|
||||
function watchScroll () {
|
||||
window.addEventListener('scroll', setYOffset);
|
||||
}
|
||||
|
||||
watchScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', setYOffset);
|
||||
};
|
||||
}, [setYOffset]);
|
||||
|
||||
return scrollY;
|
||||
}
|
||||
32
packages/react-hooks/src/useWindowSize.ts
Normal file
32
packages/react-hooks/src/useWindowSize.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// Copyright 2017-2021 @polkadot/react-hooks authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useWindowSize (): WindowSize {
|
||||
const [windowSize, setWindowSize] = useState<WindowSize>({
|
||||
height: 0,
|
||||
width: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize () {
|
||||
setWindowSize({
|
||||
height: window.innerHeight,
|
||||
width: window.innerWidth
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize();
|
||||
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user