From fc74860a1cd758996446f3b56174808a92476530 Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Wed, 4 Dec 2024 17:04:46 +0300 Subject: [PATCH 1/8] feat(Popup)!: heavily use floating ui in component --- src/components/Popup/Popup.scss | 222 +++++------------- src/components/Popup/Popup.tsx | 345 ++++++++++++---------------- src/components/Popup/PopupArrow.tsx | 15 +- src/components/Popup/constants.ts | 3 +- src/components/Popup/utils.ts | 73 +++++- src/components/Portal/Portal.tsx | 29 +-- 6 files changed, 292 insertions(+), 395 deletions(-) diff --git a/src/components/Popup/Popup.scss b/src/components/Popup/Popup.scss index a975e415b3..b46bda18ba 100644 --- a/src/components/Popup/Popup.scss +++ b/src/components/Popup/Popup.scss @@ -14,113 +14,61 @@ $transition-distance: 10px; --_--border-color: var(--g-popup-border-color, var(--g-color-line-generic-solid)); --_--border-width: var(--g-popup-border-width, 1px); - z-index: 1000; + position: relative; + border-radius: 4px; + background-color: var(--_--background-color); + box-shadow: + 0 0 0 var(--_--border-width) var(--_--border-color), + 0 8px 20px var(--_--border-width) var(--g-color-sfx-shadow); + outline: none; visibility: hidden; + transition-property: opacity, transform; + transition-timing-function: ease-out; - width: max-content; - position: absolute; - // stylelint-disable-next-line csstools/use-logical - top: 0; - // stylelint-disable-next-line csstools/use-logical - left: 0; - - &_open, - &_exit_active { + &_mounted { visibility: visible; } - &_exit_active { - &[data-floating-placement*='bottom'] #{$block}__content { - animation-name: #{variables.$ns}popup-bottom; - } - - &[data-floating-placement*='top'] #{$block}__content { - animation-name: #{variables.$ns}popup-top; - } - - &[data-floating-placement*='left'] #{$block}__content { - animation-name: #{variables.$ns}popup-left; - } - - &[data-floating-placement*='right'] #{$block}__content { - animation-name: #{variables.$ns}popup-right; - } + & > :first-child:not(#{$block}__arrow), + & > #{$block}__arrow + * { + border-start-start-radius: inherit; + border-start-end-radius: inherit; } - // open state - &_enter_active, - &_appear_active { - &[data-floating-placement*='bottom'] #{$block}__content { - animation-name: #{variables.$ns}popup-bottom-open; - } - - &[data-floating-placement*='top'] #{$block}__content { - animation-name: #{variables.$ns}popup-top-open; - } - - &[data-floating-placement*='left'] #{$block}__content { - animation-name: #{variables.$ns}popup-left-open; - } - - &[data-floating-placement*='right'] #{$block}__content { - animation-name: #{variables.$ns}popup-right-open; - } + & > :last-child { + border-end-start-radius: inherit; + border-end-end-radius: inherit; } - // arrow - &[data-floating-placement*='bottom'] #{$block}__arrow { - inset-block-start: -$arrow-offset; + @at-root [data-floating-ui-status='open'] &, + [data-floating-ui-status='close'] & { + transition-duration: 100ms; } - &[data-floating-placement*='top'] #{$block}__arrow { - inset-block-end: -$arrow-offset; - - &-content { - transform: rotate(180deg); - } + @at-root [data-floating-ui-status='initial'] &, + [data-floating-ui-status='close'] & { + opacity: 0; + transform: translate(0, 0); } - &[data-floating-placement*='left'] #{$block}__arrow { - // stylelint-disable-next-line csstools/use-logical - right: -$arrow-offset; - - &-content { - transform: rotate(90deg); - } + @at-root [data-floating-ui-status='initial'][data-floating-ui-placement*='bottom'] &, + [data-floating-ui-status='close'][data-floating-ui-placement*='bottom'] & { + transform: translateY($transition-distance); } - &[data-floating-placement*='right'] #{$block}__arrow { - // stylelint-disable-next-line csstools/use-logical - left: -$arrow-offset; - - &-content { - transform: rotate(-90deg); - } + @at-root [data-floating-ui-status='initial'][data-floating-ui-placement*='top'] &, + [data-floating-ui-status='close'][data-floating-ui-placement*='top'] & { + transform: translateY(-$transition-distance); } - &__content { - position: relative; - animation-duration: 0.1s; - animation-timing-function: ease-out; - animation-fill-mode: forwards; - border-radius: 4px; - background-color: var(--_--background-color); - box-shadow: - 0 0 0 var(--_--border-width) var(--_--border-color), - 0 8px 20px var(--_--border-width) var(--g-color-sfx-shadow); - outline: none; - - // Corners rounding for content - & > :first-child:not(#{$block}__arrow), - & > #{$block}__arrow + * { - border-start-start-radius: inherit; - border-start-end-radius: inherit; - } + @at-root [data-floating-ui-status='initial'][data-floating-ui-placement*='left'] &, + [data-floating-ui-status='close'][data-floating-ui-placement*='left'] & { + transform: translateX(-$transition-distance); + } - & > :last-child { - border-end-start-radius: inherit; - border-end-end-radius: inherit; - } + @at-root [data-floating-ui-status='initial'][data-floating-ui-placement*='right'] &, + [data-floating-ui-status='close'][data-floating-ui-placement*='right'] & { + transform: translateX($transition-distance); } &__arrow { @@ -163,92 +111,34 @@ $transition-distance: 10px; inset-block-end: -4px; } } -} -@keyframes #{variables.$ns}popup-bottom { - 0% { - opacity: 1; - transform: translateY(0); - } - 100% { - opacity: 0; - transform: translateY($transition-distance); - } -} - -@keyframes #{variables.$ns}popup-bottom-open { - 0% { - opacity: 0; - transform: translateY($transition-distance); - } - 100% { - opacity: 1; - transform: translateY(0); + @at-root [data-floating-ui-placement*='bottom'] #{$block}__arrow { + inset-block-start: -$arrow-offset; } -} -@keyframes #{variables.$ns}popup-top { - 0% { - opacity: 1; - transform: translateY(0); - } - 100% { - opacity: 0; - transform: translateY(-$transition-distance); - } -} + @at-root [data-floating-ui-placement*='top'] #{$block}__arrow { + inset-block-end: -$arrow-offset; -@keyframes #{variables.$ns}popup-top-open { - 0% { - opacity: 0; - transform: translateY(-$transition-distance); - } - 100% { - opacity: 1; - transform: translateY(0); + &-content { + transform: rotate(180deg); + } } -} -@keyframes #{variables.$ns}popup-left { - 0% { - opacity: 1; - transform: translateX(0); - } - 100% { - opacity: 0; - transform: translateX(-$transition-distance); - } -} + @at-root [data-floating-ui-placement*='left'] #{$block}__arrow { + // stylelint-disable-next-line csstools/use-logical + right: -$arrow-offset; -@keyframes #{variables.$ns}popup-left-open { - 0% { - opacity: 0; - transform: translateX(-$transition-distance); - } - 100% { - opacity: 1; - transform: translateX(0); + &-content { + transform: rotate(90deg); + } } -} -@keyframes #{variables.$ns}popup-right { - 0% { - opacity: 1; - transform: translateX(0); - } - 100% { - opacity: 0; - transform: translateX($transition-distance); - } -} + @at-root [data-floating-ui-placement*='right'] #{$block}__arrow { + // stylelint-disable-next-line csstools/use-logical + left: -$arrow-offset; -@keyframes #{variables.$ns}popup-right-open { - 0% { - opacity: 0; - transform: translateX($transition-distance); - } - 100% { - opacity: 1; - transform: translateX(0); + &-content { + transform: rotate(-90deg); + } } } diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index f26b850c31..821ec13acb 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -4,45 +4,47 @@ import React from 'react'; import { arrow, - autoPlacement, autoUpdate, - flip, offset as floatingOffset, limitShift, shift, + useDismiss, useFloating, + useInteractions, + useTransitionStatus, } from '@floating-ui/react'; import type { - Alignment, FloatingRootContext, Middleware, - Placement, + OpenChangeReason, ReferenceType, Strategy, + UseFloatingOptions, + UseInteractionsReturn, } from '@floating-ui/react'; -import {CSSTransition} from 'react-transition-group'; import {useForkRef} from '../../hooks'; -import {useRestoreFocus} from '../../hooks/private'; import {Portal} from '../Portal'; -import type {DOMProps, QAProps} from '../types'; -import {FocusTrap, useParentFocusTrap} from '../utils/FocusTrap'; +import type {AriaLabelingProps, DOMProps, QAProps} from '../types'; import {block} from '../utils/cn'; -import {useLayer} from '../utils/layer-manager'; +import {filterDOMProps} from '../utils/filterDOMProps'; +import type {LayerCloseReason} from '../utils/layer-manager'; import type {LayerExtendableProps} from '../utils/layer-manager/LayerManager'; -import {getCSSTransitionClassNames} from '../utils/transition'; import {PopupArrow} from './PopupArrow'; +import {OVERFLOW_PADDING, TRANSITION_DURATION} from './constants'; import {useAnchor} from './hooks'; import type {PopupAnchorElement, PopupAnchorRef, PopupOffset, PopupPlacement} from './types'; -import {getOffsetValue, isAutoPlacement} from './utils'; +import {arrowStylesMiddleware, getOffsetOptions, getPlacementOptions} from './utils'; import './Popup.scss'; -export interface PopupProps extends DOMProps, LayerExtendableProps, QAProps { +export interface PopupProps extends DOMProps, AriaLabelingProps, QAProps { children?: React.ReactNode; /** Manages `Popup` visibility */ open?: boolean; + /** Callback for open state changes, when dismiss happens for example */ + onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; /** `Popup` will not be removed from the DOM upon hiding */ keepMounted?: boolean; /** Render an arrow pointing to the anchor */ @@ -57,260 +59,205 @@ export interface PopupProps extends DOMProps, LayerExtendableProps, QAProps { anchorElement?: PopupAnchorElement | null; /** floating element anchor ref object */ anchorRef?: PopupAnchorRef; + /** Set up a getter for props that need to be passed to the anchor */ + setGetAnchorProps?: (getAnchorProps: UseInteractionsReturn['getReferenceProps']) => void; /** Floating UI middlewares. If set, they will completely overwrite the default middlewares. */ - middlewares?: Middleware[]; + floatingMiddlewares?: Middleware[]; /** Floating UI context to provide interactions */ floatingContext?: FloatingRootContext; /** Additional floating element props to provide interactions */ floatingProps?: Record; /** React ref floating element is attached to */ floatingRef?: React.Ref; - /** Do not use `LayerManager` on stacking popups */ - disableLayer?: boolean; - /** @deprecated Add onClick handler to children */ - onClick?: React.MouseEventHandler; - /** `mouseenter` event handler */ - onMouseEnter?: React.MouseEventHandler; - /** `mouseleave` event handler */ - onMouseLeave?: React.MouseEventHandler; - /** `focus` event handler */ - onFocus?: React.FocusEventHandler; - /** `blur` event handler */ - onBlur?: React.FocusEventHandler; - /** On start open popup animation void callback */ - onTransitionEnter?: VoidFunction; - /** On finish open popup animation void callback */ - onTransitionEntered?: VoidFunction; - /** On start close popup animation void callback */ - onTransitionExit?: VoidFunction; - /** On finish close popup animation void callback */ - onTransitionExited?: VoidFunction; + /** + * This callback will be called when Escape key pressed on keyboard, or click outside was made + * This behaviour could be disabled with `disableEscapeKeyDown` + * and `disableOutsideClick` options + * + * @deprecated Use `onOpenChange` instead + */ + onClose?: LayerExtendableProps['onClose']; + /** + * This callback will be called when Escape key pressed on keyboard + * This behaviour could be disabled with `disableEscapeKeyDown` option + * + * @deprecated Use `onOpenChange` instead + */ + onEscapeKeyDown?: LayerExtendableProps['onEscapeKeyDown']; + /** + * This callback will be called when click is outside of elements of "top layer" + * This behaviour could be disabled with `disableOutsideClick` option + * + * @deprecated Use `onOpenChange` instead + */ + onOutsideClick?: LayerExtendableProps['onOutsideClick']; + /** Do not dismiss on escape key press */ + disableEscapeKeyDown?: boolean; + /** Do not dismiss on outside click */ + disableOutsideClick?: boolean; /** Do not use `Portal` for children */ disablePortal?: boolean; - /** DOM element children to be mounted to */ - container?: HTMLElement; - /** HTML `class` attribute for content node */ - contentClassName?: string; - /** If true, the focus will return to the anchor element */ - restoreFocus?: boolean; - /** Element the focus will be restored to, depends on `restoreFocus` */ - restoreFocusRef?: React.RefObject; - /** `aria-label` attribute, use this attribute only if you didn't have visible caption */ - 'aria-label'?: React.AriaAttributes['aria-label']; - /** `aria-labelledby` attribute, prefer this attribute if you have visible caption */ - 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']; - /** `aria-modal` attribute, default value is equal to focusTrap */ - 'aria-modal'?: React.AriaAttributes['aria-modal']; /** `aria-role` attribute */ role?: React.AriaRole; /** HTML `id` attribute */ id?: string; - /** Enable focus trapping behavior */ - focusTrap?: boolean; - /** While open, the focus will be set to the first interactive element in the content */ - autoFocus?: boolean; + // CSS property `z-index` + zIndex?: number; } const b = block('popup'); export function Popup({ - floatingRef, keepMounted = false, hasArrow = false, open, + onOpenChange, strategy, - placement = 'top', - offset = 4, + placement: placementProp = 'top', + offset: offsetProp = 4, anchorElement, anchorRef, + setGetAnchorProps, + floatingMiddlewares, floatingContext, floatingProps, + floatingRef, + onClose, + onEscapeKeyDown, + onOutsideClick, disableEscapeKeyDown, disableOutsideClick, - disableLayer, style, className, - contentClassName, - middlewares, children, - onEscapeKeyDown, - onOutsideClick, - onClose, - onClick, - onMouseEnter, - onMouseLeave, - onFocus, - onBlur, - onTransitionEnter, - onTransitionEntered, - onTransitionExit, - onTransitionExited, disablePortal, - container, qa, - restoreFocus, - restoreFocusRef, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - role: roleProp, id, - focusTrap = false, - autoFocus = false, - 'aria-modal': ariaModal = focusTrap, + role, + zIndex = 1000, + ...restProps }: PopupProps) { - const containerRef = React.useRef(null); + const contentRef = React.useRef(null); const [arrowElement, setArrowElement] = React.useState(null); const anchor = useAnchor(anchorElement, anchorRef); - const offsetValue = getOffsetValue(offset, hasArrow); + const {offset} = getOffsetOptions(offsetProp, hasArrow); + const {placement, middleware: placementMiddleware} = getPlacementOptions( + placementProp, + disablePortal, + ); - let placementValue: Placement | undefined; - let preventOverflowMiddleware: Middleware; + const handleOpenChange = React.useCallback>( + (isOpen, event, reason) => { + onOpenChange?.(isOpen, event, reason); - if (Array.isArray(placement)) { - placementValue = placement[0]; - preventOverflowMiddleware = flip({ - altBoundary: disablePortal, - fallbackPlacements: placement.slice(1), - }); - } else if (isAutoPlacement(placement)) { - let alignment: Alignment | undefined; - if (placement === 'auto-start') { - alignment = 'start'; - } else if (placement === 'auto-end') { - alignment = 'end'; - } + if (isOpen || !event) { + return; + } - placementValue = undefined; - preventOverflowMiddleware = autoPlacement({ - altBoundary: disablePortal, - alignment, - }); - } else { - placementValue = placement; - preventOverflowMiddleware = flip({ - altBoundary: disablePortal, - }); - } + const closeReason: LayerCloseReason = + reason === 'escape-key' ? 'escapeKeyDown' : 'outsideClick'; - useLayer({ - open, - disableEscapeKeyDown, - disableOutsideClick, - onEscapeKeyDown, - onOutsideClick, - onClose, - contentRefs: [anchor.ref, containerRef], - enabled: !disableLayer, - type: 'popup', - }); + if (closeReason === 'escapeKeyDown' && onEscapeKeyDown) { + onEscapeKeyDown(event as KeyboardEvent); + } + + if (closeReason === 'outsideClick' && onOutsideClick) { + onOutsideClick(event as MouseEvent); + } + + onClose?.(event as KeyboardEvent | MouseEvent, closeReason); + }, + [onOpenChange, onClose, onEscapeKeyDown, onOutsideClick], + ); const { refs, + elements, floatingStyles, - placement: actualPlacement, + placement: finalPlacement, middlewareData, + context, + update, } = useFloating({ rootContext: floatingContext, strategy, - placement: placementValue, + placement: placement, open, - whileElementsMounted: open ? autoUpdate : undefined, + onOpenChange: handleOpenChange, elements: { // @ts-expect-error: Type 'Element | VirtualElement | undefined' is not assignable to type 'Element | null | undefined'. reference: anchor.element, }, - middleware: middlewares ?? [ - floatingOffset(offsetValue), - preventOverflowMiddleware, - shift({limiter: limitShift(), altBoundary: disablePortal}), - arrow({element: arrowElement, padding: 4}), + middleware: floatingMiddlewares ?? [ + floatingOffset(offset), + placementMiddleware, + shift({ + padding: OVERFLOW_PADDING, + // Offset 22 is size of the arrow (18) + padding (4) + limiter: limitShift({offset: 4 + (hasArrow ? 18 : 0)}), + altBoundary: disablePortal, + }), + hasArrow && arrow({element: arrowElement, padding: 4}), + hasArrow && arrowStylesMiddleware(), ], }); - const arrowStyles: React.CSSProperties = {}; + const dismiss = useDismiss(context, { + enabled: !disableOutsideClick || !disableEscapeKeyDown, + outsidePress: !disableOutsideClick, + escapeKey: !disableEscapeKeyDown, + }); - if (hasArrow && middlewareData.arrow) { - const {x, y} = middlewareData.arrow; - arrowStyles.left = x; - arrowStyles.top = y; - } + const {getReferenceProps, getFloatingProps} = useInteractions([dismiss]); - const handleRef = useForkRef( - floatingRef, - refs.setFloating, - containerRef, - useParentFocusTrap(), - ); + React.useLayoutEffect(() => { + setGetAnchorProps?.(getReferenceProps); + }, [setGetAnchorProps, getReferenceProps]); - const containerProps = useRestoreFocus({ - enabled: Boolean(restoreFocus && open), - restoreFocusRef, - }); + const {isMounted, status} = useTransitionStatus(context, {duration: TRANSITION_DURATION}); - let role = roleProp; - if ((ariaModal === true || ariaModal === 'true') && !role) { - role = 'dialog'; - } + React.useEffect(() => { + if (isMounted && elements.reference && elements.floating) { + return autoUpdate(elements.reference, elements.floating, update); + } + return; + }, [isMounted, elements, update]); - return ( - containerRef.current?.addEventListener('animationend', done)} - classNames={getCSSTransitionClassNames(b)} - mountOnEnter={!keepMounted} - unmountOnExit={!keepMounted} - appear={true} - onEnter={() => { - onTransitionEnter?.(); - }} - onEntered={() => { - onTransitionEntered?.(); - }} - onExit={() => { - onTransitionExit?.(); - }} - onExited={() => { - onTransitionExited?.(); - }} - > - + const handleFloatingRef = useForkRef(refs.setFloating, floatingRef); + + return isMounted || keepMounted ? ( + +
- - {/* FIXME The onClick event handler is deprecated and should be removed */} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} -
- {hasArrow && ( - - )} - {children} -
-
+ {hasArrow && ( + + )} + {children}
- - - ); +
+
+ ) : null; } diff --git a/src/components/Popup/PopupArrow.tsx b/src/components/Popup/PopupArrow.tsx index 8dbae52f23..f6fc740f9a 100644 --- a/src/components/Popup/PopupArrow.tsx +++ b/src/components/Popup/PopupArrow.tsx @@ -8,21 +8,22 @@ const b = block('popup'); interface PopupArrowProps { styles: React.CSSProperties; - attributes?: Record; - setArrowRef: (value: HTMLDivElement) => void; } -export function PopupArrow({styles, attributes, setArrowRef}: PopupArrowProps) { +export const PopupArrow = React.forwardRef(function PopupArrow( + {styles}, + ref, +) { return ( -
+
-
+
-
+
); -} +}); diff --git a/src/components/Popup/constants.ts b/src/components/Popup/constants.ts index e0ab3783a9..d9ee5b3084 100644 --- a/src/components/Popup/constants.ts +++ b/src/components/Popup/constants.ts @@ -1,3 +1,4 @@ export const AUTO_PLACEMENTS = ['auto', 'auto-start', 'auto-end'] as const; - export const ARROW_SIZE = 8; +export const OVERFLOW_PADDING = 4; +export const TRANSITION_DURATION = 100; diff --git a/src/components/Popup/utils.ts b/src/components/Popup/utils.ts index 02e4d5ed9b..bf833f70ca 100644 --- a/src/components/Popup/utils.ts +++ b/src/components/Popup/utils.ts @@ -1,19 +1,74 @@ -import {ARROW_SIZE, AUTO_PLACEMENTS} from './constants'; -import type {AutoPlacement, PopupOffset} from './types'; +import {autoPlacement, flip} from '@floating-ui/react'; +import type {Alignment, Middleware, Placement} from '@floating-ui/react'; -export function getOffsetValue(offset: PopupOffset, hasArrow: boolean | undefined) { - let offsetValue = offset; +import {ARROW_SIZE, AUTO_PLACEMENTS, OVERFLOW_PADDING} from './constants'; +import type {AutoPlacement, PopupOffset, PopupPlacement} from './types'; + +export function getOffsetOptions(offsetProp: PopupOffset, hasArrow: boolean | undefined) { + let offset = offsetProp; if (hasArrow) { - if (typeof offsetValue === 'number') { - offsetValue += ARROW_SIZE; + if (typeof offset === 'number') { + offset += ARROW_SIZE; } else { - offsetValue = {...offsetValue, mainAxis: (offsetValue.mainAxis ?? 0) + ARROW_SIZE}; + offset = {...offset, mainAxis: (offset.mainAxis ?? 0) + ARROW_SIZE}; } } - return offsetValue; + return {offset}; } -export function isAutoPlacement(placement: string): placement is AutoPlacement { +function isAutoPlacement(placement: string): placement is AutoPlacement { return AUTO_PLACEMENTS.includes(placement as AutoPlacement); } + +export function getPlacementOptions(placementProp: PopupPlacement, disablePortal?: boolean) { + let placement: Placement | undefined; + let middleware: Middleware; + + if (Array.isArray(placementProp)) { + placement = placementProp[0]; + middleware = flip({ + padding: OVERFLOW_PADDING, + altBoundary: disablePortal, + fallbackPlacements: placementProp.slice(1), + }); + } else if (isAutoPlacement(placementProp)) { + let alignment: Alignment | undefined; + if (placementProp === 'auto-start') { + alignment = 'start'; + } else if (placementProp === 'auto-end') { + alignment = 'end'; + } + + placement = undefined; + middleware = autoPlacement({ + padding: OVERFLOW_PADDING, + altBoundary: disablePortal, + alignment, + }); + } else { + placement = placementProp; + middleware = flip({ + padding: OVERFLOW_PADDING, + altBoundary: disablePortal, + }); + } + + return {placement, middleware}; +} + +export const arrowStylesMiddleware = (): Middleware => ({ + name: 'arrowStyles', + fn({middlewareData}) { + if (!middlewareData.arrow) { + return {}; + } + + return { + data: { + left: middlewareData.arrow.x, + top: middlewareData.arrow.y, + }, + }; + }, +}); diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx index dee2817682..3c38209550 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import {FloatingPortal} from '@floating-ui/react'; import {usePortalContainer} from '../../hooks'; import {ThemeProvider} from '../theme'; @@ -29,16 +29,19 @@ export function Portal({container, children, disablePortal}: PortalProps) { return {children}; } - return containerNode - ? ReactDOM.createPortal( - scoped ? ( - - {children} - - ) : ( - children - ), - containerNode, - ) - : null; + if (containerNode) { + return ( + + {scoped ? ( + + {children} + + ) : ( + children + )} + + ); + } + + return null; } From fb68f1daeb15bc4d10fefa6466307f9b69b5071e Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Thu, 5 Dec 2024 14:09:02 +0300 Subject: [PATCH 2/8] feat(Popover): implement children as function --- src/components/lab/Popover/Popover.tsx | 63 +++++++++++++------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/src/components/lab/Popover/Popover.tsx b/src/components/lab/Popover/Popover.tsx index f3eb920297..162ff2acd8 100644 --- a/src/components/lab/Popover/Popover.tsx +++ b/src/components/lab/Popover/Popover.tsx @@ -3,12 +3,12 @@ import React from 'react'; import { safePolygon, useClick, - useDismiss, useFloatingRootContext, useHover, useInteractions, useRole, } from '@floating-ui/react'; +import type {UseInteractionsReturn} from '@floating-ui/react'; import {useControlledState, useForkRef} from '../../../hooks'; import {Popup} from '../../Popup'; @@ -20,21 +20,10 @@ import {getElementRef} from '../../utils/getElementRef'; export interface PopoverProps extends QAProps, DOMProps, - Pick< - PopupProps, - | 'middlewares' - | 'strategy' - | 'placement' - | 'offset' - | 'keepMounted' - | 'disablePortal' - | 'hasArrow' - | 'contentClassName' - | 'disableEscapeKeyDown' - | 'disableOutsideClick' - | 'disableLayer' - > { - children: React.ReactElement; + Pick { + children: + | ((props: Record, ref: React.Ref) => React.ReactElement) + | React.ReactElement; open?: boolean; onOpenChange?: (open: boolean) => void; disabled?: boolean; @@ -57,17 +46,19 @@ export function Popover({ delay = DEFAULT_DELAY, enableSafePolygon, className, - contentClassName, - disableEscapeKeyDown, - disableOutsideClick, ...restProps }: PopoverProps) { - const child = React.Children.only(children); - const childRef = getElementRef(child); - - const [anchorElement, setAnchorElement] = React.useState(null); + const [anchorElement, setAnchorElement] = React.useState(null); const [floatingElement, setFloatingElement] = React.useState(null); - const anchorRef = useForkRef(setAnchorElement, childRef); + const [getAnchorProps, setGetAnchorProps] = + React.useState(); + + const handleSetGetAnchorProps = React.useCallback>( + (getAnchorPropsFn) => { + setGetAnchorProps(() => getAnchorPropsFn); + }, + [], + ); const [isOpen, setIsOpen] = useControlledState(open, false, onOpenChange); @@ -90,25 +81,35 @@ export function Popover({ handleClose: enableSafePolygon ? safePolygon() : undefined, }); const click = useClick(context, {enabled: !disabled}); - const dismiss = useDismiss(context, { - escapeKey: !disableEscapeKeyDown, - outsidePress: !disableOutsideClick, - }); const role = useRole(context, {role: 'dialog'}); - const {getReferenceProps, getFloatingProps} = useInteractions([hover, click, dismiss, role]); + const {getReferenceProps, getFloatingProps} = useInteractions([hover, click, role]); + + const anchorRef = useForkRef( + setAnchorElement, + React.isValidElement(children) ? getElementRef(children) : undefined, + ); + const anchorProps = React.isValidElement(children) + ? getReferenceProps(getAnchorProps?.(children.props) ?? children.props) + : getReferenceProps(getAnchorProps?.()); + const anchorNode = React.isValidElement(children) + ? React.cloneElement(children, { + ref: anchorRef, + ...anchorProps, + }) + : children(anchorProps, anchorRef); return ( - {React.cloneElement(child, {ref: anchorRef, ...getReferenceProps(child.props)})} + {anchorNode} {content} From 62dde257209bcec88512e50efc65ac73e20df92b Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Thu, 5 Dec 2024 15:19:00 +0300 Subject: [PATCH 3/8] feat(Popover): add focus management --- src/components/Popup/Popup.tsx | 95 +++++++++++++------ src/components/Popup/i18n/en.json | 3 + src/components/Popup/i18n/index.ts | 8 ++ src/components/Popup/i18n/ru.json | 3 + src/components/lab/Popover/Popover.tsx | 14 ++- .../Popover/__stories__/Popover.stories.tsx | 27 +++++- 6 files changed, 117 insertions(+), 33 deletions(-) create mode 100644 src/components/Popup/i18n/en.json create mode 100644 src/components/Popup/i18n/index.ts create mode 100644 src/components/Popup/i18n/ru.json diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index 821ec13acb..baeef7efc9 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { + FloatingFocusManager, arrow, autoUpdate, offset as floatingOffset, @@ -14,6 +15,7 @@ import { useTransitionStatus, } from '@floating-ui/react'; import type { + FloatingFocusManagerProps, FloatingRootContext, Middleware, OpenChangeReason, @@ -34,6 +36,7 @@ import type {LayerExtendableProps} from '../utils/layer-manager/LayerManager'; import {PopupArrow} from './PopupArrow'; import {OVERFLOW_PADDING, TRANSITION_DURATION} from './constants'; import {useAnchor} from './hooks'; +import i18n from './i18n'; import type {PopupAnchorElement, PopupAnchorRef, PopupOffset, PopupPlacement} from './types'; import {arrowStylesMiddleware, getOffsetOptions, getPlacementOptions} from './utils'; @@ -69,6 +72,16 @@ export interface PopupProps extends DOMProps, AriaLabelingProps, QAProps { floatingProps?: Record; /** React ref floating element is attached to */ floatingRef?: React.Ref; + /** Manage focus when opened */ + autoFocus?: boolean; + /** If true focus is trapped inside the floating element */ + modalFocus?: boolean; + /** The initial element to be focused */ + initialFocus?: FloatingFocusManagerProps['initialFocus']; + /** Element which focus should be returned to */ + returnFocus?: FloatingFocusManagerProps['returnFocus']; + /** Do not add a11y dismiss buttons when managing focus */ + disableFocusVisuallyHiddenDismiss?: boolean; /** * This callback will be called when Escape key pressed on keyboard, or click outside was made * This behaviour could be disabled with `disableEscapeKeyDown` @@ -110,7 +123,7 @@ const b = block('popup'); export function Popup({ keepMounted = false, hasArrow = false, - open, + open = false, onOpenChange, strategy, placement: placementProp = 'top', @@ -122,15 +135,20 @@ export function Popup({ floatingContext, floatingProps, floatingRef, + modalFocus = false, + autoFocus = false, + initialFocus, + returnFocus = true, + disableFocusVisuallyHiddenDismiss = false, onClose, onEscapeKeyDown, onOutsideClick, - disableEscapeKeyDown, - disableOutsideClick, + disableEscapeKeyDown = false, + disableOutsideClick = false, style, className, children, - disablePortal, + disablePortal = false, qa, id, role, @@ -224,40 +242,55 @@ export function Popup({ return; }, [isMounted, elements, update]); - const handleFloatingRef = useForkRef(refs.setFloating, floatingRef); + const initialFocusRef = React.useRef(null); + const handleFloatingRef = useForkRef( + refs.setFloating, + floatingRef, + initialFocusRef, + ); return isMounted || keepMounted ? ( -
- {hasArrow && ( - - )} - {children} +
+ {hasArrow && ( + + )} + {children} +
-
+
) : null; } diff --git a/src/components/Popup/i18n/en.json b/src/components/Popup/i18n/en.json new file mode 100644 index 0000000000..0c5bb0e5a1 --- /dev/null +++ b/src/components/Popup/i18n/en.json @@ -0,0 +1,3 @@ +{ + "close": "Close" +} diff --git a/src/components/Popup/i18n/index.ts b/src/components/Popup/i18n/index.ts new file mode 100644 index 0000000000..93bc5a16b0 --- /dev/null +++ b/src/components/Popup/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'Popup'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/Popup/i18n/ru.json b/src/components/Popup/i18n/ru.json new file mode 100644 index 0000000000..eeeebe6d6b --- /dev/null +++ b/src/components/Popup/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "close": "Закрыть" +} diff --git a/src/components/lab/Popover/Popover.tsx b/src/components/lab/Popover/Popover.tsx index 162ff2acd8..f04b3e0aee 100644 --- a/src/components/lab/Popover/Popover.tsx +++ b/src/components/lab/Popover/Popover.tsx @@ -20,7 +20,17 @@ import {getElementRef} from '../../utils/getElementRef'; export interface PopoverProps extends QAProps, DOMProps, - Pick { + Pick< + PopupProps, + | 'strategy' + | 'placement' + | 'offset' + | 'keepMounted' + | 'hasArrow' + | 'initialFocus' + | 'returnFocus' + | 'disableFocusVisuallyHiddenDismiss' + > { children: | ((props: Record, ref: React.Ref) => React.ReactElement) | React.ReactElement; @@ -109,6 +119,8 @@ export function Popover({ floatingContext={context} floatingRef={setFloatingElement} floatingProps={getFloatingProps()} + autoFocus + modalFocus className={b(null, className)} > {content} diff --git a/src/components/lab/Popover/__stories__/Popover.stories.tsx b/src/components/lab/Popover/__stories__/Popover.stories.tsx index d7870ee556..e2d12a1e49 100644 --- a/src/components/lab/Popover/__stories__/Popover.stories.tsx +++ b/src/components/lab/Popover/__stories__/Popover.stories.tsx @@ -4,6 +4,7 @@ import {action} from '@storybook/addon-actions'; import type {Meta, StoryObj} from '@storybook/react'; import {Button} from '../../../Button'; +import {Link} from '../../../Link'; import {Flex} from '../../../layout'; import {Popover} from '../Popover'; @@ -33,7 +34,7 @@ export const Default: Story = { export const Delay: Story = { render: (args) => ( - + @@ -72,3 +73,27 @@ export const SafePolygon: Story = { enableSafePolygon: true, }, }; + +export const FocusManagement: Story = { + render: (args) => ( + + + + + + + + + + + + ), + args: { + ...Default.args, + content: ( +
+ Content with Link and +
+ ), + }, +}; From a5d3e7124c8b7234ccb358ba28019e7ff7ad2570 Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Thu, 5 Dec 2024 18:51:36 +0300 Subject: [PATCH 4/8] feat(Popover): move role hook into Popup --- src/components/Popup/Popup.tsx | 15 ++++++++++----- src/components/lab/Popover/Popover.tsx | 5 ++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index baeef7efc9..4c45da4900 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -12,6 +12,7 @@ import { useDismiss, useFloating, useInteractions, + useRole, useTransitionStatus, } from '@floating-ui/react'; import type { @@ -23,6 +24,7 @@ import type { Strategy, UseFloatingOptions, UseInteractionsReturn, + UseRoleProps, } from '@floating-ui/react'; import {useForkRef} from '../../hooks'; @@ -110,8 +112,8 @@ export interface PopupProps extends DOMProps, AriaLabelingProps, QAProps { disableOutsideClick?: boolean; /** Do not use `Portal` for children */ disablePortal?: boolean; - /** `aria-role` attribute */ - role?: React.AriaRole; + /** ARIA role or special component role (select, combobox) */ + role?: UseRoleProps['role']; /** HTML `id` attribute */ id?: string; // CSS property `z-index` @@ -151,7 +153,7 @@ export function Popup({ disablePortal = false, qa, id, - role, + role: roleProp, zIndex = 1000, ...restProps }: PopupProps) { @@ -221,13 +223,17 @@ export function Popup({ ], }); + const role = useRole(context, { + enabled: Boolean(roleProp), + role: roleProp, + }); const dismiss = useDismiss(context, { enabled: !disableOutsideClick || !disableEscapeKeyDown, outsidePress: !disableOutsideClick, escapeKey: !disableEscapeKeyDown, }); - const {getReferenceProps, getFloatingProps} = useInteractions([dismiss]); + const {getReferenceProps, getFloatingProps} = useInteractions([role, dismiss]); React.useLayoutEffect(() => { setGetAnchorProps?.(getReferenceProps); @@ -281,7 +287,6 @@ export function Popup({ style={style} data-qa={qa} id={id} - role={role} {...filterDOMProps(restProps)} > {hasArrow && ( diff --git a/src/components/lab/Popover/Popover.tsx b/src/components/lab/Popover/Popover.tsx index f04b3e0aee..99da2a8ef9 100644 --- a/src/components/lab/Popover/Popover.tsx +++ b/src/components/lab/Popover/Popover.tsx @@ -6,7 +6,6 @@ import { useFloatingRootContext, useHover, useInteractions, - useRole, } from '@floating-ui/react'; import type {UseInteractionsReturn} from '@floating-ui/react'; @@ -91,9 +90,8 @@ export function Popover({ handleClose: enableSafePolygon ? safePolygon() : undefined, }); const click = useClick(context, {enabled: !disabled}); - const role = useRole(context, {role: 'dialog'}); - const {getReferenceProps, getFloatingProps} = useInteractions([hover, click, role]); + const {getReferenceProps, getFloatingProps} = useInteractions([hover, click]); const anchorRef = useForkRef( setAnchorElement, @@ -121,6 +119,7 @@ export function Popover({ floatingProps={getFloatingProps()} autoFocus modalFocus + role="dialog" className={b(null, className)} > {content} From 68dfa75e666c62597414695f86fc0ab5fbc2c5ab Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Fri, 6 Dec 2024 15:55:18 +0300 Subject: [PATCH 5/8] feat(Popup): add transition complete events transition --- src/components/Popup/Popup.scss | 3 +- src/components/Popup/Popup.tsx | 30 ++++++++++++++++++-- src/hooks/private/index.ts | 5 ++-- src/hooks/private/usePrevious/README.md | 13 +++++++++ src/hooks/private/usePrevious/index.ts | 1 + src/hooks/private/usePrevious/usePrevious.ts | 11 +++++++ 6 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 src/hooks/private/usePrevious/README.md create mode 100644 src/hooks/private/usePrevious/index.ts create mode 100644 src/hooks/private/usePrevious/usePrevious.ts diff --git a/src/components/Popup/Popup.scss b/src/components/Popup/Popup.scss index b46bda18ba..ab1eb21c01 100644 --- a/src/components/Popup/Popup.scss +++ b/src/components/Popup/Popup.scss @@ -7,6 +7,7 @@ $arrow-offset: 9px; $arrow-border: 5px; $arrow-circle-width: 28px; $arrow-circle-height: 30px; +$transition-duration: 100ms; $transition-distance: 10px; #{$block} { @@ -42,7 +43,7 @@ $transition-distance: 10px; @at-root [data-floating-ui-status='open'] &, [data-floating-ui-status='close'] & { - transition-duration: 100ms; + transition-duration: $transition-duration; } @at-root [data-floating-ui-status='initial'] &, diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index 4c45da4900..335f3a05b4 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -28,6 +28,7 @@ import type { } from '@floating-ui/react'; import {useForkRef} from '../../hooks'; +import {usePrevious} from '../../hooks/private'; import {Portal} from '../Portal'; import type {AriaLabelingProps, DOMProps, QAProps} from '../types'; import {block} from '../utils/cn'; @@ -116,8 +117,12 @@ export interface PopupProps extends DOMProps, AriaLabelingProps, QAProps { role?: UseRoleProps['role']; /** HTML `id` attribute */ id?: string; - // CSS property `z-index` + /** CSS property `z-index` */ zIndex?: number; + /** Callback called when `Popup` is opened and "in" transition is completed */ + onTransitionInComplete?: () => void; + /** Callback called when `Popup` is closed and "out" transition is completed */ + onTransitionOutComplete?: () => void; } const b = block('popup'); @@ -155,6 +160,8 @@ export function Popup({ id, role: roleProp, zIndex = 1000, + onTransitionInComplete, + onTransitionOutComplete, ...restProps }: PopupProps) { const contentRef = React.useRef(null); @@ -240,6 +247,7 @@ export function Popup({ }, [setGetAnchorProps, getReferenceProps]); const {isMounted, status} = useTransitionStatus(context, {duration: TRANSITION_DURATION}); + const previousStatus = usePrevious(status); React.useEffect(() => { if (isMounted && elements.reference && elements.floating) { @@ -255,6 +263,24 @@ export function Popup({ initialFocusRef, ); + const handleTransitionEnd = React.useCallback( + (event: React.TransitionEvent) => { + // There are two simultaneous transitions running at the same time + // Use specific name to only notify once + if (status === 'open' && event.propertyName === 'transform') { + onTransitionInComplete?.(); + } + }, + [status, onTransitionInComplete], + ); + + // Cannot use transitionend event for "out" transition due to unmounting from the DOM + React.useEffect(() => { + if (status === 'unmounted' && previousStatus === 'close') { + onTransitionOutComplete?.(); + } + }, [status, previousStatus, onTransitionOutComplete]); + return isMounted || keepMounted ? (
(value: T): T | undefined { + const currentRef = React.useRef(value); + const previousRef = React.useRef(); + if (currentRef.current !== value) { + previousRef.current = currentRef.current; + currentRef.current = value; + } + return previousRef.current; +} From 1d6a1f62d89ed54764ed9215677ae86083556511 Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Fri, 6 Dec 2024 19:42:03 +0300 Subject: [PATCH 6/8] fix(Popup): fix tests in related components --- .../ActionTooltip/ActionTooltip.tsx | 1 - .../__tests__/ActionTooltip.test.tsx | 18 +++++--- .../Dialog/DialogFooter/DialogFooter.tsx | 1 - .../DropdownMenu/DropdownMenuPopup.tsx | 15 +++++-- src/components/Popover/Popover.scss | 12 +++-- src/components/Popover/Popover.tsx | 9 +--- .../Popover/__stories__/Popover.stories.tsx | 1 - .../Popover/__tests__/Popover.test.tsx | 44 ++++++++++--------- src/components/Popover/types.ts | 4 +- src/components/Popup/Popup.scss | 2 +- src/components/Popup/Popup.tsx | 27 +++++++++--- src/components/Popup/__tests__/Popup.test.tsx | 22 +++------- src/components/Select/Select.tsx | 15 +++---- .../Select/__stories__/Select.stories.tsx | 16 ++++--- .../components/SelectPopup/SelectPopup.tsx | 13 +++--- .../Select/components/SelectPopup/types.ts | 1 + src/components/Tooltip/Tooltip.tsx | 1 - .../Tooltip/__tests__/Tooltip.test.tsx | 13 +++--- .../components/PopupWithTogglerList.tsx | 3 +- 19 files changed, 118 insertions(+), 100 deletions(-) diff --git a/src/components/ActionTooltip/ActionTooltip.tsx b/src/components/ActionTooltip/ActionTooltip.tsx index 7d372d6ccc..5d16e885be 100644 --- a/src/components/ActionTooltip/ActionTooltip.tsx +++ b/src/components/ActionTooltip/ActionTooltip.tsx @@ -63,7 +63,6 @@ export function ActionTooltip(props: ActionTooltipProps) { anchorElement={anchorElement} disableEscapeKeyDown disableOutsideClick - disableLayer qa={qa} >
diff --git a/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx b/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx index 338026f763..f95a463449 100644 --- a/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx +++ b/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import {createEvent, fireEvent, render, screen} from '../../../../test-utils/utils'; +import {createEvent, fireEvent, render, screen, waitFor} from '../../../../test-utils/utils'; import {ActionTooltip} from '../ActionTooltip'; export function fireAnimationEndEvent(el: Node | Window, animationName = 'animation') { @@ -46,7 +46,9 @@ test('should show tooltip on hover and hide on un hover', async () => { fireAnimationEndEvent(tooltip); - expect(tooltip).not.toBeInTheDocument(); + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); }); test('should show tooltip on focus and hide on blur', async () => { @@ -71,7 +73,9 @@ test('should show tooltip on focus and hide on blur', async () => { fireAnimationEndEvent(tooltip); expect(button).not.toHaveFocus(); - expect(tooltip).not.toBeInTheDocument(); + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); }); test('should hide on press Escape', async () => { @@ -96,7 +100,9 @@ test('should hide on press Escape', async () => { fireAnimationEndEvent(tooltip); expect(button).toHaveFocus(); - expect(tooltip).not.toBeInTheDocument(); + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); }); test('should show on focus and hide on un hover', async () => { @@ -124,5 +130,7 @@ test('should show on focus and hide on un hover', async () => { fireAnimationEndEvent(tooltip); expect(button).toHaveFocus(); - expect(tooltip).not.toBeInTheDocument(); + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); }); diff --git a/src/components/Dialog/DialogFooter/DialogFooter.tsx b/src/components/Dialog/DialogFooter/DialogFooter.tsx index c87b52428f..c416d684fa 100644 --- a/src/components/Dialog/DialogFooter/DialogFooter.tsx +++ b/src/components/Dialog/DialogFooter/DialogFooter.tsx @@ -135,7 +135,6 @@ export class DialogFooter extends React.Component { open={showError} anchorRef={this.errorTooltipRef} placement={['bottom', 'top']} - disableLayer disablePortal hasArrow > diff --git a/src/components/DropdownMenu/DropdownMenuPopup.tsx b/src/components/DropdownMenu/DropdownMenuPopup.tsx index df1e3a639a..dfc9cb88de 100644 --- a/src/components/DropdownMenu/DropdownMenuPopup.tsx +++ b/src/components/DropdownMenu/DropdownMenuPopup.tsx @@ -57,7 +57,9 @@ export const DropdownMenuPopup = ({ const handleMouseEnter = React.useCallback( (event: React.MouseEvent) => { setActiveMenuPath(path); - popupProps?.onMouseEnter?.(event); + (popupProps?.floatingProps?.onMouseEnter as React.MouseEventHandler | undefined)?.( + event, + ); }, [path, popupProps, setActiveMenuPath], ); @@ -65,7 +67,9 @@ export const DropdownMenuPopup = ({ const handleMouseLeave = React.useCallback( (event: React.MouseEvent) => { activateParent(); - popupProps?.onMouseLeave?.(event); + (popupProps?.floatingProps?.onMouseLeave as React.MouseEventHandler | undefined)?.( + event, + ); }, [activateParent, popupProps], ); @@ -144,8 +148,11 @@ export const DropdownMenuPopup = ({ onClose={onClose} placement="bottom-start" {...popupProps} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} + floatingProps={{ + ...popupProps?.floatingProps, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }} > {children || ( diff --git a/src/components/Popover/Popover.scss b/src/components/Popover/Popover.scss index 7b0a9e1550..415f763b68 100644 --- a/src/components/Popover/Popover.scss +++ b/src/components/Popover/Popover.scss @@ -24,13 +24,11 @@ $block: '.#{variables.$ns}popover'; --_--close-offset: 8px; --_--close-size: 24px; - &-popup-content { - box-sizing: border-box; - min-height: 40px; - max-width: var(--g-popover-max-width, 300px); - padding: var(--g-popover-padding, var(--_--padding)); - cursor: default; - } + box-sizing: border-box; + min-height: 40px; + max-width: var(--g-popover-max-width, 300px); + padding: var(--g-popover-padding, var(--_--padding)); + cursor: default; &-title { @include mixins.text-subheader-3(); diff --git a/src/components/Popover/Popover.tsx b/src/components/Popover/Popover.tsx index d3d46950e8..9596538095 100644 --- a/src/components/Popover/Popover.tsx +++ b/src/components/Popover/Popover.tsx @@ -37,7 +37,6 @@ export const Popover = React.forwardRef diff --git a/src/components/Popover/__stories__/Popover.stories.tsx b/src/components/Popover/__stories__/Popover.stories.tsx index 42fb8cee50..a71efc178e 100644 --- a/src/components/Popover/__stories__/Popover.stories.tsx +++ b/src/components/Popover/__stories__/Popover.stories.tsx @@ -75,7 +75,6 @@ const meta: Meta = { tooltipCancelButton: {control: 'object'}, tooltipOffset: {control: 'object'}, tooltipClassName: {control: 'text'}, - tooltipContentClassName: {control: 'text'}, className: {control: 'text'}, onClick: {action: 'onClick'}, onOpenChange: {action: 'onOpenChange'}, diff --git a/src/components/Popover/__tests__/Popover.test.tsx b/src/components/Popover/__tests__/Popover.test.tsx index 7dd3423bf8..296ccf7f52 100644 --- a/src/components/Popover/__tests__/Popover.test.tsx +++ b/src/components/Popover/__tests__/Popover.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {setupTimersMock} from '../../../../test-utils/setupTimersMock'; -import {act, fireEvent, render, screen} from '../../../../test-utils/utils'; +import {act, fireEvent, render, screen, waitFor} from '../../../../test-utils/utils'; import {Popover} from '../Popover'; import {PopoverBehavior, delayByBehavior} from '../config'; import type {PopoverProps} from '../types'; @@ -29,14 +29,16 @@ const waitForTooltipOpenedStateChange = (shouldOpen?: boolean) => const checkIfPopoverOpened = () => { const popover = screen.queryByTestId('popover-tooltip'); - expect(popover).toHaveClass('g-popup_open'); + expect(popover).toBeInTheDocument(); }; -const checkIfPopoverClosed = () => { +const checkIfPopoverClosed = async () => { const popover = screen.queryByTestId('popover-tooltip'); if (popover) { - expect(popover).not.toHaveClass('g-popup_open'); + await waitFor(() => { + expect(popover).not.toBeInTheDocument(); + }); } else { expect(true).toBe(true); } @@ -62,21 +64,21 @@ test('Can be opened/closed on hover/unhover', async () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.mouseEnter(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(true); }); checkIfPopoverOpened(); fireEvent.mouseLeave(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(false); }); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); -test("Doesn't close if the cursor is on the tooltip", () => { +test("Doesn't close if the cursor is on the tooltip", async () => { render( renderPopover({ openOnHover: true, @@ -86,21 +88,21 @@ test("Doesn't close if the cursor is on the tooltip", () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.mouseEnter(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(true); }); const tooltip = screen.getByText(defaultTooltipContent); fireEvent.mouseLeave(popoverTrigger); fireEvent.mouseEnter(tooltip); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(false); }); checkIfPopoverOpened(); }); -test("Doesn't close on unhover if not autoclosable", () => { +test("Doesn't close on unhover if not autoclosable", async () => { render( renderPopover({ openOnHover: true, @@ -110,18 +112,18 @@ test("Doesn't close on unhover if not autoclosable", () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.mouseEnter(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(true); }); fireEvent.mouseLeave(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(false); }); checkIfPopoverOpened(); }); -test('Can be opened/closed on click', () => { +test('Can be opened/closed on click', async () => { render( renderPopover({ openOnHover: false, @@ -136,10 +138,10 @@ test('Can be opened/closed on click', () => { fireEvent.click(popoverTrigger); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); -test("Can't be opened by click if onClick returns false", () => { +test("Can't be opened by click if onClick returns false", async () => { render( renderPopover({ openOnHover: false, @@ -152,10 +154,10 @@ test("Can't be opened by click if onClick returns false", () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.click(popoverTrigger); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); -test("Can't be opened if disabled", () => { +test("Can't be opened if disabled", async () => { render( renderPopover({ disabled: true, @@ -165,10 +167,10 @@ test("Can't be opened if disabled", () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.click(popoverTrigger); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); -test('Can be closed on click', () => { +test('Can be closed on click', async () => { render( renderPopover({ hasClose: true, @@ -181,7 +183,7 @@ test('Can be closed on click', () => { const closeButton = screen.getByRole('button', {name: 'Close'}); fireEvent.click(closeButton); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); test('Calls close button click handler on close button click', () => { diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index cc936a6b05..db2dcfdea6 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -44,8 +44,6 @@ export interface PopoverExternalProps { tooltipOffset?: PopupOffset; /** Tooltip's css class */ tooltipClassName?: string; - /** Tooltip's content css class */ - tooltipContentClassName?: string; /** css class for the control */ className?: string; /** @@ -124,7 +122,7 @@ export type PopoverDefaultProps = { export type PopoverProps = Pick< PopupProps, - 'anchorElement' | 'anchorRef' | 'strategy' | 'placement' | 'middlewares' + 'anchorElement' | 'anchorRef' | 'strategy' | 'placement' > & PopoverExternalProps & PopoverBehaviorProps & diff --git a/src/components/Popup/Popup.scss b/src/components/Popup/Popup.scss index ab1eb21c01..377b767d00 100644 --- a/src/components/Popup/Popup.scss +++ b/src/components/Popup/Popup.scss @@ -26,7 +26,7 @@ $transition-distance: 10px; transition-property: opacity, transform; transition-timing-function: ease-out; - &_mounted { + &_open { visibility: visible; } diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index 335f3a05b4..9a24e012c0 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -119,8 +119,12 @@ export interface PopupProps extends DOMProps, AriaLabelingProps, QAProps { id?: string; /** CSS property `z-index` */ zIndex?: number; + /** Callback called when `Popup` is opened and "in" transition is started */ + onTransitionIn?: () => void; /** Callback called when `Popup` is opened and "in" transition is completed */ onTransitionInComplete?: () => void; + /** Callback called when `Popup` is closed and "out" transition is started */ + onTransitionOut?: () => void; /** Callback called when `Popup` is closed and "out" transition is completed */ onTransitionOutComplete?: () => void; } @@ -160,6 +164,8 @@ export function Popup({ id, role: roleProp, zIndex = 1000, + onTransitionIn, + onTransitionOut, onTransitionInComplete, onTransitionOutComplete, ...restProps @@ -231,8 +237,8 @@ export function Popup({ }); const role = useRole(context, { - enabled: Boolean(roleProp), - role: roleProp, + enabled: Boolean(roleProp || modalFocus), + role: roleProp ?? (modalFocus ? 'dialog' : undefined), }); const dismiss = useDismiss(context, { enabled: !disableOutsideClick || !disableEscapeKeyDown, @@ -274,19 +280,25 @@ export function Popup({ [status, onTransitionInComplete], ); - // Cannot use transitionend event for "out" transition due to unmounting from the DOM + // Cannot use transitionend event for these callbacks due to unmounting from the DOM React.useEffect(() => { + if (status === 'initial' && previousStatus === 'unmounted') { + onTransitionIn?.(); + } + if (status === 'close' && previousStatus === 'open') { + onTransitionOut?.(); + } if (status === 'unmounted' && previousStatus === 'close') { onTransitionOutComplete?.(); } - }, [status, previousStatus, onTransitionOutComplete]); + }, [status, previousStatus, onTransitionIn, onTransitionOut, onTransitionOutComplete]); return isMounted || keepMounted ? (
{ expect(popup).toHaveClass(arbitratyClassName); }); - test('should pass arbitraty className to content', () => { - const arbitratyClassName = 'arbitratyClassName'; - render(); - const popup = screen.getByTestId(qaId); - /* eslint-disable-next-line testing-library/no-node-access */ - expect(popup.firstChild).toHaveClass(arbitratyClassName); - }); - test('should open on click', async () => { const btnText = 'Click me'; function Test() { @@ -57,27 +49,27 @@ describe('Popup', () => { expect(popup).not.toHaveAttribute('role'); }); - test('should set aria-modal to true and role to dialog if focusTrap is true', async () => { - render(); + test('should set aria-modal to true and role to dialog if modalFocus is true', async () => { + render(); const popup = screen.getByRole('dialog'); expect(popup).toHaveAttribute('aria-modal', 'true'); }); - test('should use role from props if focusTrap is true', async () => { - render(); + test('should use role from props if modalFocus is true', async () => { + render(); const popup = screen.getByRole('alertdialog'); expect(popup).toHaveAttribute('aria-modal', 'true'); }); - test('should use aria-modal from props if focusTrap is true', async () => { - render(); + test('should not set aria-modal from props if modalFocus is true', async () => { + render(); const popup = screen.getByTestId(qaId); expect(popup).not.toHaveAttribute('aria-modal'); expect(popup).not.toHaveAttribute('role'); }); test('should remove aria-modal if popup is closed', async () => { - render(); + render(); const popup = screen.getByRole('dialog'); expect(popup).not.toHaveAttribute('aria-modal'); }); diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 22dba73d51..bb627e1d77 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -221,14 +221,6 @@ export const Select = React.forwardRef(function disabled: filterable, }); - React.useEffect(() => { - if (open) { - if (filterable) { - filterRef.current?.focus(); - } - } - }, [open, filterable]); - const mods: CnMods = { ...(width === 'max' && {width}), }; @@ -355,6 +347,13 @@ export const Select = React.forwardRef(function virtualized={virtualized} mobile={mobile} placement={popupPlacement} + onAfterOpen={ + filterable + ? () => { + filterRef.current?.focus(); + } + : undefined + } onAfterClose={ filterable ? () => { diff --git a/src/components/Select/__stories__/Select.stories.tsx b/src/components/Select/__stories__/Select.stories.tsx index 2a2588623e..82417fe125 100644 --- a/src/components/Select/__stories__/Select.stories.tsx +++ b/src/components/Select/__stories__/Select.stories.tsx @@ -4,6 +4,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import {Select} from '..'; import {Button} from '../../Button'; +import {Flex} from '../../layout'; import {SelectPopupWidthShowcase} from './SelectPopupWidthShowcase'; import {SelectShowcase} from './SelectShowcase'; @@ -34,12 +35,15 @@ type Story = StoryObj; export const Default = { render: (args) => ( - + + + + ), } satisfies Story; diff --git a/src/components/Select/components/SelectPopup/SelectPopup.tsx b/src/components/Select/components/SelectPopup/SelectPopup.tsx index f3ef6c85d6..57fbc89635 100644 --- a/src/components/Select/components/SelectPopup/SelectPopup.tsx +++ b/src/components/Select/components/SelectPopup/SelectPopup.tsx @@ -21,6 +21,7 @@ export const SelectPopup = React.forwardRef( ( { handleClose, + onAfterOpen, onAfterClose, width, open, @@ -46,18 +47,20 @@ export const SelectPopup = React.forwardRef( ) : ( } placement={placement} open={open} onClose={handleClose} disablePortal={disablePortal} - restoreFocus - restoreFocusRef={controlRef} - middlewares={getMiddlewares({width, disablePortal, virtualized})} + autoFocus + initialFocus={-1} + returnFocus={controlRef} + floatingMiddlewares={getMiddlewares({width, disablePortal, virtualized})} id={id} - onTransitionExited={onAfterClose} + onTransitionIn={onAfterOpen} + onTransitionOutComplete={onAfterClose} > {children} diff --git a/src/components/Select/components/SelectPopup/types.ts b/src/components/Select/components/SelectPopup/types.ts index 597d754261..b88d4d1379 100644 --- a/src/components/Select/components/SelectPopup/types.ts +++ b/src/components/Select/components/SelectPopup/types.ts @@ -15,5 +15,6 @@ export type SelectPopupProps = { disablePortal?: boolean; virtualized?: boolean; id?: string; + onAfterOpen?: () => void; onAfterClose?: () => void; }; diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 695079319c..6f1cdb3b57 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -63,7 +63,6 @@ export const Tooltip = (props: TooltipProps) => { disablePortal={disablePortal} disableEscapeKeyDown disableOutsideClick - disableLayer qa={qa} >
diff --git a/src/components/Tooltip/__tests__/Tooltip.test.tsx b/src/components/Tooltip/__tests__/Tooltip.test.tsx index a609d74dd4..bae4bf6557 100644 --- a/src/components/Tooltip/__tests__/Tooltip.test.tsx +++ b/src/components/Tooltip/__tests__/Tooltip.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import {createEvent, fireEvent, render, screen} from '../../../../test-utils/utils'; +import {createEvent, fireEvent, render, screen, waitFor} from '../../../../test-utils/utils'; import {Tooltip} from '../Tooltip'; export function fireAnimationEndEvent(el: Node | Window, animationName = 'animation') { @@ -44,9 +44,9 @@ test('should show tooltip on hover and hide on un hover', async () => { await user.unhover(button); - fireAnimationEndEvent(tooltip); - - expect(tooltip).not.toBeInTheDocument(); + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); }); test('should not show tooltip on focus', async () => { @@ -89,5 +89,8 @@ test('should hide on press Escape', async () => { fireAnimationEndEvent(tooltip); expect(button).toHaveFocus(); - expect(tooltip).not.toBeInTheDocument(); + + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); }); diff --git a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx index 8f2f6f17e8..65ce4f22d2 100644 --- a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx +++ b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx @@ -72,8 +72,7 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro open={open} onClose={() => setOpen(false)} disablePortal - restoreFocus - restoreFocusRef={controlRef} + returnFocus={controlRef} > Date: Thu, 19 Dec 2024 15:33:39 +0300 Subject: [PATCH 7/8] feat(modal)!: use floating ui in modal (#1995) Co-authored-by: oynikishin --- package-lock.json | 22 +- package.json | 3 +- src/components/Dialog/Dialog.tsx | 220 +++++------ .../Dialog/DialogFooter/DialogFooter.tsx | 227 +++++------- src/components/Dialog/DialogPrivateContext.ts | 9 + .../Dialog/__stories__/DialogShowcase.tsx | 4 +- src/components/Modal/Modal.scss | 78 ++-- src/components/Modal/Modal.tsx | 344 ++++++++++++------ .../Modal/__stories__/Modal.stories.tsx | 90 ++++- src/components/Modal/i18n/en.json | 4 + src/components/Modal/i18n/index.ts | 8 + src/components/Modal/i18n/ru.json | 4 + src/components/Popup/Popup.tsx | 19 +- src/components/Sheet/Sheet.tsx | 14 +- .../DefaultShowcase.stories.tsx | 10 + .../ToasterComponent/ToasterPortal.tsx | 16 +- src/components/utils/FocusTrap.tsx | 142 -------- src/hooks/index.ts | 1 - src/hooks/private/index.ts | 1 - src/hooks/private/useRestoreFocus/README.md | 17 - src/hooks/private/useRestoreFocus/index.ts | 2 - .../useRestoreFocus/useRestoreFocus.tsx | 100 ----- src/hooks/useBodyScrollLock/README.md | 17 - src/hooks/useBodyScrollLock/index.ts | 2 - .../useBodyScrollLock/useBodyScrollLock.ts | 106 ------ 25 files changed, 638 insertions(+), 822 deletions(-) create mode 100644 src/components/Dialog/DialogPrivateContext.ts create mode 100644 src/components/Modal/i18n/en.json create mode 100644 src/components/Modal/i18n/index.ts create mode 100644 src/components/Modal/i18n/ru.json delete mode 100644 src/components/utils/FocusTrap.tsx delete mode 100644 src/hooks/private/useRestoreFocus/README.md delete mode 100644 src/hooks/private/useRestoreFocus/index.ts delete mode 100644 src/hooks/private/useRestoreFocus/useRestoreFocus.tsx delete mode 100644 src/hooks/useBodyScrollLock/README.md delete mode 100644 src/hooks/useBodyScrollLock/index.ts delete mode 100644 src/hooks/useBodyScrollLock/useBodyScrollLock.ts diff --git a/package-lock.json b/package-lock.json index c11370b6ce..2f8fbb6273 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,11 @@ "license": "MIT", "dependencies": { "@bem-react/classname": "^1.6.0", - "@floating-ui/react": "^0.26.28", + "@floating-ui/react": "^0.27.0", "@gravity-ui/i18n": "^1.7.0", "@gravity-ui/icons": "^2.11.0", "@tanstack/react-virtual": "^3.10.8", "blueimp-md5": "^2.19.0", - "focus-trap": "^7.6.2", "lodash": "^4.17.21", "rc-slider": "^11.1.7", "react-beautiful-dnd": "^13.1.1", @@ -3177,17 +3176,18 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.26.28", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", - "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.0.tgz", + "integrity": "sha512-WLEksq7fJapXSJbmfiyq9pAW0a7ZFMEJToFE4oTDESxGjoa+nZu3YMjmZE2KvoUtQhqOK2yMMfWQFZyeWD0wGQ==", + "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.8", "tabbable": "^6.0.0" }, "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "react": ">=17.0.0", + "react-dom": ">=17.0.0" } }, "node_modules/@floating-ui/react-dom": { @@ -12318,14 +12318,6 @@ "readable-stream": "^2.3.6" } }, - "node_modules/focus-trap": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.2.tgz", - "integrity": "sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w==", - "dependencies": { - "tabbable": "^6.2.0" - } - }, "node_modules/follow-redirects": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", diff --git a/package.json b/package.json index 3cbe219647..9ce8d2eeb9 100644 --- a/package.json +++ b/package.json @@ -135,12 +135,11 @@ }, "dependencies": { "@bem-react/classname": "^1.6.0", - "@floating-ui/react": "^0.26.28", + "@floating-ui/react": "^0.27.0", "@gravity-ui/i18n": "^1.7.0", "@gravity-ui/icons": "^2.11.0", "@tanstack/react-virtual": "^3.10.8", "blueimp-md5": "^2.19.0", - "focus-trap": "^7.6.2", "lodash": "^4.17.21", "rc-slider": "^11.1.7", "react-beautiful-dnd": "^13.1.1", diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index 1acdc56547..5d569a2d9e 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -12,136 +12,152 @@ import {DialogBody} from './DialogBody/DialogBody'; import {DialogDivider} from './DialogDivider/DialogDivider'; import {DialogFooter} from './DialogFooter/DialogFooter'; import {DialogHeader} from './DialogHeader/DialogHeader'; +import {DialogPrivateContext} from './DialogPrivateContext'; +import type {DialogPrivateContextProps} from './DialogPrivateContext'; import './Dialog.scss'; const b = block('dialog'); -interface DialogOwnProps extends QAProps { +export interface DialogProps extends QAProps { open: boolean; children: React.ReactNode; + onOpenChange?: ModalProps['onOpenChange']; + onEnterKeyDown?: (event: KeyboardEvent) => void; onEscapeKeyDown?: ModalProps['onEscapeKeyDown']; - onEnterKeyDown?: ModalProps['onEnterKeyDown']; onOutsideClick?: ModalProps['onOutsideClick']; onClose: ( event: MouseEvent | KeyboardEvent, reason: ModalCloseReason | 'closeButtonClick', ) => void; - onTransitionEnter?: ModalProps['onTransitionEnter']; - onTransitionEntered?: ModalProps['onTransitionEntered']; - onTransitionExit?: ModalProps['onTransitionExit']; - onTransitionExited?: ModalProps['onTransitionExited']; + onTransitionIn?: ModalProps['onTransitionIn']; + onTransitionInComplete?: ModalProps['onTransitionInComplete']; + onTransitionOut?: ModalProps['onTransitionOut']; + onTransitionOutComplete?: ModalProps['onTransitionOutComplete']; className?: string; modalClassName?: string; size?: 's' | 'm' | 'l'; 'aria-label'?: string; 'aria-labelledby'?: string; container?: HTMLElement; - disableFocusTrap?: boolean; - disableAutoFocus?: boolean; - restoreFocusRef?: React.RefObject; + // TODO: Remove from readme disableFocusTrap disableAutoFocus + initialFocus?: ModalProps['initialFocus'] | 'cancel' | 'apply'; + returnFocus?: ModalProps['returnFocus']; contentOverflow?: 'visible' | 'auto'; + disableBodyScrollLock?: boolean; + disableEscapeKeyDown?: boolean; + disableOutsideClick?: boolean; + keepMounted?: boolean; + hasCloseButton?: boolean; } -interface DialogDefaultProps { - disableBodyScrollLock: boolean; - disableEscapeKeyDown: boolean; - disableOutsideClick: boolean; - keepMounted: boolean; - hasCloseButton: boolean; -} +export function Dialog({ + container, + children, + open, + disableBodyScrollLock = false, + disableEscapeKeyDown = false, + disableOutsideClick = false, + initialFocus, + returnFocus, + keepMounted = false, + size, + contentOverflow = 'visible', + className, + modalClassName, + hasCloseButton = true, + onEscapeKeyDown, + onEnterKeyDown, + onOpenChange, + onOutsideClick, + onClose, + onTransitionIn, + onTransitionInComplete, + onTransitionOut, + onTransitionOutComplete, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + qa, +}: DialogProps) { + const handleCloseButtonClick = React.useCallback( + (event: React.MouseEvent) => { + onClose(event.nativeEvent, 'closeButtonClick'); + }, + [onClose], + ); + + const footerAutoFocusRef = React.useRef(null); -export type DialogProps = DialogOwnProps & Partial; -type DialogInnerProps = DialogOwnProps & DialogDefaultProps; + const privateContextProps = React.useMemo(() => { + const result: DialogPrivateContextProps = { + onTooltipEscapeKeyDown: (event: KeyboardEvent) => { + onOpenChange?.(false, event, 'escape-key'); + onEscapeKeyDown?.(event); + onClose?.(event, 'escapeKeyDown'); + }, + }; -export class Dialog extends React.Component { - static defaultProps: DialogDefaultProps = { - disableBodyScrollLock: false, - disableEscapeKeyDown: false, - disableOutsideClick: false, - keepMounted: false, - hasCloseButton: true, - }; + if (typeof initialFocus === 'string') { + result.initialFocusRef = footerAutoFocusRef; + result.initialFocusAction = initialFocus; + } - static Footer = DialogFooter; - static Header = DialogHeader; - static Body = DialogBody; - static Divider = DialogDivider; + return result; + }, [initialFocus, onEscapeKeyDown, onClose, onOpenChange]); - render() { - const { - container, - children, - open, - disableBodyScrollLock, - disableEscapeKeyDown, - disableOutsideClick, - disableFocusTrap, - disableAutoFocus, - restoreFocusRef, - keepMounted, - size, - contentOverflow = 'visible', - className, - modalClassName, - hasCloseButton, - onEscapeKeyDown, - onEnterKeyDown, - onOutsideClick, - onClose, - onTransitionEnter, - onTransitionEntered, - onTransitionExit, - onTransitionExited, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - qa, - } = this.props; + let initialFocusValue: ModalProps['initialFocus']; + if (typeof initialFocus === 'string') { + initialFocusValue = footerAutoFocusRef; + } else { + initialFocusValue = initialFocus; + } - return ( - +
-
+ {children} - {hasCloseButton && } -
- - ); - } + - private handleCloseButtonClick = (event: React.MouseEvent) => { - const {onClose} = this.props; - onClose(event.nativeEvent, 'closeButtonClick'); - }; + {hasCloseButton && } +
+
+ ); } + +Dialog.Footer = DialogFooter; +Dialog.Header = DialogHeader; +Dialog.Body = DialogBody; +Dialog.Divider = DialogDivider; diff --git a/src/components/Dialog/DialogFooter/DialogFooter.tsx b/src/components/Dialog/DialogFooter/DialogFooter.tsx index c416d684fa..d842264917 100644 --- a/src/components/Dialog/DialogFooter/DialogFooter.tsx +++ b/src/components/Dialog/DialogFooter/DialogFooter.tsx @@ -2,10 +2,14 @@ import React from 'react'; +import type {UseFloatingOptions} from '@floating-ui/react'; + +import {useForkRef} from '../../../hooks'; import {Button} from '../../Button'; import type {ButtonProps, ButtonView} from '../../Button'; import {Popup} from '../../Popup'; import {block} from '../../utils/cn'; +import {DialogPrivateContext} from '../DialogPrivateContext'; import './DialogFooter.scss'; @@ -33,15 +37,9 @@ interface DialogFooterOwnProps { interface DialogFooterDefaultProps { preset: ButtonPreset; showError: boolean; - /** - * @deprecated use on onEnterKeyDown on Dialog component - */ - listenKeyEnter: boolean; } -export type DialogFooterProps = DialogFooterOwnProps & Partial; -type DialogFooterInnerProps = DialogFooterOwnProps & DialogFooterDefaultProps; - +// TODO: Оно точно нужно? function getButtonView(preset: ButtonPreset): ButtonView { switch (preset) { case 'default': @@ -55,128 +53,103 @@ function getButtonView(preset: ButtonPreset): ButtonView { } } -export class DialogFooter extends React.Component { - static defaultProps: DialogFooterDefaultProps = { - preset: 'default', - showError: false, - listenKeyEnter: false, - }; - - private errorTooltipRef = React.createRef(); - - componentDidMount() { - if (this.props.listenKeyEnter) { - this.attachKeyDownListeners(); - } - } - - componentDidUpdate(prevProps: DialogFooterInnerProps) { - if (!this.props.listenKeyEnter && prevProps.listenKeyEnter) { - this.detachKeyDownListeners(); - } - if (this.props.listenKeyEnter && !prevProps.listenKeyEnter) { - this.attachKeyDownListeners(); - } - } - - componentWillUnmount() { - this.detachKeyDownListeners(); - } +export type DialogFooterProps = DialogFooterOwnProps & Partial; - render() { - const { - onClickButtonCancel, - onClickButtonApply, - loading, - textButtonCancel, - textButtonApply, - propsButtonCancel, - propsButtonApply, - preset, - children, - errorText, - showError, - renderButtons, - className, - } = this.props; - - const buttonCancel = ( -
- -
- ); - - const buttonApply = ( -
- +
+ ); + + const handleOpenChange = React.useCallback>( + (isOpen, event, reason) => { + if (!isOpen && event && reason === 'escape-key') { + onTooltipEscapeKeyDown?.(event as KeyboardEvent); + } + }, + [onTooltipEscapeKeyDown], + ); + + const buttonApply = ( +
+ + {errorText && ( + - {textButtonApply} - - {errorText && ( - -
{errorText}
-
+
{errorText}
+
+ )} +
+ ); + + return ( +
+
{children}
+
+ {renderButtons ? ( + renderButtons(buttonApply, buttonCancel) + ) : ( + + {textButtonCancel && buttonCancel} + {textButtonApply && buttonApply} + )}
- ); - - return ( -
-
{children}
-
- {renderButtons ? ( - renderButtons(buttonApply, buttonCancel) - ) : ( - - {textButtonCancel && buttonCancel} - {textButtonApply && buttonApply} - - )} -
-
- ); - } - - private attachKeyDownListeners() { - setTimeout(() => { - window.addEventListener('keydown', this.handleKeyDown); - }, 0); - } - - private detachKeyDownListeners() { - window.removeEventListener('keydown', this.handleKeyDown); - } - - private handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - if (this.props.onClickButtonApply) { - this.props.onClickButtonApply(event); - } - } - }; +
+ ); } diff --git a/src/components/Dialog/DialogPrivateContext.ts b/src/components/Dialog/DialogPrivateContext.ts new file mode 100644 index 0000000000..4dbbd60336 --- /dev/null +++ b/src/components/Dialog/DialogPrivateContext.ts @@ -0,0 +1,9 @@ +import React from 'react'; + +export interface DialogPrivateContextProps { + initialFocusRef?: React.RefObject; + initialFocusAction?: 'apply' | 'cancel'; + onTooltipEscapeKeyDown?: (event: KeyboardEvent) => void; +} + +export const DialogPrivateContext = React.createContext({}); diff --git a/src/components/Dialog/__stories__/DialogShowcase.tsx b/src/components/Dialog/__stories__/DialogShowcase.tsx index b3e0039b7a..a4c6605fac 100644 --- a/src/components/Dialog/__stories__/DialogShowcase.tsx +++ b/src/components/Dialog/__stories__/DialogShowcase.tsx @@ -68,7 +68,7 @@ function OtherDialog() { keepMounted onEnterKeyDown={handleApply} qa="darthVader" - onTransitionEntered={() => { + onTransitionInComplete={() => { selectRef?.current?.focus(); setTimeout(() => setOpenSelect(true), 0); }} @@ -165,6 +165,7 @@ export function DialogShowcase() { className="my-custom-class-for-dialog" hasCloseButton onEnterKeyDown={handleApply} + initialFocus="apply" > void; keepMounted?: boolean; disableBodyScrollLock?: boolean; - /** @deprecated Use focusTrap instead */ - disableFocusTrap?: boolean; - /** @deprecated Use autoFocus instead */ - disableAutoFocus?: boolean; - focusTrap?: boolean; - autoFocus?: boolean; - restoreFocusRef?: React.RefObject; + /** + * FloatingFocusManager `initialFocus` property + */ + initialFocus?: FloatingFocusManagerProps['initialFocus']; + /** + * FloatingFocusManager `returnFocus` property + */ + returnFocus?: FloatingFocusManagerProps['returnFocus']; + + /** Do not add a11y dismiss buttons when managing focus */ + disableFocusVisuallyHiddenDismiss?: boolean; + children?: React.ReactNode; + + /** + * This callback will be called when Escape key pressed on keyboard, or click outside was made + * This behaviour could be disabled with `disableEscapeKeyDown` + * and `disableOutsideClick` options + * + * @deprecated Use `onOpenChange` instead + */ + onClose?: (event: MouseEvent | KeyboardEvent, reason: ModalCloseReason) => void; + /** + * This callback will be called when Escape key pressed on keyboard + * This behaviour could be disabled with `disableEscapeKeyDown` option + * + * @deprecated Use `onOpenChange` instead + */ + onEscapeKeyDown?: (event: KeyboardEvent) => void; + /** + * This callback will be called when Enter key is pressed on keyboard + * + * @deprecated It is not recommended to use this callback. + * Consider using the submit event in case of a form content or using initialFocus property on the confirm button in case of non-interactive content + */ + onEnterKeyDown?: (event: KeyboardEvent) => void; + /** + * This callback will be called when click is outside of elements of "top layer" + * This behaviour could be disabled with `disableOutsideClick` option + * + * @deprecated Use `onOpenChange` instead + */ + onOutsideClick?: (event: MouseEvent) => void; + /** Do not dismiss on escape key press */ + disableEscapeKeyDown?: boolean; + /** Do not dismiss on outside click */ + disableOutsideClick?: boolean; /** * Id of visible `` caption element */ @@ -40,36 +95,41 @@ export interface ModalProps extends DOMProps, LayerExtendableProps, QAProps { 'aria-label'?: string; container?: HTMLElement; contentClassName?: string; - onTransitionEnter?: VoidFunction; - onTransitionEntered?: VoidFunction; - onTransitionExit?: VoidFunction; - onTransitionExited?: VoidFunction; + /** Callback called when `Modal` is opened and "in" transition is started */ + onTransitionIn?: () => void; + /** Callback called when `Modal` is opened and "in" transition is completed */ + onTransitionInComplete?: () => void; + /** Callback called when `Modal` is closed and "out" transition is started */ + onTransitionOut?: () => void; + /** Callback called when `Popup` is closed and "out" transition is completed */ + onTransitionOutComplete?: () => void; contentOverflow?: 'visible' | 'auto'; -} -export type ModalCloseReason = LayerCloseReason; + floatingRef?: React.RefObject; +} const b = block('modal'); +const TRANSITION_DURATION = 150; + export function Modal({ open = false, + onOpenChange, keepMounted = false, disableBodyScrollLock = false, disableEscapeKeyDown, disableOutsideClick, - disableFocusTrap, - disableAutoFocus, - focusTrap = true, - autoFocus = true, - restoreFocusRef, + initialFocus, + returnFocus, + disableFocusVisuallyHiddenDismiss, onEscapeKeyDown, - onEnterKeyDown, onOutsideClick, onClose, - onTransitionEnter, - onTransitionEntered, - onTransitionExit, - onTransitionExited, + onEnterKeyDown, + onTransitionIn, + onTransitionInComplete, + onTransitionOut, + onTransitionOutComplete, children, style, contentOverflow = 'visible', @@ -79,85 +139,159 @@ export function Modal({ 'aria-label': ariaLabel, container, qa, + floatingRef, }: ModalProps) { - const containerRef = React.useRef(null); - const contentRef = React.useRef(null); - const [inTransition, setInTransition] = React.useState(false); - - useBodyScrollLock({enabled: !disableBodyScrollLock && (open || inTransition)}); - const containerProps = useRestoreFocus({ - enabled: open || inTransition, - restoreFocusRef, - focusTrapped: true, - }); + const handleOpenChange = React.useCallback>( + (isOpen, event, reason) => { + onOpenChange?.(isOpen, event, reason); + + if (isOpen || !event) { + return; + } - useLayer({ + const closeReason = reason === 'escape-key' ? 'escapeKeyDown' : 'outsideClick'; + + if (closeReason === 'escapeKeyDown' && onEscapeKeyDown) { + onEscapeKeyDown(event as KeyboardEvent); + } + + if (closeReason === 'outsideClick' && onOutsideClick) { + onOutsideClick(event as MouseEvent); + } + + onClose?.(event as KeyboardEvent | MouseEvent, closeReason); + }, + [onOpenChange, onEscapeKeyDown, onOutsideClick, onClose], + ); + + const {refs, elements, context} = useFloating({ open, - disableEscapeKeyDown, - disableOutsideClick, - onEscapeKeyDown, - onEnterKeyDown, - onOutsideClick, - onClose, - contentRefs: [contentRef], - type: 'modal', + onOpenChange: handleOpenChange, + }); + + const initialFocusRef = React.useRef(null); + const handleFloatingRef = useForkRef( + refs.setFloating, + initialFocusRef, + floatingRef, + ); + + const dismiss = useDismiss(context, { + enabled: !disableOutsideClick || !disableEscapeKeyDown, + outsidePress: !disableOutsideClick, + escapeKey: !disableEscapeKeyDown, }); - return ( - containerRef.current?.addEventListener('animationend', done)} - classNames={getCSSTransitionClassNames(b)} - mountOnEnter={!keepMounted} - unmountOnExit={!keepMounted} - appear={true} - onEnter={() => { - setInTransition(true); - onTransitionEnter?.(); - }} - onExit={() => { - setInTransition(true); - onTransitionExit?.(); - }} - onEntered={() => { - setInTransition(false); - onTransitionEntered?.(); - }} - onExited={() => { - setInTransition(false); - onTransitionExited?.(); - }} - > - -
-
-
- { + if (status === 'initial' && previousStatus === 'unmounted') { + onTransitionIn?.(); + } + if (status === 'close' && previousStatus === 'open') { + onTransitionOut?.(); + } + if (status === 'unmounted' && previousStatus === 'close') { + onTransitionOutComplete?.(); + } + }, [previousStatus, status, onTransitionIn, onTransitionOut, onTransitionOutComplete]); + + const handleTransitionEnd = React.useCallback( + (event: React.TransitionEvent) => { + // There are two simultaneous transitions running at the same time + // Use specific name to only notify once + if ( + status === 'open' && + event.propertyName === 'transform' && + event.target === elements.floating + ) { + onTransitionInComplete?.(); + } + }, + [status, onTransitionInComplete, elements.floating], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (!onEnterKeyDown || event.key !== KeyCode.ENTER || event.defaultPrevented) { + return; + } + + const floatingElement = elements.floating; + if (!floatingElement) { + return; + } + const pathElements = event.nativeEvent.composedPath(); + const index = pathElements.indexOf(floatingElement); + + const nestedElements = index < 0 ? pathElements : pathElements.slice(0, index); + const nestedFloatingElementIndex = nestedElements.findIndex((el) => + (el as Element)?.hasAttribute('data-floating-ui-focusable'), + ); + + if (nestedFloatingElementIndex < 0) { + onEnterKeyDown(event.nativeEvent); + return; + } + + const hasInnerTabbableElements = nestedElements + .slice(0, nestedFloatingElementIndex) + .some((el) => isTabbable(el as Element)); + + if (!hasInnerTabbableElements) { + onEnterKeyDown(event.nativeEvent); + } + }, + [elements.floating, onEnterKeyDown], + ); + + return isMounted || keepMounted ? ( + + +
+
+ +
-
- {children} -
- -
+ {children} +
+
- - - ); + + + ) : null; } diff --git a/src/components/Modal/__stories__/Modal.stories.tsx b/src/components/Modal/__stories__/Modal.stories.tsx index 9bf8aa7c7d..1a40ecc56e 100644 --- a/src/components/Modal/__stories__/Modal.stories.tsx +++ b/src/components/Modal/__stories__/Modal.stories.tsx @@ -1,8 +1,11 @@ import React from 'react'; +import {faker} from '@faker-js/faker/locale/en'; import type {Meta, StoryFn} from '@storybook/react'; +import range from 'lodash/range'; import {Button} from '../../Button'; +import {Popup} from '../../Popup'; import {Modal} from '../Modal'; import type {ModalProps} from '../Modal'; @@ -12,24 +15,103 @@ export default { } as Meta; export const Default: StoryFn = (props) => { - const [open, setOpen] = React.useState(false); + const [openSmall, setOpenSmall] = React.useState(false); + const [openLarge, setOpenLarge] = React.useState(false); + const [openWithPopups, setOpenWithPopups] = React.useState(false); + + const [textLines] = React.useState(() => range(50).map(() => faker.lorem.sentences())); return ( - setOpen(false)}> +
Modal content
+ + {textLines.map((text, index) => ( +
+ {text} +
+ ))} +
+ +
+ +
+
+ +
- +
); }; + +function ModalWithPopups(props: ModalProps) { + const [topPopupOpen, setTopPopupOpen] = React.useState(false); + const [bottomPopupOpen, setBottomPopupOpen] = React.useState(false); + + const handleTogglePopups = React.useCallback(() => { + setTopPopupOpen(!topPopupOpen); + setBottomPopupOpen(!bottomPopupOpen); + }, [topPopupOpen, bottomPopupOpen]); + + const btnRef = React.useRef(null); + + React.useEffect(() => { + if (!props.open) { + setTopPopupOpen(false); + setBottomPopupOpen(false); + } + }, [props.open]); + + return ( + +
+ + +
Top popup
+
+ +
Bottom popup
+
+
+
+ ); +} diff --git a/src/components/Modal/i18n/en.json b/src/components/Modal/i18n/en.json new file mode 100644 index 0000000000..047b02a79a --- /dev/null +++ b/src/components/Modal/i18n/en.json @@ -0,0 +1,4 @@ +{ + "close": "Close" +} + \ No newline at end of file diff --git a/src/components/Modal/i18n/index.ts b/src/components/Modal/i18n/index.ts new file mode 100644 index 0000000000..0aada620fc --- /dev/null +++ b/src/components/Modal/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'Modal'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/Modal/i18n/ru.json b/src/components/Modal/i18n/ru.json new file mode 100644 index 0000000000..2da2d1cad5 --- /dev/null +++ b/src/components/Modal/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "close": "Закрыть" +} + \ No newline at end of file diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index 9a24e012c0..6f62224399 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -33,8 +33,6 @@ import {Portal} from '../Portal'; import type {AriaLabelingProps, DOMProps, QAProps} from '../types'; import {block} from '../utils/cn'; import {filterDOMProps} from '../utils/filterDOMProps'; -import type {LayerCloseReason} from '../utils/layer-manager'; -import type {LayerExtendableProps} from '../utils/layer-manager/LayerManager'; import {PopupArrow} from './PopupArrow'; import {OVERFLOW_PADDING, TRANSITION_DURATION} from './constants'; @@ -45,6 +43,8 @@ import {arrowStylesMiddleware, getOffsetOptions, getPlacementOptions} from './ut import './Popup.scss'; +export type PopupCloseReason = 'outsideClick' | 'escapeKeyDown'; + export interface PopupProps extends DOMProps, AriaLabelingProps, QAProps { children?: React.ReactNode; /** Manages `Popup` visibility */ @@ -92,21 +92,21 @@ export interface PopupProps extends DOMProps, AriaLabelingProps, QAProps { * * @deprecated Use `onOpenChange` instead */ - onClose?: LayerExtendableProps['onClose']; + onClose?: (event: MouseEvent | KeyboardEvent, reason: PopupCloseReason) => void; /** * This callback will be called when Escape key pressed on keyboard * This behaviour could be disabled with `disableEscapeKeyDown` option * * @deprecated Use `onOpenChange` instead */ - onEscapeKeyDown?: LayerExtendableProps['onEscapeKeyDown']; + onEscapeKeyDown?: (event: KeyboardEvent) => void; /** * This callback will be called when click is outside of elements of "top layer" * This behaviour could be disabled with `disableOutsideClick` option * * @deprecated Use `onOpenChange` instead */ - onOutsideClick?: LayerExtendableProps['onOutsideClick']; + onOutsideClick?: (event: MouseEvent) => void; /** Do not dismiss on escape key press */ disableEscapeKeyDown?: boolean; /** Do not dismiss on outside click */ @@ -188,8 +188,7 @@ export function Popup({ return; } - const closeReason: LayerCloseReason = - reason === 'escape-key' ? 'escapeKeyDown' : 'outsideClick'; + const closeReason = reason === 'escape-key' ? 'escapeKeyDown' : 'outsideClick'; if (closeReason === 'escapeKeyDown' && onEscapeKeyDown) { onEscapeKeyDown(event as KeyboardEvent); @@ -302,6 +301,7 @@ export function Popup({ initialFocus={initialFocus ?? initialFocusRef} returnFocus={returnFocus} visuallyHiddenDismiss={disableFocusVisuallyHiddenDismiss ? false : i18n('close')} + guards={modalFocus || !disablePortal} >
-
+ -
+ ); }; diff --git a/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx b/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx index 7b0f1bde4a..651fc2f323 100644 --- a/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx +++ b/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx @@ -31,6 +31,16 @@ const EXTRA_INNER_CONTENT_MORE_THAN_VIEWPORT = getRandomText(3000); export default { title: 'Components/Overlays/Sheet', component: Sheet, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], } as Meta; export const Default: StoryFn = ({ diff --git a/src/components/Toaster/ToasterComponent/ToasterPortal.tsx b/src/components/Toaster/ToasterComponent/ToasterPortal.tsx index ab4355abd5..43ecc5f008 100644 --- a/src/components/Toaster/ToasterComponent/ToasterPortal.tsx +++ b/src/components/Toaster/ToasterComponent/ToasterPortal.tsx @@ -31,15 +31,13 @@ export function ToasterPortal({children, className, mobile}: Props) { }; }, []); - React.useEffect(() => { - if (!el.current) { - return; - } - - el.current.className = b({mobile}, className); - }, [className, mobile]); - - return {children}; + return ( + +
+ {children} +
+
+ ); } ToasterPortal.displayName = 'ToasterPortal'; diff --git a/src/components/utils/FocusTrap.tsx b/src/components/utils/FocusTrap.tsx deleted file mode 100644 index 5d60e36eca..0000000000 --- a/src/components/utils/FocusTrap.tsx +++ /dev/null @@ -1,142 +0,0 @@ -'use client'; - -import React from 'react'; - -import {createFocusTrap} from 'focus-trap'; -import type {FocusTrap as FocusTrapInstance} from 'focus-trap'; - -import {useForkRef, useUniqId} from '../../hooks'; - -import {getElementRef} from './getElementRef'; - -interface FocusTrapContext { - addNode: (id: string, node: HTMLElement) => void; - removeNode: (id: string) => void; -} - -const focusTrapContext = React.createContext(undefined); - -interface FocusTrapProps { - enabled?: boolean; - /** @deprecated Use autoFocus instead */ - disableAutoFocus?: boolean; - autoFocus?: boolean; - children: React.ReactElement; -} -export function FocusTrap({ - children, - enabled = true, - disableAutoFocus, - autoFocus = true, -}: FocusTrapProps) { - const nodeRef = React.useRef(null); - - const setAutoFocusRef = React.useRef(!disableAutoFocus && autoFocus); - React.useEffect(() => { - setAutoFocusRef.current = !disableAutoFocus && autoFocus; - }); - - const focusTrapRef = React.useRef(); - - const containersRef = React.useRef>({}); - const updateContainerElements = React.useCallback(() => { - focusTrapRef.current?.updateContainerElements([ - nodeRef.current!, - ...Object.values(containersRef.current), - ]); - }, []); - - const actions = React.useMemo( - () => ({ - addNode(id: string, node: HTMLElement) { - if (containersRef.current[id] !== node && !nodeRef.current?.contains(node)) { - containersRef.current[id] = node; - updateContainerElements(); - } - }, - removeNode(id: string) { - if (containersRef.current[id]) { - delete containersRef.current[id]; - updateContainerElements(); - } - }, - }), - [updateContainerElements], - ); - - const handleNodeRef = React.useCallback( - (node: HTMLElement | null) => { - if (enabled && node) { - nodeRef.current = node; - if (!focusTrapRef.current) { - focusTrapRef.current = createFocusTrap([], { - initialFocus: () => setAutoFocusRef.current && getFocusElement(node), - fallbackFocus: () => node, - returnFocusOnDeactivate: false, - escapeDeactivates: false, - clickOutsideDeactivates: false, - allowOutsideClick: true, - }); - } - updateContainerElements(); - focusTrapRef.current.activate(); - } else { - focusTrapRef.current?.deactivate(); - nodeRef.current = null; - } - }, - [enabled, updateContainerElements], - ); - - const child = React.Children.only(children); - if (!React.isValidElement(child)) { - throw new Error('Children must contain only one valid element'); - } - const childRef = getElementRef(child); - - const ref = useForkRef(handleNodeRef, childRef); - - return ( - - {React.cloneElement(child, {ref})} - - ); -} - -export function useParentFocusTrap() { - const actions = React.useContext(focusTrapContext); - const id = useUniqId(); - - return React.useMemo(() => { - if (!actions) { - return undefined; - } - - return (node: HTMLElement | null) => { - if (node) { - actions.addNode(id, node); - } else { - actions.removeNode(id); - } - }; - }, [actions, id]); -} - -function getFocusElement(root: HTMLElement) { - if ( - !(document.activeElement instanceof HTMLElement) || - !root.contains(document.activeElement) - ) { - if (!root.hasAttribute('tabIndex')) { - if (process.env.NODE_ENV !== 'production') { - // used only in dev build - // eslint-disable-next-line no-console - console.error('@gravity-ui/uikit: focus-trap content node does node accept focus.'); - } - root.setAttribute('tabIndex', '-1'); - } - return root; - } - - return document.activeElement; -} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c19b579ae2..e5e67492c2 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,6 +1,5 @@ export * from './useActionHandlers'; export * from './useAsyncActionHandler'; -export * from './useBodyScrollLock'; export * from './useControlledState'; export * from './useFileInput'; export * from './useFocusWithin'; diff --git a/src/hooks/private/index.ts b/src/hooks/private/index.ts index a9c6e7235f..524ace2662 100644 --- a/src/hooks/private/index.ts +++ b/src/hooks/private/index.ts @@ -8,6 +8,5 @@ export * from './useHover'; export * from './usePrevious'; export * from './useRadio'; export * from './useRadioGroup'; -export * from './useRestoreFocus'; export * from './useTooltipVisible'; export * from './useUpdateEffect'; diff --git a/src/hooks/private/useRestoreFocus/README.md b/src/hooks/private/useRestoreFocus/README.md deleted file mode 100644 index a640bcd6c2..0000000000 --- a/src/hooks/private/useRestoreFocus/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# useRestoreFocus - -The `useRestoreFocus` hook restore focus - -## Properties - -| Name | Description | Type | Default | -| :-------------- | :------------------------- | :---------------: | :-----: | -| enabled | Enabled flag | `boolean` | | -| restoreFocusRef | Ref-link for restore focus | `React.RefObject` | | -| focusTrapped | Focus trapped flag | `boolean` | | - -## Result - -| Name | Description | Type | -| :------ | :--------------- | :---------------------------------: | -| onFocus | OnFocus callback | `(event: React.FocusEvent) => void` | diff --git a/src/hooks/private/useRestoreFocus/index.ts b/src/hooks/private/useRestoreFocus/index.ts deleted file mode 100644 index a1c50d98e0..0000000000 --- a/src/hooks/private/useRestoreFocus/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {useRestoreFocus} from './useRestoreFocus'; -export type {UseRestoreFocusProps, UseRestoreFocusResult} from './useRestoreFocus'; diff --git a/src/hooks/private/useRestoreFocus/useRestoreFocus.tsx b/src/hooks/private/useRestoreFocus/useRestoreFocus.tsx deleted file mode 100644 index 6bdd05502e..0000000000 --- a/src/hooks/private/useRestoreFocus/useRestoreFocus.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react'; - -import {isFocusable, isTabbable} from 'tabbable'; - -export interface UseRestoreFocusProps { - enabled: boolean; - restoreFocusRef?: React.RefObject; - focusTrapped?: boolean; -} - -export interface UseRestoreFocusResult { - onFocus: (event: React.FocusEvent) => void; -} - -export function useRestoreFocus({ - enabled, - restoreFocusRef, - focusTrapped, -}: UseRestoreFocusProps): UseRestoreFocusResult { - const ref = React.useRef(null); - - const initialActiveElementRef = React.useRef(null); - const lastActiveElementRef = React.useRef(null); - - const handleFocus = (event: React.FocusEvent) => { - if (enabled && initialActiveElementRef.current === null) { - initialActiveElementRef.current = event.relatedTarget as HTMLElement | null; - lastActiveElementRef.current = initialActiveElementRef.current; - ref.current = (restoreFocusRef?.current || initialActiveElementRef.current) ?? null; - } - }; - - React.useEffect(() => { - if (!enabled) { - return undefined; - } - - const handleFocusIn = (event: FocusEvent) => { - const element = event.target; - if (!focusTrapped && element instanceof HTMLElement && isTabbable(element)) { - lastActiveElementRef.current = element; - } - }; - const handlePointerDown = (event: MouseEvent | TouchEvent) => { - const element = event.target; - if (element instanceof HTMLElement && isTabbable(element)) { - lastActiveElementRef.current = element; - } else { - lastActiveElementRef.current = null; - } - }; - - window.addEventListener('focusin', handleFocusIn); - window.addEventListener('mousedown', handlePointerDown); - window.addEventListener('touchstart', handlePointerDown); - return () => { - window.removeEventListener('focusin', handleFocusIn); - window.removeEventListener('mousedown', handlePointerDown); - window.removeEventListener('touchstart', handlePointerDown); - }; - }, [enabled, focusTrapped]); - - React.useEffect(() => { - if (enabled) { - ref.current = (restoreFocusRef?.current || initialActiveElementRef.current) ?? null; - } else { - ref.current = null; - } - }); - - React.useEffect(() => { - if (!enabled) { - return undefined; - } - - return () => { - let element = ref.current; - const lastActive = lastActiveElementRef.current; - if (lastActive && document.contains(lastActive) && isTabbable(lastActive)) { - element = lastActive; - } - if ( - element && - typeof element.focus === 'function' && - document.contains(element) && - isFocusable(element) - ) { - if (element !== document.activeElement) { - setTimeout(() => { - element?.focus(); - }, 0); - } - initialActiveElementRef.current = null; - lastActiveElementRef.current = null; - } - }; - }, [enabled]); - - return {onFocus: handleFocus}; -} diff --git a/src/hooks/useBodyScrollLock/README.md b/src/hooks/useBodyScrollLock/README.md deleted file mode 100644 index fbd0b0a033..0000000000 --- a/src/hooks/useBodyScrollLock/README.md +++ /dev/null @@ -1,17 +0,0 @@ - - -# useBodyScrollLock - - - -```tsx -import {useBodyScrollLock} from '@gravity-ui/uikit'; -``` - -The `useBodyScrollLock` hook helps to blocks scrolling on the body element. - -## Properties - -| Name | Description | Type | Default | -| :------ | :---------- | :-------: | :-----: | -| enabled | Enable flag | `boolean` | | diff --git a/src/hooks/useBodyScrollLock/index.ts b/src/hooks/useBodyScrollLock/index.ts deleted file mode 100644 index 59656edbbe..0000000000 --- a/src/hooks/useBodyScrollLock/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {useBodyScrollLock} from './useBodyScrollLock'; -export type {BodyScrollLockProps, UseBodyScrollLockProps} from './useBodyScrollLock'; diff --git a/src/hooks/useBodyScrollLock/useBodyScrollLock.ts b/src/hooks/useBodyScrollLock/useBodyScrollLock.ts deleted file mode 100644 index 44c171596b..0000000000 --- a/src/hooks/useBodyScrollLock/useBodyScrollLock.ts +++ /dev/null @@ -1,106 +0,0 @@ -import React from 'react'; - -const PROPERTY_PADDING_RIGHT = 'padding-right'; -const PROPERTY_PADDING_BOTTOM = 'padding-bottom'; -const PROPERTY_OVERFLOW = 'overflow'; - -const STORED_BODY_STYLE_KEYS = [ - PROPERTY_OVERFLOW, - PROPERTY_PADDING_RIGHT, - PROPERTY_PADDING_BOTTOM, -] as const; - -type StoredBodyStyleKeys = (typeof STORED_BODY_STYLE_KEYS)[number]; -type StoredBodyStyle = Partial>; - -function getStoredStyles(): StoredBodyStyle { - const styles: StoredBodyStyle = {}; - - for (const property of STORED_BODY_STYLE_KEYS) { - styles[property] = document.body.style.getPropertyValue(property); - } - - return styles; -} - -export interface UseBodyScrollLockProps { - enabled: boolean; -} - -export type BodyScrollLockProps = UseBodyScrollLockProps; - -let locks = 0; -let storedBodyStyle: StoredBodyStyle = {}; - -export function useBodyScrollLock({enabled}: UseBodyScrollLockProps) { - React.useLayoutEffect(() => { - if (enabled) { - locks++; - - if (locks === 1) { - setBodyStyles(); - } - - return () => { - locks--; - if (locks === 0) { - restoreBodyStyles(); - } - }; - } - - return undefined; - }, [enabled]); -} - -function setBodyStyles() { - const yScrollbarWidth = getYScrollbarWidth(); - const xScrollbarWidth = getXScrollbarWidth(); - const bodyPadding = getBodyComputedPadding(); - - storedBodyStyle = getStoredStyles(); - - document.body.style.setProperty(PROPERTY_OVERFLOW, 'hidden'); - - if (yScrollbarWidth) { - document.body.style.setProperty( - PROPERTY_PADDING_RIGHT, - `${bodyPadding.right + yScrollbarWidth}px`, - ); - } - if (xScrollbarWidth) { - document.body.style.setProperty( - PROPERTY_PADDING_BOTTOM, - `${bodyPadding.bottom + xScrollbarWidth}px`, - ); - } -} - -function restoreBodyStyles() { - for (const property of STORED_BODY_STYLE_KEYS) { - const storedProperty = storedBodyStyle[property]; - if (storedProperty) { - document.body.style.setProperty(property, storedProperty); - } else { - document.body.style.removeProperty(property); - } - } -} - -function getYScrollbarWidth() { - return window.innerWidth - document.documentElement.clientWidth; -} - -function getXScrollbarWidth() { - return window.innerHeight - document.documentElement.clientHeight; -} - -function getBodyComputedPadding() { - const computedStyle = window.getComputedStyle(document.body); - return { - top: Number.parseFloat(computedStyle.paddingTop), - right: Number.parseFloat(computedStyle.paddingRight), - bottom: Number.parseFloat(computedStyle.paddingBottom), - left: Number.parseFloat(computedStyle.paddingLeft), - }; -} From 1e14cb249dc7d0d510376d7432aadb5d802e3d67 Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Fri, 20 Dec 2024 18:41:04 +0300 Subject: [PATCH 8/8] refactor Tooltip and ActionTooltip --- .../ActionTooltip/ActionTooltip.scss | 16 +- .../ActionTooltip/ActionTooltip.tsx | 111 ++++------- src/components/ActionTooltip/README.md | 58 ++++-- .../__stories__/ActionTooltip.stories.tsx | 50 +++-- .../__tests__/ActionTooltip.test.tsx | 62 +----- .../FilePreview/FilePreviewAction.tsx | 5 +- src/components/Tooltip/README.md | 71 +++++-- src/components/Tooltip/Tooltip.scss | 46 +++-- src/components/Tooltip/Tooltip.tsx | 184 ++++++++++++------ .../Tooltip/__stories__/Tooltip.stories.tsx | 52 ++++- .../Tooltip/__tests__/Tooltip.test.tsx | 63 +----- src/components/lab/Popover/Popover.tsx | 16 +- .../Popover/__stories__/Popover.stories.tsx | 7 +- src/demo/colors/ColorPanel.tsx | 7 +- 14 files changed, 404 insertions(+), 344 deletions(-) diff --git a/src/components/ActionTooltip/ActionTooltip.scss b/src/components/ActionTooltip/ActionTooltip.scss index efa726b514..81b36b2432 100644 --- a/src/components/ActionTooltip/ActionTooltip.scss +++ b/src/components/ActionTooltip/ActionTooltip.scss @@ -4,15 +4,9 @@ $block: '.#{variables.$ns}action-tooltip'; #{$block} { - --g-popup-border-width: 0; - --g-popup-background-color: var(--g-color-base-float-heavy); - - &__content { - padding: 6px 12px; - color: var(--g-color-text-light-primary); - max-width: 300px; - box-sizing: border-box; - } + --g-tooltip-text-color: var(--g-color-text-light-primary); + --g-tooltip-background-color: var(--g-color-base-float-heavy); + --g-tooltip-padding: var(--g-spacing-2) var(--g-spacing-3); &__heading { display: flex; @@ -25,11 +19,11 @@ $block: '.#{variables.$ns}action-tooltip'; } &__hotkey { - margin-inline-start: 8px; + margin-inline-start: var(--g-spacing-2); } &__description { - margin-block-start: 4px; + margin-block-start: var(--g-spacing-1); color: var(--g-color-text-light-secondary); } } diff --git a/src/components/ActionTooltip/ActionTooltip.tsx b/src/components/ActionTooltip/ActionTooltip.tsx index 5d16e885be..fb5e567d8a 100644 --- a/src/components/ActionTooltip/ActionTooltip.tsx +++ b/src/components/ActionTooltip/ActionTooltip.tsx @@ -2,89 +2,62 @@ import React from 'react'; -import {useForkRef} from '../../hooks'; -import {useTooltipVisible} from '../../hooks/private'; -import type {TooltipDelayProps} from '../../hooks/private'; import {Hotkey} from '../Hotkey'; import type {HotkeyProps} from '../Hotkey'; -import {Popup} from '../Popup'; -import type {PopupPlacement} from '../Popup'; +import {Tooltip} from '../Tooltip'; +import type {TooltipProps} from '../Tooltip'; import type {DOMProps, QAProps} from '../types'; import {block} from '../utils/cn'; -import {getElementRef} from '../utils/getElementRef'; import './ActionTooltip.scss'; -export interface ActionTooltipProps extends QAProps, DOMProps, TooltipDelayProps { - id?: string; - disablePortal?: boolean; - contentClassName?: string; - disabled?: boolean; - placement?: PopupPlacement; - children: React.ReactElement; +export interface ActionTooltipProps + extends QAProps, + DOMProps, + Omit { + /** Floating element title */ title: string; - hotkey?: HotkeyProps['value']; + /** Floating element description */ description?: React.ReactNode; + /** Floating element hotkey label */ + hotkey?: HotkeyProps['value']; } -const DEFAULT_PLACEMENT: PopupPlacement = ['bottom', 'top']; const b = block('action-tooltip'); - -export function ActionTooltip(props: ActionTooltipProps) { - const { - placement = DEFAULT_PLACEMENT, - title, - hotkey, - children, - className, - contentClassName, - description, - disabled = false, - style, - qa, - id, - disablePortal, - ...delayProps - } = props; - - const [anchorElement, setAnchorElement] = React.useState(null); - const tooltipVisible = useTooltipVisible(anchorElement, delayProps); - - const renderPopup = () => { - return ( - -
-
-
{title}
- {hotkey && } -
- {description &&
{description}
} +const DEFAULT_OPEN_DELAY = 500; +const DEFAULT_CLOSE_DELAY = 0; + +export function ActionTooltip({ + title, + description, + hotkey, + openDelay = DEFAULT_OPEN_DELAY, + closeDelay = DEFAULT_CLOSE_DELAY, + className, + ...restProps +}: ActionTooltipProps) { + const content = React.useMemo( + () => ( + +
+
{title}
+ {hotkey && }
- - ); - }; - - const child = React.Children.only(children); - const childRef = getElementRef(child); - - const ref = useForkRef(setAnchorElement, childRef); + {description &&
{description}
} +
+ ), + [title, description, hotkey], + ); return ( - - {React.cloneElement(child, {ref})} - {anchorElement ? renderPopup() : null} - + ); } diff --git a/src/components/ActionTooltip/README.md b/src/components/ActionTooltip/README.md index 5b40131568..6c5443dc2c 100644 --- a/src/components/ActionTooltip/README.md +++ b/src/components/ActionTooltip/README.md @@ -4,32 +4,56 @@ -A simple text tip that uses its children node as an anchor. For correct functioning, the anchor node -must be able to handle mouse events and focus or blur events. +[`Tooltip`](../Tooltip/README.md) for labeling action buttons without descriptive text (e.g. icon buttons). ## Usage ```tsx import {ActionTooltip} from '@gravity-ui/uikit'; - +
Anchor
; ``` +## Anchor + +In order for `ActionTooltip` to work you should pass a valid `ReactElement` as a children which accepts `ref` property for `HTMLElement` +and other properties for `HTMLElement`. + +Alternatively, you can pass function as a children to provide ref and props manually to your underlying components: + +```tsx +import {ActionTooltip} from '@gravity-ui/uikit'; + + + {(props, ref) => } +; +``` + +## Controlled State + +By default `ActionTooltip` opens and hides by hovering the anchor. You can change this behaviour to manually set the open state. +Pass your state to the `open` prop and change it from `onOpenChange` callback. +`onOpenChange` callback has the following signature: `(open: boolean, event?: Event, reason: 'hover' | 'focus') => void`. + ## Properties -| Name | Description | Type | Default | -| :--------------- | --------------------------------------------------------------------------------------- | :----------------------------------------------: | :-----: | -| children | An anchor element for a `Tooltip`. Must accept a `ref` that will provide a DOM element. | `React.ReactElement` | | -| closeDelay | Number of ms to delay hiding the `Tooltip` after the hover ends | `number` | `0` | -| openDelay | Number of ms to delay showing the `Tooltip` after the hover begins | `number` | `250` | -| placement | `Tooltip` position relative to its anchor | [`PopupPlacement`](../Popup/README.md#placement) | | -| qa | HTML `data-qa` attribute, used in tests | `string` | | -| title | Tooltip title text | `string` | | -| description | Tooltip description text | `string` | | -| hotkey | Hot keys that are assigned to an interface action. | `string` | | -| id | This prop is used to help implement the accessibility logic. | `string` | | -| disablePortal | Do not use Portal for children | `boolean` | | -| contentClassName | HTML class attribute for content node | `string` | | -| disabled | Prevent popup from opening | `boolean` | `false` | +| Name | Description | Type | Default | +| :----------- | --------------------------------------------------------------------------- | :----------------------------------------------: | :--------: | +| children | An anchor element for the `ActionTooltip` | `React.ReactElement` `Function` | | +| className | HTML class attribute | `string` | | +| closeDelay | Number of ms to delay hiding the `ActionTooltip` after the hover ends | `number` | `0` | +| description | Description content | `React.ReactNode` | | +| disabled | Prevent the `ActionTooltip` from opening | `boolean` | | +| hotkey | Hotkey value to be shown in the top-end corner | [`Hotkey` value](../Hotkey/README.md#value) | | +| offset | `ActionTooltip` offset from its anchor | `number` | `4` | +| onOpenChange | Callback to handle open state change | `Function` | | +| open | Controlled open state | `boolean` | | +| openDelay | Number of ms to delay showing the `ActionTooltip` after the hover begins | `number` | `1000` | +| placement | `ActionTooltip` position relative to its anchor | [`PopupPlacement`](../Popup/README.md#placement) | `bottom` | +| qa | HTML `data-qa` attribute, used in tests | `string` | | +| strategy | The type of CSS position property to use. | `absolute` `fixed` | `absolute` | +| style | HTML style attribute | `React.CSSProperties` | | +| title | Title content | `string` | | +| trigger | Event type that should trigger opening. By default both hover and focus do. | `"focus"` | | diff --git a/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx b/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx index edd23e7e25..f9c4adcc78 100644 --- a/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx +++ b/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx @@ -1,24 +1,52 @@ import React from 'react'; -import type {StoryFn} from '@storybook/react'; +import {FloppyDisk} from '@gravity-ui/icons'; +import type {Meta, StoryObj} from '@storybook/react'; import {Button} from '../../Button'; +import {Icon} from '../../Icon'; import {ActionTooltip} from '../ActionTooltip'; -import type {ActionTooltipProps} from '../ActionTooltip'; -export default { +const meta: Meta = { title: 'Components/Overlays/ActionTooltip', component: ActionTooltip, + parameters: { + layout: 'centered', + }, }; -const DefaultTemplate: StoryFn = (args) => ; +export default meta; -export const Default = DefaultTemplate.bind({}); +type Story = StoryObj; -Default.args = { - title: 'Tooltip text', - hotkey: 'mod+s', - description: - 'Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups.', - children: , +export const Default: Story = { + render: (args) => { + return ( + + + + ); + }, + args: { + title: 'Save', + }, +}; + +export const Hotkey: Story = { + ...Default, + args: { + ...Default.args, + hotkey: 'mod+s', + }, +}; + +export const Description: Story = { + ...Default, + args: { + ...Default.args, + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua', + }, }; diff --git a/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx b/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx index f95a463449..3ab81899bb 100644 --- a/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx +++ b/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx @@ -2,22 +2,13 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import {createEvent, fireEvent, render, screen, waitFor} from '../../../../test-utils/utils'; +import {render, screen, waitFor} from '../../../../test-utils/utils'; import {ActionTooltip} from '../ActionTooltip'; -export function fireAnimationEndEvent(el: Node | Window, animationName = 'animation') { - const ev = createEvent.animationEnd(el, {animationName}); - Object.assign(ev, { - animationName, - }); - - fireEvent(el, ev); -} - test('should preserve ref on anchor element', () => { const ref = jest.fn(); render( - + +
); }, args: { - id: 'tooltip-id', - content: 'Hello world!', + content: 'Content', + onOpenChange: action('onOpenChange'), + }, +}; + +export const Delay: Story = { + render: (args) => ( + + + + + + + + + ), + args: { + ...Default.args, + }, +}; + +export const OnlyFocus: Story = { + ...Default, + args: { + ...Default.args, + trigger: 'focus', + }, +}; + +export const Disabled: Story = { + ...Default, + args: { + ...Default.args, + disabled: true, + }, +}; + +export const LongText: Story = { + ...Default, + args: { + ...Default.args, + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', }, }; diff --git a/src/components/Tooltip/__tests__/Tooltip.test.tsx b/src/components/Tooltip/__tests__/Tooltip.test.tsx index bae4bf6557..0925135499 100644 --- a/src/components/Tooltip/__tests__/Tooltip.test.tsx +++ b/src/components/Tooltip/__tests__/Tooltip.test.tsx @@ -2,22 +2,13 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import {createEvent, fireEvent, render, screen, waitFor} from '../../../../test-utils/utils'; +import {render, screen, waitFor} from '../../../../test-utils/utils'; import {Tooltip} from '../Tooltip'; -export function fireAnimationEndEvent(el: Node | Window, animationName = 'animation') { - const ev = createEvent.animationEnd(el, {animationName}); - Object.assign(ev, { - animationName, - }); - - fireEvent(el, ev); -} - test('should preserve ref on anchor element', () => { const ref = jest.fn(); render( - + - + @@ -68,7 +68,8 @@ export const SafePolygon: Story = { ...Default, args: { ...Default.args, - delay: 0, + openDelay: 0, + closeDelay: 0, offset: 50, enableSafePolygon: true, }, diff --git a/src/demo/colors/ColorPanel.tsx b/src/demo/colors/ColorPanel.tsx index b6a70c430e..b1d471e5f3 100644 --- a/src/demo/colors/ColorPanel.tsx +++ b/src/demo/colors/ColorPanel.tsx @@ -4,7 +4,6 @@ import {Bulb} from '@gravity-ui/icons'; import ReactCopyToClipboard from 'react-copy-to-clipboard'; import {ActionTooltip, Button, Icon} from '../../components'; -import {useUniqId} from '../../hooks'; import './ColorPanel.scss'; @@ -26,7 +25,6 @@ const switchBackgroundTitle = 'Switch background'; export function ColorPanel(props: ColorPanelProps) { const [currentBackgroundIndex, setCurrentBackgroundIndex] = React.useState(0); - const tooltipId = useUniqId(); function rotateBackground() { setCurrentBackgroundIndex((index) => (index + 1) % BACKGROUND_LIST.length); @@ -61,7 +59,7 @@ export function ColorPanel(props: ColorPanelProps) { return (
- +