diff --git a/packages/page-accounts/src/Accounts/Account.tsx b/packages/page-accounts/src/Accounts/Account.tsx
index b69424042e..2b2f535d6b 100644
--- a/packages/page-accounts/src/Accounts/Account.tsx
+++ b/packages/page-accounts/src/Accounts/Account.tsx
@@ -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
)}
+ isDisabled={!menuItems.length}
+ value={
+
}
- >
-
-
+ />
|
-
+ onClick={toggleIsExpanded}
+ >
|
-
+
(null);
const [genesisHash, setGenesisHash] = useState(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 = (
+
+ );
+
return (
|
@@ -207,38 +225,8 @@ function Address ({ address, className = '', filter, isFavorite, toggleFavorite
)}
- }
- >
-
-
+ value={PopupDropdown}
+ />
|
| 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 }
/>
}
- }
- >
-
+ }
+ >
+
)
diff --git a/packages/page-js/src/ActionButtons.tsx b/packages/page-js/src/ActionButtons.tsx
index f762aaae8e..02cfc8a773 100644
--- a/packages/page-js/src/ActionButtons.tsx
+++ b/packages/page-js/src/ActionButtons.tsx
@@ -20,7 +20,6 @@ interface Props {
function ActionButtons ({ className = '', isCustomExample, isRunning, removeSnippet, runJs, saveSnippet, stopJs }: Props): React.ReactElement {
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
: (
+ onCloseAction={_onPopupClose}
+ value={
+ <>
+ ('Name your example')}
+ value={snippetName}
+ withLabel={false}
+ />
+ |
diff --git a/packages/react-components/src/Button/Button.tsx b/packages/react-components/src/Button/Button.tsx
index e913b80e9e..02a93e14f7 100644
--- a/packages/react-components/src/Button/Button.tsx
+++ b/packages/react-components/src/Button/Button.tsx
@@ -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 {
+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 {
const _onClick = useCallback(
() => !(isBusy || isDisabled) && onClick && onClick(),
[isBusy, isDisabled, onClick]
@@ -17,7 +17,7 @@ function Button ({ children, className = '', dataTestId = '', icon, isBasic, isB
return (
{
return (
-
-
+
+
void;
- position?: PopupProps['position'];
- trigger?: React.ReactNode;
-}
-
-function Popup ({ children, className = '', isOpen, on = 'click', onClose, position = 'bottom right', trigger }: Props): React.ReactElement {
- return (
-
- {children}
-
- );
-}
-
-export default React.memo(Popup);
diff --git a/packages/react-components/src/Popup/Popup.spec.tsx b/packages/react-components/src/Popup/Popup.spec.tsx
new file mode 100644
index 0000000000..b90e2f575f
--- /dev/null
+++ b/packages/react-components/src/Popup/Popup.spec.tsx
@@ -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 (
+ <>
+ Test outside text
+
+ Test popup content
+
+ }
+ />
+ >
+ );
+}
+
+function renderPopup () {
+ return render(
+
+ );
+}
+
+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'));
+}
diff --git a/packages/react-components/src/Popup/PopupWindow.tsx b/packages/react-components/src/Popup/PopupWindow.tsx
new file mode 100644
index 0000000000..87f5de166e
--- /dev/null
+++ b/packages/react-components/src/Popup/PopupWindow.tsx
@@ -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(
+
+ {children}
+ ,
+ 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;
+ }
+ }
+`);
diff --git a/packages/react-components/src/Popup/index.tsx b/packages/react-components/src/Popup/index.tsx
new file mode 100644
index 0000000000..a63a6f73d8
--- /dev/null
+++ b/packages/react-components/src/Popup/index.tsx
@@ -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 (null);
+ const dropdownRef = useRef(null);
+
+ useOutsideClick([triggerRef, dropdownRef], () => setIsOpen(false));
+
+ useEffect(() => {
+ if (!isOpen && onCloseAction) {
+ onCloseAction();
+ }
+ }, [isOpen, onCloseAction]);
+
+ return (
+
+ {isOpen && (
+
+ {value}
+
+ )}
+
+ {children ?? (
+
+ )}
+
+
+ );
+}
+
+export default React.memo(styled(Popup)`
+ display: inline-flex;
+ flex-direction: column;
+ justify-content: center;
+ position: relative;
+`);
diff --git a/packages/react-components/src/Popup/types.ts b/packages/react-components/src/Popup/types.ts
new file mode 100644
index 0000000000..ab9c69b018
--- /dev/null
+++ b/packages/react-components/src/Popup/types.ts
@@ -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;
+ triggerRef: React.RefObject;
+ position: HorizontalPosition;
+}
+
+export interface PopupProps {
+ isDisabled?: boolean;
+ className?: string;
+ value?: React.ReactNode;
+ children?: React.ReactNode;
+ position?: HorizontalPosition;
+ onCloseAction?: () => void;
+}
diff --git a/packages/react-components/src/Popup/utils.ts b/packages/react-components/src/Popup/utils.ts
new file mode 100644
index 0000000000..7f2df6d4e9
--- /dev/null
+++ b/packages/react-components/src/Popup/utils.ts
@@ -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;
+}
diff --git a/packages/react-components/src/styles/index.ts b/packages/react-components/src/styles/index.ts
index f63d583ac0..435f75cda2 100644
--- a/packages/react-components/src/styles/index.ts
+++ b/packages/react-components/src/styles/index.ts
@@ -255,6 +255,16 @@ export default createGlobalStyle(({ 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{
diff --git a/packages/react-components/src/styles/semantic.ts b/packages/react-components/src/styles/semantic.ts
index 405900c9d0..644e581c0b 100644
--- a/packages/react-components/src/styles/semantic.ts
+++ b/packages/react-components/src/styles/semantic.ts
@@ -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;
}
diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts
index 5c1ad2444c..eac7462487 100644
--- a/packages/react-hooks/src/index.ts
+++ b/packages/react-hooks/src/index.ts
@@ -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';
diff --git a/packages/react-hooks/src/useElementPosition.ts b/packages/react-hooks/src/useElementPosition.ts
new file mode 100644
index 0000000000..a0ac353e6f
--- /dev/null
+++ b/packages/react-hooks/src/useElementPosition.ts
@@ -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): ElementPosition | undefined {
+ const [elementPosition, setElementPosition] = useState();
+ 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;
+}
diff --git a/packages/react-hooks/src/useOutsideClick.ts b/packages/react-hooks/src/useOutsideClick.ts
new file mode 100644
index 0000000000..417b02d45a
--- /dev/null
+++ b/packages/react-hooks/src/useOutsideClick.ts
@@ -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[], e: MouseEvent) {
+ return refs.find((ref) => ref.current && ref.current.contains(e.target as HTMLElement));
+}
+
+export const useOutsideClick = (elements: RefObject[], 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]);
+};
diff --git a/packages/react-hooks/src/usePopupWindow.ts b/packages/react-hooks/src/usePopupWindow.ts
new file mode 100644
index 0000000000..e50a619352
--- /dev/null
+++ b/packages/react-hooks/src/usePopupWindow.ts
@@ -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,
+ triggerRef: React.RefObject,
+ position: HorizontalPosition
+): {renderWindowPosition: {x: number, y: number} | undefined, verticalPosition: VerticalPosition | undefined} {
+ const [renderWindowPosition, setRenderWindowPosition] = useState<{x: number, y: number}>();
+ const [verticalPosition, setVerticalPosition] = useState();
+ 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 };
+}
diff --git a/packages/react-hooks/src/useScroll.ts b/packages/react-hooks/src/useScroll.ts
new file mode 100644
index 0000000000..15bc81a0f6
--- /dev/null
+++ b/packages/react-hooks/src/useScroll.ts
@@ -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;
+}
diff --git a/packages/react-hooks/src/useWindowSize.ts b/packages/react-hooks/src/useWindowSize.ts
new file mode 100644
index 0000000000..e0812f3c3e
--- /dev/null
+++ b/packages/react-hooks/src/useWindowSize.ts
@@ -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({
+ 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;
+}
| |