diff --git a/packages/plasma-core/api/plasma-core.api.md b/packages/plasma-core/api/plasma-core.api.md index b934b1cde9..db1fcf0f6b 100644 --- a/packages/plasma-core/api/plasma-core.api.md +++ b/packages/plasma-core/api/plasma-core.api.md @@ -815,6 +815,26 @@ export interface PinProps { } // @public +export const Popover: React_2.NamedExoticComponent>; + +// Warning: (ae-forgotten-export) The symbol "PopoverBasicPlacement" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type PopoverPlacement = PopoverBasicPlacement | 'auto'; + +// @public (undocumented) +export interface PopoverProps extends HTMLAttributes { + children?: ReactNode; + closeOnOverlayClick: boolean; + isOpen?: boolean; + onToggle?: (isOpen: boolean, event: SyntheticEvent | Event) => void; + placement?: PopoverPlacement | Array; + showArrow?: boolean; + target?: ReactNode; + trigger: 'hover' | 'click'; +} + +// @public @deprecated export const Popup: React_2.NamedExoticComponent>; // @public (undocumented) diff --git a/packages/plasma-core/src/components/Popover/Popover.tsx b/packages/plasma-core/src/components/Popover/Popover.tsx new file mode 100644 index 0000000000..c03c938ec6 --- /dev/null +++ b/packages/plasma-core/src/components/Popover/Popover.tsx @@ -0,0 +1,265 @@ +import React, { memo, useRef, useCallback, useEffect, useState } from 'react'; +import type { HTMLAttributes, ReactNode, RefAttributes, SyntheticEvent } from 'react'; +import styled from 'styled-components'; +import { usePopper } from 'react-popper'; +import PopperJS from '@popperjs/core'; + +import { useForkRef } from '../../hooks'; + +export type PopoverBasicPlacement = 'top' | 'bottom' | 'right' | 'left'; +export type PopoverPlacement = PopoverBasicPlacement | 'auto'; + +export interface PopoverProps extends HTMLAttributes { + /** + * Всплывающее окно раскрыто или нет. + */ + isOpen?: boolean; + /** + * Способ всплывающего окна - наведение или клик мышью. + */ + trigger: 'hover' | 'click'; + /** + * Расположение всплывающего окна. По умолчанию "auto" + */ + placement?: PopoverPlacement | Array; + /** + * Стрелка над элементом показывается или нет. + */ + showArrow?: boolean; + /** + * Элемент, при нажатии на который произойдет вызов всплывающего окна. + */ + target?: ReactNode; + /** + * Контент всплывающего окна. + */ + children?: ReactNode; + /** + * Событие сворачивания/разворачивания всплывающего окна. + */ + onToggle?: (isOpen: boolean, event: SyntheticEvent | Event) => void; + /** + * Закрывать окно при нажатии вне области окна(по умолчанию true), + */ + closeOnOverlayClick: boolean; +} + +const StyledRoot = styled.div` + position: relative; + box-sizing: border-box; + display: inline-flex; +`; + +const StyledArrow = styled.div` + visibility: hidden; + + &, + &::before { + position: absolute; + width: 0.5rem; + height: 0.5rem; + background: inherit; + } + + &::before { + visibility: visible; + content: ''; + transform: rotate(45deg); + } +`; + +const StyledPopover = styled.div` + position: absolute; + z-index: 1; + + &[data-popper-placement^='top'] > ${StyledArrow} { + bottom: -0.25rem; + } + + &[data-popper-placement^='bottom'] > ${StyledArrow} { + top: -0.25rem; + } + + &[data-popper-placement^='left'] > ${StyledArrow} { + right: -0.25rem; + } + + &[data-popper-placement^='right'] > ${StyledArrow} { + left: -0.25rem; + } + + padding: var(--plasma-popup-padding); + margin: var(--plasma-popup-margin); + width: var(--plasma-popup-width); +`; + +export const getPlacement = (placement: PopoverPlacement) => { + return `${placement}-start` as PopperJS.Placement; +}; + +const getAutoPlacements = (placements?: PopoverPlacement[]) => { + return (placements || []).map((placement) => getPlacement(placement)); +}; + +/** + * Всплывающее окно с возможностью позиционирования + * и вызова по клику либо ховеру. + */ +export const Popover = memo>( + React.forwardRef( + ( + { + target, + children, + isOpen, + trigger, + showArrow = false, + placement = 'auto', + closeOnOverlayClick = true, + onToggle, + ...rest + }, + outerRootRef, + ) => { + const rootRef = useRef(null); + const popoverRef = useRef(null); + const handleRef = useForkRef(rootRef, outerRootRef); + + const [arrowElement, setArrowElement] = useState(null); + + const isAutoArray = Array.isArray(placement); + const isAuto = isAutoArray || placement === 'auto'; + + const { styles, attributes, forceUpdate } = usePopper(rootRef.current, popoverRef.current, { + placement: getPlacement(isAutoArray ? 'auto' : (placement as PopoverPlacement)), + modifiers: [ + { + name: 'flip', + enabled: isAuto, + options: { + allowedAutoPlacements: getAutoPlacements( + isAutoArray ? (placement as PopoverPlacement[]) : [], + ), + }, + }, + { name: 'arrow', options: { element: arrowElement } }, + ], + }); + + const onDocumentClick = useCallback( + (event: MouseEvent) => { + console.log('closeOnOverlayClick', closeOnOverlayClick); + if (!closeOnOverlayClick) { + return; + } + const targetIsRoot = event.target === rootRef.current; + const rootHasTarget = rootRef.current?.contains(event.target as Element); + + if (!targetIsRoot && !rootHasTarget) { + onToggle?.(false, event); + } + }, + [onToggle, closeOnOverlayClick], + ); + + const onClick = useCallback( + (event) => { + if (trigger === 'click') { + console.log('popover click'); + const targetIsPopover = event.target === popoverRef.current; + const rootHasTarget = popoverRef.current?.contains(event.target as Element); + + if (!targetIsPopover && !rootHasTarget) { + onToggle?.(!isOpen, event); + } + } + }, + [trigger, isOpen, onToggle], + ); + + const onMouseEnter = useCallback( + (event) => { + if (trigger === 'hover') { + onToggle?.(true, event); + } + }, + [trigger, onToggle], + ); + + const onMouseLeave = useCallback( + (event) => { + if (trigger === 'hover') { + onToggle?.(false, event); + } + }, + [trigger, onToggle], + ); + + const onFocus = useCallback( + (event) => { + if (trigger === 'hover') { + onToggle?.(true, event); + } + }, + [trigger, onToggle], + ); + + const onBlur = useCallback( + (event) => { + if (trigger === 'hover') { + onToggle?.(false, event); + } + }, + [trigger, onToggle], + ); + + useEffect(() => { + document.addEventListener('click', onDocumentClick); + return () => document.removeEventListener('click', onDocumentClick); + }, []); + + useEffect(() => { + console.log('effect', isOpen); + if (!isOpen || !forceUpdate) { + return; + } + + /* + * INFO: Метод forceUpdate содержит в себе flushSync и приводит + * к повторному рендеру компонента, который уже находится в процессе рендера. + * Данный хак, нужен для того, чтобы это поведение избежать и перенаправить + * вызов метода в очередь микрозадач. + */ + Promise.resolve().then(forceUpdate); + }, [isOpen, forceUpdate]); + + console.log(isOpen); + + return ( + + {target} + {children && ( + + {showArrow && ( + + )} + {children} + + )} + + ); + }, + ), +); diff --git a/packages/plasma-core/src/components/Popover/index.ts b/packages/plasma-core/src/components/Popover/index.ts new file mode 100644 index 0000000000..060e7e04ba --- /dev/null +++ b/packages/plasma-core/src/components/Popover/index.ts @@ -0,0 +1,2 @@ +export { Popover } from './Popover'; +export type { PopoverProps, PopoverPlacement } from './Popover'; diff --git a/packages/plasma-core/src/components/Popup/Popup.tsx b/packages/plasma-core/src/components/Popup/Popup.tsx index 0e5a791d08..6d3427c279 100644 --- a/packages/plasma-core/src/components/Popup/Popup.tsx +++ b/packages/plasma-core/src/components/Popup/Popup.tsx @@ -60,6 +60,7 @@ const getAutoPlacements = (placements?: PopupPlacement[]) => { /** * Всплывающее окно с возможностью позиционирования * и вызова по клику либо ховеру. + * @deprecated Используйте Popover */ export const Popup = memo>( React.forwardRef( diff --git a/packages/plasma-core/src/index.ts b/packages/plasma-core/src/index.ts index 298fff1a3b..e7fe64fccf 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/Popover'; export * from './components/Price'; export * from './components/RadioGroup'; export * from './components/Skeleton'; diff --git a/packages/plasma-hope/api/plasma-hope.api.md b/packages/plasma-hope/api/plasma-hope.api.md index f1a158b7ed..5783f435e9 100644 --- a/packages/plasma-hope/api/plasma-hope.api.md +++ b/packages/plasma-hope/api/plasma-hope.api.md @@ -111,6 +111,9 @@ import { ParagraphText1 } from '@salutejs/plasma-core'; import { ParagraphText2 } from '@salutejs/plasma-core'; import { PickOptional } from '@salutejs/plasma-core'; import { PinProps } from '@salutejs/plasma-core'; +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 { PopupProps } from '@salutejs/plasma-core'; import type { PriceProps as PriceProps_2 } from '@salutejs/plasma-core'; @@ -994,6 +997,12 @@ export { PinProps } // @public (undocumented) export type Placement = BasePlacement | VariationPlacement; +export { Popover } + +export { PopoverPlacement } + +export { PopoverProps } + export { Popup } export { PopupProps } diff --git a/packages/plasma-hope/src/components/Popover/index.ts b/packages/plasma-hope/src/components/Popover/index.ts new file mode 100644 index 0000000000..906ebfb660 --- /dev/null +++ b/packages/plasma-hope/src/components/Popover/index.ts @@ -0,0 +1,2 @@ +export { Popover } from '@salutejs/plasma-core'; +export type { PopoverProps, PopoverPlacement } from '@salutejs/plasma-core'; diff --git a/packages/plasma-hope/src/index.ts b/packages/plasma-hope/src/index.ts index b5cafe8169..49ba3fe72e 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/Popover'; export * from './components/Price'; export * from './components/Progress'; export * from './components/PreviewGallery'; diff --git a/packages/plasma-web/src/components/Popover/Popover.stories.tsx b/packages/plasma-web/src/components/Popover/Popover.stories.tsx new file mode 100644 index 0000000000..1761f5f320 --- /dev/null +++ b/packages/plasma-web/src/components/Popover/Popover.stories.tsx @@ -0,0 +1,69 @@ +import React, { useRef } from 'react'; +import styled from 'styled-components'; +import { Story, Meta } from '@storybook/react'; + +import { InSpacingDecorator, disableProps } from '../../helpers'; +import { Button } from '../Button'; + +import { Popover, PopoverProps, PopoverPlacement } from '.'; + +const placements: Array = ['top', 'bottom', 'right', 'left', 'auto']; + +export default { + title: 'Controls/Popover', + decorators: [InSpacingDecorator], +} as Meta; + +export const Live: Story = (args) => { + const [isOpen, setIsOpen] = React.useState(false); + + const toggle = (is) => { + setIsOpen(is); + console.log('toggleO', is); + }; + + const click = () => { + setIsOpen(true); + console.log('clickO', isOpen); + }; + + return ( + <> + + Trigger} + {...args} + > + <>Content + + + ); +}; + +Live.args = { + placement: 'bottom', + showArrow: false, + trigger: 'hover', + closeOnOverlayClick: true, +}; + +Live.argTypes = { + placement: { + options: placements, + control: { + type: 'select', + }, + }, + trigger: { + options: ['click', 'hover'], + control: { + type: 'select', + }, + }, + ...disableProps(['isOpen']), +}; diff --git a/packages/plasma-web/src/components/Popover/index.ts b/packages/plasma-web/src/components/Popover/index.ts new file mode 100644 index 0000000000..df75910495 --- /dev/null +++ b/packages/plasma-web/src/components/Popover/index.ts @@ -0,0 +1,2 @@ +export { Popover } from '@salutejs/plasma-hope'; +export type { PopoverProps, PopoverPlacement } from '@salutejs/plasma-hope';