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(Modal): add responsiveness for modal #2737

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
42 changes: 38 additions & 4 deletions packages/core/src/components/Modal/Modal/Modal.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,17 @@ $full-view-margin: 40px;

&.sizeSmall {
--modal-max-height: 50%;
--modal-width: 480px;
--modal-width: 460px;
}

&.sizeMedium {
--modal-max-height: 80%;
--modal-width: 580px;
--modal-width: 540px;
}

&.sizeLarge {
--modal-max-height: 80%;
--modal-width: 840px;
--modal-width: 800px;
}

&.sizeFullView {
Expand All @@ -61,10 +61,44 @@ $full-view-margin: 40px;
inset: 0;
height: calc(100% - $full-view-margin);

margin-inline: $full-view-margin;
margin-inline: var(--spacing-large);
margin-block-start: $full-view-margin;

border-end-start-radius: unset;
border-end-end-radius: unset;
}

@media (min-width: 1280px) {
&.sizeSmall {
--modal-width: 480px;
}

&.sizeMedium {
--modal-width: 580px;
}

&.sizeLarge {
--modal-width: 840px;
}
}

@media (min-width: 1440px) {
&.sizeSmall {
--modal-width: 520px;
}

&.sizeMedium {
--modal-width: 620px;
}

&.sizeLarge {
--modal-width: 900px;
}
}

@media (min-width: 1720px) {
&.sizeFullView {
margin-inline: $full-view-margin;
}
}
}
27 changes: 24 additions & 3 deletions packages/core/src/components/Modal/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const Modal = forwardRef(
closeButtonTheme,
closeButtonAriaLabel,
onClose = () => {},
autoFocus = true,
onFocusAttempt,
anchorElementRef,
alertModal,
container = document.body,
Expand Down Expand Up @@ -78,9 +80,10 @@ const Modal = forwardRef(
() => ({
modalId: id,
setTitleId: setTitleIdCallback,
setDescriptionId: setDescriptionIdCallback
setDescriptionId: setDescriptionIdCallback,
autoFocus
}),
[id, setTitleIdCallback, setDescriptionIdCallback]
[id, setTitleIdCallback, setDescriptionIdCallback, autoFocus]
);

const onBackdropClick = useCallback<React.MouseEventHandler<HTMLDivElement>>(
Expand Down Expand Up @@ -108,13 +111,31 @@ const Modal = forwardRef(

const zIndexStyle = zIndex ? ({ "--monday-modal-z-index": zIndex } as React.CSSProperties) : {};

const handleFocusLockWhiteList = useCallback(
(nextFocusedElement?: HTMLElement) => {
if (!onFocusAttempt) return true;

const outcome = onFocusAttempt(nextFocusedElement);

if (outcome === true) return true;

if (outcome instanceof HTMLElement) {
outcome.focus();
return false;
}

return false;
},
[onFocusAttempt]
);

return (
<AnimatePresence>
{show && (
<LayerProvider layerRef={containerRef}>
<ModalProvider value={contextValue}>
{createPortal(
<FocusLockComponent returnFocus>
<FocusLockComponent returnFocus autoFocus={autoFocus} whiteList={handleFocusLockWhiteList}>
<div ref={containerRef} className={styles.container} style={zIndexStyle}>
<motion.div
variants={modalAnimationOverlayVariants}
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/components/Modal/Modal/Modal.types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,30 @@ export interface ModalProps extends VibeComponentProps {
* Callback fired when the modal should close.
*/
onClose?: (event: ModalCloseEvent) => void;
/**
* This is intended for advanced use-cases.
* It allows you to control the default focus behavior when the modal mounts.
* Make sure to use this prop only when you understand the implications.
*
* Determines if focus should automatically move to the first focusable element when the component mounts.
* When set to `false` - disables the automatic focus behavior.
* - Notice this might break keyboard and general accessibility and should be used with caution.
*/
autoFocus?: boolean;
/**
* This is intended for advanced use-cases.
* It allows you to control the focus behavior when moving between elements within the modal.
* Make sure to use this prop only when you understand the implications.
*
* Called whenever focus is about to move to a new element within the modal.
* Return:
* - `true` to allow normal flow focus.
* - `false` to block it (let the browser decide, usually moves to body).
* - Notice this might break keyboard accessibility and should be used with caution.
* - An HTMLElement to redirect focus to instead of normal flow.
* - Any other value (e.g., null, undefined) would act as `false`.
*/
onFocusAttempt?: (nextFocusedElement?: HTMLElement) => boolean | HTMLElement;
/**
* Additional action to render in the header area.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,50 @@
[aria-modal][role="dialog"] {
&[class*="sizeSmall"] {
--modal-max-height: 50%;
--modal-width: 480px;
--modal-width: 460px;
}

&[class*="sizeMedium"] {
--modal-max-height: 80%;
--modal-width: 580px;
--modal-width: 540px;
}

&[class*="sizeLarge"] {
--modal-max-height: 80%;
--modal-width: 840px;
--modal-width: 800px;
}

&[class*="sizeFullView"] {
--modal-max-height: 100%;
--modal-width: auto;
}

@container (min-width: 1280px) {
&[class*="sizeSmall"] {
--modal-width: 480px;
}

&[class*="sizeMedium"] {
--modal-width: 580px;
}

&[class*="sizeLarge"] {
--modal-width: 840px;
}
}

@container (min-width: 1440px) {
&[class*="sizeSmall"] {
--modal-width: 520px;
}

&[class*="sizeMedium"] {
--modal-width: 620px;
}

&[class*="sizeLarge"] {
--modal-width: 900px;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,78 @@ describe("Modal", () => {
expect(getByText("Focusable 1")).toHaveFocus();
});

it("should allow focus by default if onFocusAttempt is not provided", () => {
const { getByText, getByLabelText } = render(
<>
<button type="button">Focusable outside</button>
<Modal id={id} show closeButtonAriaLabel={closeButtonAriaLabel}>
<button type="button">Focusable 1</button>
<button type="button">Focusable 2</button>
</Modal>
</>
);

expect(getByText("Focusable 1")).toHaveFocus();

userEvent.tab();
expect(getByText("Focusable 2")).toHaveFocus();

userEvent.tab();
expect(getByLabelText(closeButtonAriaLabel)).toHaveFocus();
});

it("should block focus if onFocusAttempt returns HTMLElement", () => {
const modalRef = React.createRef<HTMLDivElement>();
const onFocusAttemptMock = jest.fn(nextFocusedElement => {
return nextFocusedElement.textContent === "Focusable 2" ? modalRef.current : true;
});

const { getByText, getByRole } = render(
<>
<button type="button">Focusable outside</button>
<Modal
id={id}
ref={modalRef}
show
onFocusAttempt={onFocusAttemptMock}
closeButtonAriaLabel={closeButtonAriaLabel}
>
<button type="button">Focusable 1</button>
<button type="button">Focusable 2</button>
</Modal>
</>
);

expect(getByText("Focusable 1")).toHaveFocus();

userEvent.tab();
expect(onFocusAttemptMock).toHaveBeenCalledWith(getByText("Focusable 2"));
// initial + focusable 1 + ignored focusable 2 + enforced modal
expect(onFocusAttemptMock).toHaveBeenCalledTimes(4);
expect(getByRole("dialog")).toHaveFocus();
});

it("should not auto-focus any modal content when autoFocus is false", () => {
const outsideButtonRef = React.createRef<HTMLButtonElement>();
const { getByText } = render(
<>
<button type="button" ref={outsideButtonRef}>
Focusable outside
</button>
<Modal id={id} show autoFocus={false} closeButtonAriaLabel={closeButtonAriaLabel}>
<button type="button">Focusable 1</button>
</Modal>
</>
);

expect(document.body).toHaveFocus();
userEvent.tab();
expect(document.body).toHaveFocus();

userEvent.tab();
expect(getByText("Focusable 1")).toHaveFocus();
});

describe("integrated with ModalContent", () => {
it("should trap and moves focus to focusable element inside ModalContent and to cycle through full focus flow", () => {
const { getByLabelText, getByText } = render(
Expand All @@ -266,6 +338,24 @@ describe("Modal", () => {
userEvent.tab();
expect(getByText("Focusable inside ModalContent")).toHaveFocus();
});

it("should pass autoFocus prop value to ModalContent's data-autofocus-inside attribute", () => {
const { getByTestId, rerender } = render(
<Modal id={id} show>
<ModalContent data-testid="modal-content">Some content</ModalContent>
</Modal>
);

expect(getByTestId("modal-content")).toHaveAttribute("data-autofocus-inside", "true");

rerender(
<Modal id={id} show autoFocus={false}>
<ModalContent data-testid="modal-content">Some content</ModalContent>
</Modal>
);

expect(getByTestId("modal-content")).toHaveAttribute("data-autofocus-inside", "false");
});
});

describe("integrated with ModalHeader", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ import React, { forwardRef } from "react";
import { getTestId } from "../../../tests/test-ids-utils";
import { ComponentDefaultTestId } from "../../../tests/constants";
import { ModalContentProps } from "./ModalContent.types";
import { useModal } from "../context/ModalContext";

const ModalContent = forwardRef(
(
{ children, className, id, "data-testid": dataTestId }: ModalContentProps,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const { autoFocus } = useModal();
return (
<div
ref={ref}
className={className}
id={id}
data-testid={dataTestId || getTestId(ComponentDefaultTestId.MODAL_NEXT_CONTENT, id)}
data-autofocus-inside={true}
data-autofocus-inside={autoFocus}
>
{children}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export type ModalProviderValue = {
* Callback to set the description element ID for accessibility.
*/
setDescriptionId: (id: string) => void;
/**
* Passed from the Modal component, `true` by default, `false` if set by user.
*/
autoFocus?: boolean;
};

export interface ModalProviderProps {
Expand Down
Loading