diff --git a/package-lock.json b/package-lock.json index c384c6844d..93a7ef79e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,10 @@ "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", "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", @@ -3176,17 +3175,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": { @@ -12292,14 +12292,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 62b8cb8617..140ce23dec 100644 --- a/package.json +++ b/package.json @@ -135,11 +135,10 @@ }, "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", "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), - }; -}