Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(plasma-b2c, plasma-web): Modal refinement #629

Merged
merged 2 commits into from
Aug 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 7 additions & 4 deletions packages/plasma-b2c/src/components/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const StyledButton = styled(Button)`
margin-right: 1rem;
`;

export const LiveDemo: Story<{ withBlur: boolean }> = ({ withBlur }) => {
export const LiveDemo: Story<{ withBlur: boolean }> = ({ withBlur, ...rest }) => {
const [isOpenA, setIsOpenA] = React.useState(false);
const [isOpenB, setIsOpenB] = React.useState(false);
const [isOpenC, setIsOpenC] = React.useState(false);
Expand All @@ -46,18 +46,18 @@ export const LiveDemo: Story<{ withBlur: boolean }> = ({ withBlur }) => {
<ModalsProvider>
<Button text="Open modal" onClick={() => setIsOpenA(true)} />

<Modal id="modalA" isOpen={isOpenA} onClose={onCloseA} withBlur={withBlur}>
<Modal id="modalA" isOpen={isOpenA} onClose={onCloseA} withBlur={withBlur} {...rest}>
<StyledHeadline3>Modal A</StyledHeadline3>
<StyledButton view="primary" text="Open modal B" onClick={() => setIsOpenB(true)} />
<Button text="Close" onClick={onCloseA} />
</Modal>

<Modal id="modalB" isOpen={isOpenB} onClose={onCloseB}>
<Modal id="modalB" isOpen={isOpenB} onClose={onCloseB} {...rest}>
<StyledHeadline3>Modal B</StyledHeadline3>
<StyledButton view="primary" text="Open modal C" onClick={() => setIsOpenC(true)} />
<Button text="Close" onClick={onCloseB} />

<Modal id="modalC" isOpen={isOpenC} onClose={onCloseC}>
<Modal id="modalC" isOpen={isOpenC} onClose={onCloseC} {...rest}>
<StyledHeadline3>Modal C</StyledHeadline3>
<Button text="Close" onClick={onCloseC} />
</Modal>
Expand All @@ -69,4 +69,7 @@ export const LiveDemo: Story<{ withBlur: boolean }> = ({ withBlur }) => {

LiveDemo.args = {
withBlur: false,
closeOnEsc: true,
closeOnOverlayClick: true,
showCloseButton: true,
};
10 changes: 9 additions & 1 deletion packages/plasma-hope/api/plasma-hope.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ import { ReactNode } from 'react';
import { RectSkeleton } from '@salutejs/plasma-core';
import { RectSkeletonProps } from '@salutejs/plasma-core';
import { RefAttributes } from 'react';
import { RefObject } from 'react';
import { Roundness } from '@salutejs/plasma-core';
import { RoundnessProps } from '@salutejs/plasma-core';
import { ScrollSnapProps } from '@salutejs/plasma-core';
Expand Down Expand Up @@ -906,7 +907,13 @@ export const Modal: FC<ModalProps>;

// @public (undocumented)
export interface ModalProps extends ModalViewProps {
closeOnEsc?: boolean;
closeOnOverlayClick?: boolean;
focusAfterRef?: React_2.RefObject<HTMLElement>;
initialFocusRef?: React_2.RefObject<HTMLElement>;
isOpen: boolean;
onEscKeyDown?: (event: KeyboardEvent) => void;
onOverlayClick?: (event: React_2.MouseEvent<HTMLDivElement>) => void;
withBlur?: boolean;
}

Expand All @@ -923,6 +930,7 @@ export interface ModalViewProps extends React_2.HTMLAttributes<HTMLDivElement> {
children?: React_2.ReactNode;
closeButtonAriaLabel?: string;
onClose?: () => void;
showCloseButton?: boolean;
}

export { monthLongName }
Expand Down Expand Up @@ -1336,7 +1344,7 @@ export interface UploadVisualProps extends UploadProps, PreviewGalleryProps {
export { useDebouncedFunction }

// @public
export const useFocusTrap: (active?: boolean, firstFocusSelector?: string | HTMLElement | undefined) => (instance: HTMLElement | null) => void;
export const useFocusTrap: (active?: boolean, firstFocusSelector?: string | RefObject<HTMLElement> | undefined, focusAfterNode?: RefObject<HTMLElement> | undefined) => (instance: HTMLElement | null) => void;

export { useForkRef }

Expand Down
76 changes: 68 additions & 8 deletions packages/plasma-hope/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState, useContext, FC } from 'react';
import React, { useEffect, useCallback, useRef, useState, useContext, FC } from 'react';
import ReactDOM from 'react-dom';
import styled, { css, keyframes, createGlobalStyle } from 'styled-components';
import { useUniqId } from '@salutejs/plasma-core';
Expand All @@ -16,9 +16,34 @@ export interface ModalProps extends ModalViewProps {
isOpen: boolean;

/**
* Нужно ли применять blur для подложки
* Нужно ли применять blur для подложки.
*/
withBlur?: boolean;
/**
* Закрывать модальное окно при нажатии на ESC(по умолчанию true).
*/
closeOnEsc?: boolean;
/**
* Закрывать модальное окно при нажатии вне области модального окна(по умолчанию true),
*/
closeOnOverlayClick?: boolean;
/**
* Обработчик клика при нажатии на ESC(если не передан, то при нажатии используется onClose).
*/
onEscKeyDown?: (event: KeyboardEvent) => void;
/**
* Обработчик клика при нажатии вне области модального окна(если не передан, то при нажатии используется onClose).
*/
onOverlayClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
/**
* Первый элемент для фокуса внутри модального окна.
*/
initialFocusRef?: React.RefObject<HTMLElement>;
/**
* Элемент для фокуса после закрытия модального окна
* (по умолчанию фокус на последнем перед открытием активном элементе).
*/
focusAfterRef?: React.RefObject<HTMLElement>;
}

interface HidingProps {
Expand Down Expand Up @@ -78,7 +103,7 @@ const StyledWrapper = styled.div<HidingProps>`
`}
`;

const StyledOverlay = styled.div<{ transparent?: boolean; $withBlur?: boolean }>`
const StyledOverlay = styled.div<{ transparent?: boolean; $withBlur?: boolean; clickable?: boolean }>`
position: absolute;

${({ $withBlur }) => {
Expand All @@ -93,7 +118,7 @@ const StyledOverlay = styled.div<{ transparent?: boolean; $withBlur?: boolean }>

background-color: ${({ transparent }) => (transparent ? 'transparent' : 'var(--background-color)')};
backdrop-filter: var(--backdrop-filter);
cursor: pointer;
cursor: ${({ clickable }) => (clickable ? 'pointer' : 'default')};
`;

const StyledModal = styled.div<HidingProps>`
Expand All @@ -116,18 +141,46 @@ const NoScroll = createGlobalStyle`
* Модальное окно.
* Управляет показом/скрытием, подложкой и анимацией визуальной части модального окна.
*/
export const Modal: FC<ModalProps> = ({ id, isOpen, onClose, withBlur, ...rest }) => {
export const Modal: FC<ModalProps> = ({
id,
isOpen,
onClose,
onOverlayClick,
onEscKeyDown,
closeOnEsc = true,
closeOnOverlayClick = true,
withBlur,
initialFocusRef,
focusAfterRef,
...rest
}) => {
const uniqId = useUniqId();
const innerId = id || uniqId;

const wrapperRef = useRef<HTMLDivElement | null>(null);
const portalRef = useRef<HTMLElement | null>(null);
const trapRef = useFocusTrap();
const trapRef = useFocusTrap(true, initialFocusRef, focusAfterRef);

const controller = useContext(ModalsContext);

const [, forceRender] = useState(false);

const onOverlayKeyDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!closeOnOverlayClick) {
return;
}

if (onOverlayClick) {
onOverlayClick(event);
return;
}

onClose?.();
},
[closeOnOverlayClick, onOverlayClick, onClose],
);

useEffect(() => {
let portal = document.getElementById(MODALS_PORTAL_ID);

Expand Down Expand Up @@ -157,12 +210,18 @@ export const Modal: FC<ModalProps> = ({ id, isOpen, onClose, withBlur, ...rest }
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
closeOnEsc &&
event.keyCode === ESCAPE_KEYCODE &&
wrapperRef.current &&
portalRef.current &&
portalRef.current.contains(wrapperRef.current) &&
portalRef.current.children[portalRef.current.children.length - 1] === wrapperRef.current
) {
if (onEscKeyDown) {
onEscKeyDown(event);
return;
}

onClose?.();
}
};
Expand All @@ -172,7 +231,7 @@ export const Modal: FC<ModalProps> = ({ id, isOpen, onClose, withBlur, ...rest }
return () => {
window.removeEventListener('keydown', onKeyDown);
};
}, [onClose]);
}, [onClose, onEscKeyDown, closeOnEsc]);

if (isOpen) {
controller.register(innerId);
Expand All @@ -189,7 +248,8 @@ export const Modal: FC<ModalProps> = ({ id, isOpen, onClose, withBlur, ...rest }
<StyledWrapper ref={wrapperRef}>
<StyledOverlay
transparent={controller.items.indexOf(innerId) !== 0}
onClick={onClose}
clickable={closeOnOverlayClick}
onClick={onOverlayKeyDown}
$withBlur={withBlur}
/>
<StyledModal>
Expand Down
20 changes: 13 additions & 7 deletions packages/plasma-hope/src/components/Modal/ModalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ export interface ModalViewProps extends React.HTMLAttributes<HTMLDivElement> {
*/
children?: React.ReactNode;
/**
* Обработчик клика по кнопке "закрыть".
* Общий обработчик клика по кнопке "закрыть".
*/
onClose?: () => void;
/**
* WAI-ARIA атрибут кнопки "закрыть".
*/
closeButtonAriaLabel?: string;
/**
* Отображать кнопку "закрыть"(по умолчанию true).
*/
showCloseButton?: boolean;
}

const StyledWrapper = styled.div`
Expand Down Expand Up @@ -54,16 +58,18 @@ const StyledButtonClose = styled(Button({ design: 'web' })).attrs(() => ({ view:
* Визуальная часть модального окна.
*/
export const ModalView = React.forwardRef<HTMLDivElement, ModalViewProps>(
({ role = 'dialog', closeButtonAriaLabel, children, onClose, ...rest }, ref) => {
({ role = 'dialog', closeButtonAriaLabel, children, onClose, showCloseButton = true, ...rest }, ref) => {
return (
<StyledWrapper>
<StyledBody {...rest} ref={ref} role={role} aria-modal="true">
<StyledContent>{children}</StyledContent>
<StyledButtonClose
aria-label={closeButtonAriaLabel}
onClick={onClose}
contentLeft={<IconClose size="s" color="inherit" />}
/>
{showCloseButton && (
<StyledButtonClose
aria-label={closeButtonAriaLabel}
onClick={onClose}
contentLeft={<IconClose size="s" color="inherit" />}
/>
)}
</StyledBody>
</StyledWrapper>
);
Expand Down
19 changes: 13 additions & 6 deletions packages/plasma-hope/src/hooks/useFocusTrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ import { focusSelector, isFocusable, isTabble } from '../utils/tabbable';
import { scopeTab } from '../utils/scopeTab';

// Находим элемент для фокуса
const getFocusElement = (node: HTMLElement, firstFocusSelector?: string | HTMLElement): HTMLElement | null => {
const getFocusElement = (
node: HTMLElement,
firstFocusSelector?: string | React.RefObject<HTMLElement>,
): HTMLElement | null => {
let focusElement: HTMLElement | null = null;
if (firstFocusSelector) {
focusElement =
typeof firstFocusSelector === 'string' ? node.querySelector(firstFocusSelector) : firstFocusSelector;
if (typeof firstFocusSelector === 'string') {
focusElement = node.querySelector(firstFocusSelector);
} else if (firstFocusSelector.current) {
focusElement = firstFocusSelector.current;
}
}
neretin-trike marked this conversation as resolved.
Show resolved Hide resolved

if (!focusElement) {
Expand All @@ -25,7 +31,7 @@ const getFocusElement = (node: HTMLElement, firstFocusSelector?: string | HTMLEl
return focusElement;
};

const processNode = (node: HTMLElement, firstFocusSelector?: string | HTMLElement) => {
const processNode = (node: HTMLElement, firstFocusSelector?: string | React.RefObject<HTMLElement>) => {
const focusElement = getFocusElement(node, firstFocusSelector);

if (focusElement) {
Expand All @@ -40,7 +46,8 @@ const focusManager = new FocusManager();
* */
export const useFocusTrap = (
active = true,
firstFocusSelector?: string | HTMLElement,
firstFocusSelector?: string | React.RefObject<HTMLElement>,
focusAfterNode?: React.RefObject<HTMLElement>,
): ((instance: HTMLElement | null) => void) => {
const ref = useRef<HTMLElement | null>();

Expand All @@ -53,7 +60,7 @@ export const useFocusTrap = (

if (active && node) {
focusManager.setupScopedFocus(node);
focusManager.markForFocusLater();
focusManager.markForFocusAfter(focusAfterNode);

// Delay processing the HTML node by a frame. This ensures focus is assigned correctly.
setTimeout(() => {
Expand Down
10 changes: 6 additions & 4 deletions packages/plasma-hope/src/utils/focusManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { findTabbableDescendants } from './tabbable';
* */
export class FocusManager {
// массив с элементами, которые нужно зафокусить после анмаунта
private focusLaterElements: Array<HTMLElement> = [];
private focusAfterElements: Array<HTMLElement> = [];

// массив с trap нодами
private focusNodes: Array<HTMLElement> = [];
Expand All @@ -25,13 +25,15 @@ export class FocusManager {
};

// добавление на фокус после анмаунта
public markForFocusLater = () => {
this.focusLaterElements.push(document.activeElement as HTMLElement);
public markForFocusAfter = (focusAfterNode?: React.RefObject<HTMLElement>) => {
const node =
focusAfterNode && focusAfterNode.current ? focusAfterNode.current : (document.activeElement as HTMLElement);
this.focusAfterElements.push(node);
};

// фокус на необходимый элемент
public returnFocus = () => {
const toFocus = this.focusLaterElements.pop() ?? null;
const toFocus = this.focusAfterElements.pop() ?? null;
if (toFocus) {
toFocus.focus();
}
Expand Down
Loading