diff --git a/packages/plasma-new-hope/src/components/BottomSheet/BottomSheet.tsx b/packages/plasma-new-hope/src/components/BottomSheet/BottomSheet.tsx index 2f2063eb23f..176e64c5959 100644 --- a/packages/plasma-new-hope/src/components/BottomSheet/BottomSheet.tsx +++ b/packages/plasma-new-hope/src/components/BottomSheet/BottomSheet.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, useMemo } from 'react'; import { RootProps } from '../../engines'; +import { useEscKeyDown } from '../../hooks'; import { Overlay } from '../Overlay'; import { IconClose } from '../_Icon'; import { cx } from '../../utils'; @@ -39,9 +40,12 @@ export const bottomSheetRoot = (Root: RootProps) => { + if (onOverlayClick) { + onOverlayClick(event); + return; + } + + onClose(); + }; + return ( {withOverlay && opened && ( @@ -75,7 +90,7 @@ export const bottomSheetRoot = (Root: RootProps )} diff --git a/packages/plasma-new-hope/src/components/BottomSheet/BottomSheet.types.ts b/packages/plasma-new-hope/src/components/BottomSheet/BottomSheet.types.ts index 26158635e37..0cca4c85a3a 100644 --- a/packages/plasma-new-hope/src/components/BottomSheet/BottomSheet.types.ts +++ b/packages/plasma-new-hope/src/components/BottomSheet/BottomSheet.types.ts @@ -64,10 +64,28 @@ export interface BottomSheetProps extends HTMLAttributes { */ zIndex?: CSSProperties['zIndex']; /** - * Обработчик закрытия шторки. Вызывается при клике по оверлею, по кнопке закрытия или свайпом вниз. + * Обработчик закрытия шторки. Вызывается по кнопке закрытия или свайпом вниз, + * при клике по оверлею (если не передан onOverlayClick) + * и при нажатии ESC (если не передан onEscKeyDown). */ onClose?: () => void; + /** + * Обработчик клика по оверлею (если не передан, используется onClose). + */ + onOverlayClick?: (event: React.MouseEvent) => void; + + /** + * Закрывать шторку при нажатии на ESC. + * @default true + */ + closeOnEsc?: boolean; + + /** + * Обработчик нажатия ESC (если не передан, используется onClose). + */ + onEscKeyDown?: (event: KeyboardEvent) => void; + /** * Вид компонента. */ diff --git a/packages/plasma-new-hope/src/components/Sheet/Sheet.tsx b/packages/plasma-new-hope/src/components/Sheet/Sheet.tsx index b1bce637d7c..63b2cd26a24 100644 --- a/packages/plasma-new-hope/src/components/Sheet/Sheet.tsx +++ b/packages/plasma-new-hope/src/components/Sheet/Sheet.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, useRef } from 'react'; import { RootProps } from '../../engines'; +import { useEscKeyDown } from '../../hooks'; import { Overlay } from '../Overlay'; import { cx } from '../../utils'; @@ -30,6 +31,9 @@ export const sheetRoot = (Root: RootProps) => opened, children, onClose, + onOverlayClick, + onEscKeyDown, + closeOnEsc = true, hasHandle = true, handlePlacement, contentHeader, @@ -53,6 +57,7 @@ export const sheetRoot = (Root: RootProps) => useOverflow({ opened }); useSheetSwipe({ contentWrapperRef, contentRef, handleRef, throttleMs, hasScrollEvents, onClose }); + useEscKeyDown({ opened, closeOnEsc, onEscKeyDown, onClose }); const hasHeader = Boolean(contentHeader); const hasFooter = Boolean(contentFooter); @@ -63,6 +68,15 @@ export const sheetRoot = (Root: RootProps) => ? `var(${tokens.sheetOverlayWithBlurColor})` : `var(${tokens.sheetOverlayColor})`; + const handleOverlayClick = (event: React.MouseEvent) => { + if (onOverlayClick) { + onOverlayClick(event); + return; + } + + onClose(); + }; + return ( ) => backgroundColorProperty={overlayBackgroundToken} withBlur={withBlur} isClickable - onOverlayClick={onClose} + onOverlayClick={handleOverlayClick} /> )} diff --git a/packages/plasma-new-hope/src/components/Sheet/Sheet.types.ts b/packages/plasma-new-hope/src/components/Sheet/Sheet.types.ts index 6753b668c64..a6d04d4ad9d 100644 --- a/packages/plasma-new-hope/src/components/Sheet/Sheet.types.ts +++ b/packages/plasma-new-hope/src/components/Sheet/Sheet.types.ts @@ -7,10 +7,28 @@ export interface SheetProps extends HTMLAttributes { opened?: boolean; /** - * Обработчик закрытия шторки. Вызывается при клике по оверлею или смахиванию шторки вниз + * Обработчик закрытия шторки. Вызывается при смахивании шторки вниз, + * при клике по оверлею (если не передан onOverlayClick) + * и при нажатии ESC (если не передан onEscKeyDown). */ onClose: () => void; + /** + * Обработчик клика по оверлею (если не передан, используется onClose). + */ + onOverlayClick?: (event: React.MouseEvent) => void; + + /** + * Закрывать шторку при нажатии на ESC. + * @default true + */ + closeOnEsc?: boolean; + + /** + * Обработчик нажатия ESC (если не передан, используется onClose). + */ + onEscKeyDown?: (event: KeyboardEvent) => void; + /** * Слот для контента в заголовке */ diff --git a/packages/plasma-new-hope/src/hooks/index.ts b/packages/plasma-new-hope/src/hooks/index.ts index ec759750690..7147613ba49 100644 --- a/packages/plasma-new-hope/src/hooks/index.ts +++ b/packages/plasma-new-hope/src/hooks/index.ts @@ -10,3 +10,4 @@ export { useCodeHook } from './useCodeHook'; export { useDisableScroll } from './useDisableScroll'; export { useForkRef } from './useForkRef'; export { useResizeObserver } from './useResizeObserver'; +export { useEscKeyDown } from './useEscKeyDown'; diff --git a/packages/plasma-new-hope/src/hooks/useEscKeyDown.ts b/packages/plasma-new-hope/src/hooks/useEscKeyDown.ts new file mode 100644 index 00000000000..0fd2a273073 --- /dev/null +++ b/packages/plasma-new-hope/src/hooks/useEscKeyDown.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef } from 'react'; + +const ESCAPE_KEYCODE = 27; +const stack: object[] = []; + +export type UseEscKeyDownArgs = { + onClose: () => void; + opened?: boolean; + closeOnEsc?: boolean; + onEscKeyDown?: (event: KeyboardEvent) => void; +}; + +/** + * Закрывает верхний открытый слой по ESC. + * При нескольких открытых Sheet/BottomSheet реагирует только последний. + * Если у верхнего слоя closeOnEsc=false, ESC ничего не закрывает. + */ +export const useEscKeyDown = ({ opened, closeOnEsc = true, onEscKeyDown, onClose }: UseEscKeyDownArgs) => { + const callbackRef = useRef({ closeOnEsc, onEscKeyDown, onClose }); + callbackRef.current = { closeOnEsc, onEscKeyDown, onClose }; + + useEffect(() => { + if (!opened) { + return undefined; + } + + const id = {}; + stack.push(id); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.keyCode !== ESCAPE_KEYCODE || stack[stack.length - 1] !== id || !callbackRef.current.closeOnEsc) { + return; + } + + const { onEscKeyDown: onEsc, onClose: close } = callbackRef.current; + + if (onEsc) { + onEsc(event); + return; + } + + close(); + }; + + window.addEventListener('keydown', onKeyDown); + + return () => { + stack.splice(stack.indexOf(id), 1); + window.removeEventListener('keydown', onKeyDown); + }; + }, [opened]); +}; diff --git a/utils/api-tests/script.mjs b/utils/api-tests/script.mjs index 9d0733795ab..2e7e7e0b050 100644 --- a/utils/api-tests/script.mjs +++ b/utils/api-tests/script.mjs @@ -13,50 +13,50 @@ const config = { libs: [ { name: '@salutejs/plasma-b2c', - excludeComponents: ['TextField', 'CodeArea'], + excludeComponents: ['TextField', 'CodeArea', 'BottomSheet'], }, { name: '@salutejs/plasma-web', - excludeComponents: ['TextField', 'CodeArea'], + excludeComponents: ['TextField', 'CodeArea', 'BottomSheet'], }, { name: '@salutejs/plasma-giga', }, { name: '@salutejs/sdds-bizcom', - excludeComponents: ['CodeArea'], + excludeComponents: ['CodeArea', 'BottomSheet'], }, { name: '@salutejs/sdds-cs', - excludeComponents: ['Select', 'InformationWrapper', 'CodeArea'], + excludeComponents: ['Select', 'InformationWrapper', 'CodeArea', 'BottomSheet'], }, { name: '@salutejs/sdds-dfa', - excludeComponents: ['InformationWrapper', 'CodeArea'], + excludeComponents: ['InformationWrapper', 'CodeArea', 'BottomSheet'], }, { name: '@salutejs/sdds-finai', - excludeComponents: ['CodeArea'], + excludeComponents: ['CodeArea', 'BottomSheet'], }, { name: '@salutejs/sdds-insol', - excludeComponents: ['CodeArea'], + excludeComponents: ['CodeArea', 'BottomSheet'], }, { name: '@salutejs/sdds-netology', - excludeComponents: ['CodeArea'], + excludeComponents: ['CodeArea', 'BottomSheet'], }, { name: '@salutejs/sdds-platform-ai', - excludeComponents: ['CodeArea'], + excludeComponents: ['CodeArea', 'BottomSheet'], }, { name: '@salutejs/sdds-scan', - excludeComponents: ['CodeArea'], + excludeComponents: ['CodeArea', 'Sheet', 'BottomSheet'], }, { name: '@salutejs/sdds-serv', - excludeComponents: ['CodeArea'], + excludeComponents: ['CodeArea', 'BottomSheet'], }, ], includeComponents: [], diff --git a/utils/api-tests/src/components/BottomSheet/BottomSheet.api.test.tsx b/utils/api-tests/src/components/BottomSheet/BottomSheet.api.test.tsx new file mode 100644 index 00000000000..2d00b09b2ae --- /dev/null +++ b/utils/api-tests/src/components/BottomSheet/BottomSheet.api.test.tsx @@ -0,0 +1,166 @@ +import * as React from 'react'; +import type { ComponentProps, ReactNode, CSSProperties, AriaRole } from 'react'; +import { useState } from 'react'; +import { describe, it } from 'node:test'; +import { expectTypeOf } from 'expect-type'; +import { BottomSheet } from '@salutejs/plasma-giga'; + +type BottomSheetProps = ComponentProps; + +describe('Basics', () => { + it('Common', () => { + // layout + expectTypeOf() + .toHaveProperty('handlePlacement') + .toEqualTypeOf<'inner' | 'outer' | undefined>(); + expectTypeOf().toHaveProperty('hasClose').toEqualTypeOf(); + expectTypeOf().toHaveProperty('hasHandle').toEqualTypeOf(); + expectTypeOf().toHaveProperty('withOverlay').toEqualTypeOf(); + expectTypeOf().toHaveProperty('withBlur').toEqualTypeOf(); + expectTypeOf().toHaveProperty('withTransition').toEqualTypeOf(); + expectTypeOf() + .toHaveProperty('maxHeight') + .toEqualTypeOf(); + expectTypeOf().toHaveProperty('zIndex').toEqualTypeOf(); + + // state + expectTypeOf().toHaveProperty('opened').toEqualTypeOf(); + expectTypeOf().toHaveProperty('closeOnEsc').toEqualTypeOf(); + + // content slots + expectTypeOf().toHaveProperty('children').toEqualTypeOf(); + expectTypeOf().toHaveProperty('contentBeforeHeader').toEqualTypeOf(); + expectTypeOf().toHaveProperty('contentHeader').toEqualTypeOf(); + expectTypeOf().toHaveProperty('contentFooter').toEqualTypeOf(); + + // callbacks + expectTypeOf().toHaveProperty('onClose').toEqualTypeOf<(() => void) | undefined>(); + expectTypeOf() + .toHaveProperty('onOverlayClick') + .toEqualTypeOf<((event: React.MouseEvent) => void) | undefined>(); + expectTypeOf() + .toHaveProperty('onEscKeyDown') + .toEqualTypeOf<((event: KeyboardEvent) => void) | undefined>(); + }); + + it('Variations', () => { + type View = NonNullable; + expectTypeOf().toExtend(); + expectTypeOf().not.toExtend(); + + type Size = NonNullable; + expectTypeOf().toExtend(); + expectTypeOf().not.toExtend(); + }); + + it('HTMLDivElement', () => { + expectTypeOf().toHaveProperty('id').toEqualTypeOf(); + expectTypeOf().toHaveProperty('className').toEqualTypeOf(); + expectTypeOf().toHaveProperty('style').toEqualTypeOf(); + expectTypeOf().toHaveProperty('aria-label').toEqualTypeOf(); + expectTypeOf().toHaveProperty('role').toEqualTypeOf(); + expectTypeOf() + .toHaveProperty('onClick') + .toEqualTypeOf | undefined>(); + expectTypeOf() + .toHaveProperty('onMouseEnter') + .toEqualTypeOf | undefined>(); + expectTypeOf() + .toHaveProperty('onMouseLeave') + .toEqualTypeOf | undefined>(); + }); +}); + +describe('Complex', () => { + it('Examples', () => { + expectTypeOf({}); + expectTypeOf({ opened: true, onClose: () => {} }); + expectTypeOf({ + opened: true, + onClose: () => {}, + onOverlayClick: () => {}, + withOverlay: true, + withBlur: true, + }); + expectTypeOf({ + opened: true, + contentBeforeHeader: 'Before', + contentHeader: 'Header', + contentFooter: 'Footer', + handlePlacement: 'outer', + hasClose: true, + hasHandle: true, + maxHeight: '80dvh', + zIndex: 2000, + }); + }); +}); + +describe('Examples', () => { + it('Controlled', () => { + () => { + const [opened, setOpened] = useState(false); + + return ( + setOpened(false)}> + Content + + ); + }; + }); + + it('With overlay click', () => { + () => { + const [opened, setOpened] = useState(false); + + return ( + setOpened(false)} + onOverlayClick={() => setOpened(false)} + withOverlay + withBlur + > + Content + + ); + }; + }); + + it('With esc', () => { + () => { + const [opened, setOpened] = useState(false); + + return ( + setOpened(false)} + onEscKeyDown={() => setOpened(false)} + closeOnEsc + > + Content + + ); + }; + }); + + it('With slots', () => { + () => { + return ( + {}} + contentBeforeHeader={
Before
} + contentHeader={
Header
} + contentFooter={
Footer
} + handlePlacement="outer" + hasClose + hasHandle + maxHeight="80dvh" + > + Content +
+ ); + }; + }); +}); diff --git a/utils/api-tests/src/components/Sheet/Sheet.api.test.tsx b/utils/api-tests/src/components/Sheet/Sheet.api.test.tsx new file mode 100644 index 00000000000..51dc16b39ab --- /dev/null +++ b/utils/api-tests/src/components/Sheet/Sheet.api.test.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import type { ComponentProps, ReactNode, CSSProperties, AriaRole } from 'react'; +import { useState } from 'react'; +import { describe, it } from 'node:test'; +import { expectTypeOf } from 'expect-type'; +import { Sheet } from '@salutejs/plasma-b2c'; + +type SheetProps = ComponentProps; + +describe('Basics', () => { + it('Common', () => { + // layout + expectTypeOf().toHaveProperty('hasHandle').toEqualTypeOf(); + expectTypeOf().toHaveProperty('withOverlay').toEqualTypeOf(); + expectTypeOf().toHaveProperty('withBlur').toEqualTypeOf(); + expectTypeOf().toHaveProperty('withTransition').toEqualTypeOf(); + expectTypeOf().toHaveProperty('isHeaderFixed').toEqualTypeOf(); + expectTypeOf().toHaveProperty('isFooterFixed').toEqualTypeOf(); + expectTypeOf().toHaveProperty('hasScrollEvents').toEqualTypeOf(); + expectTypeOf().toHaveProperty('throttleMs').toEqualTypeOf(); + + // state + expectTypeOf().toHaveProperty('opened').toEqualTypeOf(); + expectTypeOf().toHaveProperty('closeOnEsc').toEqualTypeOf(); + + // content slots + expectTypeOf().toHaveProperty('children').toEqualTypeOf(); + expectTypeOf().toHaveProperty('contentHeader').toEqualTypeOf(); + expectTypeOf().toHaveProperty('contentFooter').toEqualTypeOf(); + + // callbacks + expectTypeOf().toHaveProperty('onClose').toEqualTypeOf<() => void>(); + expectTypeOf() + .toHaveProperty('onOverlayClick') + .toEqualTypeOf<((event: React.MouseEvent) => void) | undefined>(); + expectTypeOf() + .toHaveProperty('onEscKeyDown') + .toEqualTypeOf<((event: KeyboardEvent) => void) | undefined>(); + }); + + it('Variations', () => { + type View = NonNullable; + expectTypeOf().toExtend(); + expectTypeOf().not.toExtend(); + + type HandlePlacement = NonNullable; + expectTypeOf().toExtend(); + expectTypeOf().not.toExtend(); + }); + + it('HTMLDivElement', () => { + expectTypeOf().toHaveProperty('id').toEqualTypeOf(); + expectTypeOf().toHaveProperty('className').toEqualTypeOf(); + expectTypeOf().toHaveProperty('style').toEqualTypeOf(); + expectTypeOf().toHaveProperty('aria-label').toEqualTypeOf(); + expectTypeOf().toHaveProperty('role').toEqualTypeOf(); + expectTypeOf() + .toHaveProperty('onClick') + .toEqualTypeOf | undefined>(); + expectTypeOf() + .toHaveProperty('onMouseEnter') + .toEqualTypeOf | undefined>(); + expectTypeOf() + .toHaveProperty('onMouseLeave') + .toEqualTypeOf | undefined>(); + }); +}); + +describe('Complex', () => { + it('Examples', () => { + expectTypeOf({ onClose: () => {} }); + expectTypeOf({ opened: true, onClose: () => {} }); + expectTypeOf({ + opened: true, + onClose: () => {}, + onOverlayClick: () => {}, + withOverlay: true, + withBlur: true, + }); + expectTypeOf({ + opened: true, + onClose: () => {}, + contentHeader: 'Header', + contentFooter: 'Footer', + isHeaderFixed: true, + isFooterFixed: true, + }); + }); +}); + +describe('Examples', () => { + it('Controlled', () => { + () => { + const [opened, setOpened] = useState(false); + + return ( + setOpened(false)}> + Content + + ); + }; + }); + + it('With overlay click', () => { + () => { + const [opened, setOpened] = useState(false); + + return ( + setOpened(false)} + onOverlayClick={() => setOpened(false)} + withOverlay + withBlur + > + Content + + ); + }; + }); + + it('With esc', () => { + () => { + const [opened, setOpened] = useState(false); + + return ( + setOpened(false)} + onEscKeyDown={() => setOpened(false)} + closeOnEsc + > + Content + + ); + }; + }); + + it('With header and footer', () => { + () => { + return ( + {}} + contentHeader={
Header
} + contentFooter={
Footer
} + isHeaderFixed + isFooterFixed + hasHandle + > + Content +
+ ); + }; + }); +});