diff --git a/packages/plasma-b2c/api/plasma-b2c.api.md b/packages/plasma-b2c/api/plasma-b2c.api.md index 61a0859751..957e6e4639 100644 --- a/packages/plasma-b2c/api/plasma-b2c.api.md +++ b/packages/plasma-b2c/api/plasma-b2c.api.md @@ -169,6 +169,9 @@ import { Popover } from '@salutejs/plasma-hope'; import { PopoverPlacement } from '@salutejs/plasma-hope'; import { PopoverProps } from '@salutejs/plasma-hope'; import { Popup } from '@salutejs/plasma-hope'; +import { PopupBase } from '@salutejs/plasma-hope'; +import { PopupBasePlacement } from '@salutejs/plasma-hope'; +import { PopupBaseProps } from '@salutejs/plasma-hope'; import { PopupProps } from '@salutejs/plasma-hope'; import { PreviewGallery } from '@salutejs/plasma-hope'; import { PreviewGalleryItemProps } from '@salutejs/plasma-hope'; @@ -609,6 +612,12 @@ export { PopoverProps } export { Popup } +export { PopupBase } + +export { PopupBasePlacement } + +export { PopupBaseProps } + export { PopupProps } export { PreviewGallery } diff --git a/packages/plasma-b2c/src/components/PopupBase/PopupBase.stories.tsx b/packages/plasma-b2c/src/components/PopupBase/PopupBase.stories.tsx new file mode 100644 index 0000000000..a5511de4cc --- /dev/null +++ b/packages/plasma-b2c/src/components/PopupBase/PopupBase.stories.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import styled, { keyframes } from 'styled-components'; +import { Story, Meta } from '@storybook/react'; +import { InSpacingDecorator } from '@salutejs/plasma-sb-utils'; +import { surfaceSolid03 } from '@salutejs/plasma-tokens-web'; + +import { SSRProvider } from '../SSRProvider'; +import { Button } from '../Button'; + +import { PopupBase } from '.'; + +export default { + title: 'Controls/PopupBase', + decorators: [InSpacingDecorator], + argTypes: { + position: { + options: [ + 'center', + 'top', + 'bottom', + 'right', + 'left', + 'top-right', + 'top-left', + 'bottom-right', + 'bottom-left', + ], + control: { + type: 'select', + }, + }, + }, +} as Meta; + +type PopupBaseStoryProps = { position: string; offsetX: number; offsetY: number }; + +const showAnimation = keyframes` + 0% { + transform: translateX(100%); + opacity: 0; + } + + 100% { + transform: translateX(0); + opacity: 1; + } +`; +const hideAnimation = keyframes` + 0% { + transform: translateX(0); + opacity: 1; + } + + 100% { + transform: translateX(100%); + opacity: 0; + } +`; + +const StyledButton = styled(Button)` + margin-top: 1rem; + width: 15rem; +`; + +const StyledWrapper = styled.div` + height: 1200px; +`; + +const OtherContent = styled.div` + margin-top: 1rem; + width: 400px; + height: 500px; + background: ${surfaceSolid03}; + position: absolute; + + display: flex; + align-items: flex-start; + justify-content: center; + padding: 1rem; + + top: 0; + right: 0; +`; + +export const PopupBaseDemo: Story = ({ position, offsetX, offsetY }) => { + const [isOpenA, setIsOpenA] = React.useState(false); + const [isOpenB, setIsOpenB] = React.useState(false); + + const ref = React.useRef(null); + + return ( + + +
+ setIsOpenA(true)} /> + setIsOpenB(true)} /> +
+ +
+ + <>Content +
+
+ + <>Frame + + +
+ + <>Content +
+
+
+
+ ); +}; + +PopupBaseDemo.args = { + position: 'center', + offsetX: 0, + offsetY: 0, +}; diff --git a/packages/plasma-b2c/src/components/PopupBase/index.ts b/packages/plasma-b2c/src/components/PopupBase/index.ts new file mode 100644 index 0000000000..312f45f3bf --- /dev/null +++ b/packages/plasma-b2c/src/components/PopupBase/index.ts @@ -0,0 +1,2 @@ +export { PopupBase } from '@salutejs/plasma-hope'; +export type { PopupBaseProps, PopupBasePlacement } from '@salutejs/plasma-hope'; diff --git a/packages/plasma-b2c/src/index.ts b/packages/plasma-b2c/src/index.ts index 97b15aea62..648424960b 100644 --- a/packages/plasma-b2c/src/index.ts +++ b/packages/plasma-b2c/src/index.ts @@ -17,6 +17,7 @@ export * from './components/Modal'; export * from './components/Notification'; export * from './components/PaginationDots'; export * from './components/Popup'; +export * from './components/PopupBase'; export * from './components/Popover'; export * from './components/PreviewGallery'; export * from './components/Price'; diff --git a/packages/plasma-core/api/plasma-core.api.md b/packages/plasma-core/api/plasma-core.api.md index 5dae0110b4..1cacd66166 100644 --- a/packages/plasma-core/api/plasma-core.api.md +++ b/packages/plasma-core/api/plasma-core.api.md @@ -21,6 +21,7 @@ import { FlattenSimpleInterpolation } from 'styled-components'; import { FunctionComponent } from 'react'; import { HTMLAttributes } from 'react'; import { InterpolationFunction } from 'styled-components'; +import { Keyframes } from 'styled-components'; import { MutableRefObject } from 'react'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; @@ -841,6 +842,29 @@ export interface PopoverProps extends HTMLAttributes { // @public @deprecated export const Popup: React_2.NamedExoticComponent>; +// @public +export const PopupBase: FC; + +// Warning: (ae-forgotten-export) The symbol "BasicPopupBasePlacement" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "MixedPopupBasePlacement" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type PopupBasePlacement = BasicPopupBasePlacement | MixedPopupBasePlacement; + +// @public (undocumented) +export interface PopupBaseProps extends React_2.HTMLAttributes { + children?: React_2.ReactNode; + frame: 'document' | React_2.RefObject; + hideAnimation?: Keyframes; + isOpen: boolean; + // (undocumented) + offset?: [number | string, number | string]; + // (undocumented) + position?: PopupBasePlacement; + showAnimation?: Keyframes; + zIndex?: string; +} + // @public (undocumented) export interface PopupProps extends HTMLAttributes { children?: ReactNode; diff --git a/packages/plasma-core/src/components/PopupBase/PopupBase.tsx b/packages/plasma-core/src/components/PopupBase/PopupBase.tsx new file mode 100644 index 0000000000..a68801f4be --- /dev/null +++ b/packages/plasma-core/src/components/PopupBase/PopupBase.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useRef, useState, useContext, FC } from 'react'; +import ReactDOM from 'react-dom'; +import styled, { Keyframes, css } from 'styled-components'; + +import { useUniqId } from '../../hooks'; + +import { PopupBaseContext, POPOVER_PORTAL_ID } from './PopupBaseContext'; + +type BasicPopupBasePlacement = 'center' | 'top' | 'bottom' | 'right' | 'left'; +type MixedPopupBasePlacement = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; +export type PopupBasePlacement = BasicPopupBasePlacement | MixedPopupBasePlacement; + +export interface PopupBaseProps extends React.HTMLAttributes { + /** + * Отображение PopupBase. + */ + isOpen: boolean; + /* Позиция на экране + * center - по умолчанию + * left, right, top, bottom и их комбинации + */ + position?: PopupBasePlacement; + /* Смещение отнсительно текущей позиции налево и вверх + * (x, y) - или проценты + */ + offset?: [number | string, number | string]; + /** + * В каком контейнере позиционируется. + */ + frame: 'document' | React.RefObject; + /** + * Содержимое PopupBase. + */ + children?: React.ReactNode; + /** + * Анимация при появлении PopupBase. + */ + showAnimation?: Keyframes; + /** + * Анимация при скрытии PopupBase. + */ + hideAnimation?: Keyframes; + /** + * Значение z-index для PopupBase. + */ + zIndex?: string; +} + +interface HidingProps { + isHiding?: boolean; +} + +const DEFAULT_Z_INDEX = 9000; + +interface PopupBaseRootProps { + position?: PopupBasePlacement; + frame: 'document' | React.RefObject; + offset?: [number | string, number | string]; + showAnimation?: Keyframes; + hideAnimation?: Keyframes; + zIndex?: string; +} + +const PopupBaseView = styled.div` + position: relative; + max-width: 100%; + pointer-events: all; +`; + +const handlePosition = (position?: PopupBasePlacement, offset?: [number | string, number | string]) => { + let x = ''; + let y = ''; + if (offset) { + const [_x, _y] = offset; + x = typeof _x === 'number' ? `${_x}rem` : _x; + y = typeof _y === 'number' ? `${_y}rem` : _y; + } + + if (!position || position === 'center') { + return css` + left: calc(50% + ${x}); + top: calc(50% - ${y}); + transform: translate(-50%, -50%); + `; + } + + let left; + let right; + let top; + let bottom; + const placements = position.split('-') as BasicPopupBasePlacement[]; + + placements.forEach((placement: BasicPopupBasePlacement) => { + switch (placement) { + case 'left': + left = 0; + break; + case 'right': + right = 0; + break; + case 'top': + top = 0; + break; + case 'bottom': + bottom = 0; + break; + default: + break; + } + }); + + const isCenteredX = left !== 0 && right !== 0; + const isCenteredY = top !== 0 && bottom !== 0; + + return css` + left: calc(${left} + ${x}); + right: ${right}; + top: calc(${top} - ${y}); + bottom: ${bottom}; + ${isCenteredX && + css` + left: calc(50% + ${x}); + transform: translateX(-50%); + `} + ${isCenteredY && + css` + top: calc(50% - ${y}); + transform: translateY(-50%); + `} + `; +}; + +const PopupBaseRoot = styled.div` + ${({ frame }) => css` + position: ${frame === 'document' ? 'fixed' : 'absolute'}; + `} + + ${({ zIndex }) => css` + z-index: ${zIndex || DEFAULT_Z_INDEX}; + `} + + ${({ position, offset }) => handlePosition(position, offset)}; + + ${({ isHiding, showAnimation, hideAnimation }) => css` + animation: 0.4s ${isHiding ? hideAnimation : showAnimation} ease-out; + `} +`; + +/** + * Базовый PopupBase. + * Управляет показом/скрытием и анимацией(?) высплывающего окна. + */ +export const PopupBase: FC = ({ + id, + isOpen, + position, + offset, + frame = 'document', + children, + role, + zIndex, + showAnimation, + hideAnimation, + ...rest +}) => { + const uniqId = useUniqId(); + const innerId = id || uniqId; + + const portalRef = useRef(null); + + const controller = useContext(PopupBaseContext); + + const [, forceRender] = useState(false); + + useEffect(() => { + let portal = document.getElementById(POPOVER_PORTAL_ID); + + if (frame !== 'document' && frame && frame.current) { + portal = frame.current; + } + + if (!portal) { + portal = document.createElement('div'); + portal.setAttribute('id', POPOVER_PORTAL_ID); + document.body.appendChild(portal); + } + + portalRef.current = portal; + + /** + * Изменение стейта нужно для того, чтобы PopupBase + * отобразился после записи DOM элемента в portalRef.current + */ + forceRender(true); + + return () => { + controller.unregister(innerId); + }; + }, [controller, innerId, zIndex]); + + if (isOpen) { + controller.register(innerId); + } else { + controller.unregister(innerId); + return null; + } + + return ( + <> + {portalRef.current && + ReactDOM.createPortal( + + + {children} + + , + portalRef.current, + )} + + ); +}; diff --git a/packages/plasma-core/src/components/PopupBase/PopupBaseContext.tsx b/packages/plasma-core/src/components/PopupBase/PopupBaseContext.tsx new file mode 100644 index 0000000000..5b519ff416 --- /dev/null +++ b/packages/plasma-core/src/components/PopupBase/PopupBaseContext.tsx @@ -0,0 +1,35 @@ +import React, { ReactNode, useEffect } from 'react'; + +/** + * Хранилище модальных окон. + */ +class PopupBaseController { + public items: string[] = []; + + public register(id: string) { + return this.items.push(id); + } + + public unregister(id: string) { + this.items.splice(this.items.indexOf(id), 1); + } +} + +const controller = new PopupBaseController(); + +export const POPOVER_PORTAL_ID = 'plasma-popup-root'; + +export const PopupBaseContext = React.createContext(controller); + +export const PopupBaseProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + useEffect(() => { + return () => { + const portal = document.createElement('div'); + if (portal && document.body.contains(portal)) { + document.body.removeChild(portal); + } + }; + }, []); + + return {children}; +}; diff --git a/packages/plasma-core/src/components/PopupBase/index.ts b/packages/plasma-core/src/components/PopupBase/index.ts new file mode 100644 index 0000000000..e9ec8717f5 --- /dev/null +++ b/packages/plasma-core/src/components/PopupBase/index.ts @@ -0,0 +1,2 @@ +export { PopupBase } from './PopupBase'; +export type { PopupBaseProps, PopupBasePlacement } from './PopupBase'; diff --git a/packages/plasma-core/src/index.ts b/packages/plasma-core/src/index.ts index e7fe64fccf..2774fbe9f9 100644 --- a/packages/plasma-core/src/index.ts +++ b/packages/plasma-core/src/index.ts @@ -8,6 +8,7 @@ export * from './components/Image'; export * from './components/Input'; export * from './components/PaginationDots'; export * from './components/Popup'; +export * from './components/PopupBase'; export * from './components/Popover'; export * from './components/Price'; export * from './components/RadioGroup'; diff --git a/packages/plasma-hope/api/plasma-hope.api.md b/packages/plasma-hope/api/plasma-hope.api.md index ebe958f746..b1965a59ec 100644 --- a/packages/plasma-hope/api/plasma-hope.api.md +++ b/packages/plasma-hope/api/plasma-hope.api.md @@ -115,6 +115,9 @@ import { Popover } from '@salutejs/plasma-core'; import { PopoverPlacement } from '@salutejs/plasma-core'; import { PopoverProps } from '@salutejs/plasma-core'; import { Popup } from '@salutejs/plasma-core'; +import { PopupBase } from '@salutejs/plasma-core'; +import { PopupBasePlacement } from '@salutejs/plasma-core'; +import { PopupBaseProps } from '@salutejs/plasma-core'; import { PopupProps } from '@salutejs/plasma-core'; import type { PriceProps as PriceProps_2 } from '@salutejs/plasma-core'; import { PropsWithChildren } from 'react'; @@ -1005,6 +1008,12 @@ export { PopoverProps } export { Popup } +export { PopupBase } + +export { PopupBasePlacement } + +export { PopupBaseProps } + export { PopupProps } // @public diff --git a/packages/plasma-hope/src/components/PopupBase/index.ts b/packages/plasma-hope/src/components/PopupBase/index.ts new file mode 100644 index 0000000000..be803c11df --- /dev/null +++ b/packages/plasma-hope/src/components/PopupBase/index.ts @@ -0,0 +1,2 @@ +export { PopupBase } from '@salutejs/plasma-core'; +export type { PopupBaseProps, PopupBasePlacement } from '@salutejs/plasma-core'; diff --git a/packages/plasma-hope/src/index.ts b/packages/plasma-hope/src/index.ts index 49ba3fe72e..969017a45a 100644 --- a/packages/plasma-hope/src/index.ts +++ b/packages/plasma-hope/src/index.ts @@ -17,6 +17,7 @@ export * from './components/Modal'; export * from './components/Notification'; export * from './components/PaginationDots'; export * from './components/Popup'; +export * from './components/PopupBase'; export * from './components/Popover'; export * from './components/Price'; export * from './components/Progress'; diff --git a/packages/plasma-web/api/plasma-web.api.md b/packages/plasma-web/api/plasma-web.api.md index 8cbb90617b..c4b5d3bb37 100644 --- a/packages/plasma-web/api/plasma-web.api.md +++ b/packages/plasma-web/api/plasma-web.api.md @@ -169,6 +169,9 @@ import { Popover } from '@salutejs/plasma-hope'; import { PopoverPlacement } from '@salutejs/plasma-hope'; import { PopoverProps } from '@salutejs/plasma-hope'; import { Popup } from '@salutejs/plasma-hope'; +import { PopupBase } from '@salutejs/plasma-hope'; +import { PopupBasePlacement } from '@salutejs/plasma-hope'; +import { PopupBaseProps } from '@salutejs/plasma-hope'; import { PopupProps } from '@salutejs/plasma-hope'; import { PreviewGallery } from '@salutejs/plasma-hope'; import { PreviewGalleryItemProps } from '@salutejs/plasma-hope'; @@ -609,6 +612,12 @@ export { PopoverProps } export { Popup } +export { PopupBase } + +export { PopupBasePlacement } + +export { PopupBaseProps } + export { PopupProps } export { PreviewGallery } diff --git a/packages/plasma-web/src/components/PopupBase/PopupBase.stories.tsx b/packages/plasma-web/src/components/PopupBase/PopupBase.stories.tsx new file mode 100644 index 0000000000..994d2eb9e2 --- /dev/null +++ b/packages/plasma-web/src/components/PopupBase/PopupBase.stories.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import styled, { keyframes } from 'styled-components'; +import { Story, Meta } from '@storybook/react'; +import { surfaceSolid03 } from '@salutejs/plasma-tokens-web'; + +import { SSRProvider } from '../SSRProvider'; +import { InSpacingDecorator } from '../../helpers'; +import { Button } from '../Button'; + +import { PopupBase } from '.'; + +export default { + title: 'Controls/PopupBase', + decorators: [InSpacingDecorator], + argTypes: { + position: { + options: [ + 'center', + 'top', + 'bottom', + 'right', + 'left', + 'top-right', + 'top-left', + 'bottom-right', + 'bottom-left', + ], + control: { + type: 'select', + }, + }, + }, +} as Meta; + +type PopupBaseStoryProps = { position: string; offsetX: number; offsetY: number }; + +const showAnimation = keyframes` + 0% { + transform: translateX(100%); + opacity: 0; + } + + 100% { + transform: translateX(0); + opacity: 1; + } +`; +const hideAnimation = keyframes` + 0% { + transform: translateX(0); + opacity: 1; + } + + 100% { + transform: translateX(100%); + opacity: 0; + } +`; + +const StyledButton = styled(Button)` + margin-top: 1rem; + width: 15rem; +`; + +const StyledWrapper = styled.div` + height: 1200px; +`; + +const OtherContent = styled.div` + margin-top: 1rem; + width: 400px; + height: 500px; + background: ${surfaceSolid03}; + position: absolute; + + display: flex; + align-items: flex-start; + justify-content: center; + padding: 1rem; + + top: 0; + right: 0; +`; + +export const PopupBaseDemo: Story = ({ position, offsetX, offsetY }) => { + const [isOpenA, setIsOpenA] = React.useState(false); + const [isOpenB, setIsOpenB] = React.useState(false); + + const ref = React.useRef(null); + + return ( + + +
+ setIsOpenA(true)} /> + setIsOpenB(true)} /> +
+ +
+ + <>Content +
+
+ + <>Frame + + +
+ + <>Content +
+
+
+
+ ); +}; + +PopupBaseDemo.args = { + position: 'center', + offsetX: 0, + offsetY: 0, +}; diff --git a/packages/plasma-web/src/components/PopupBase/index.ts b/packages/plasma-web/src/components/PopupBase/index.ts new file mode 100644 index 0000000000..312f45f3bf --- /dev/null +++ b/packages/plasma-web/src/components/PopupBase/index.ts @@ -0,0 +1,2 @@ +export { PopupBase } from '@salutejs/plasma-hope'; +export type { PopupBaseProps, PopupBasePlacement } from '@salutejs/plasma-hope'; diff --git a/packages/plasma-web/src/index.ts b/packages/plasma-web/src/index.ts index 697becdb62..1051adfec3 100644 --- a/packages/plasma-web/src/index.ts +++ b/packages/plasma-web/src/index.ts @@ -17,6 +17,7 @@ export * from './components/Modal'; export * from './components/Notification'; export * from './components/PaginationDots'; export * from './components/Popup'; +export * from './components/PopupBase'; export * from './components/Popover'; export * from './components/Price'; export * from './components/Progress';