Skip to content
Open
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
81 changes: 49 additions & 32 deletions packages/core/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import { DialogAnimationType, DialogPosition, DialogTriggerEvent } from "./Dialo
import LayerContext from "../LayerProvider/LayerContext";
import { isClient } from "../../utils/ssr-utils";
import { createObserveContentResizeModifier } from "./modifiers/observeContentResizeModifier";
import FocusLock from "react-focus-lock";

// @ts-expect-error This is a precaution to support all possible module systems (ESM/CJS)
const FocusLockComponent = (FocusLock.default || FocusLock) as typeof FocusLock;

export interface DialogProps extends VibeComponentProps {
/**
Expand Down Expand Up @@ -178,6 +182,11 @@ export interface DialogProps extends VibeComponentProps {
* that may grow or shrink without a re-render being triggered.
*/
observeContentResize?: boolean;
/**
* If true, the dialog content will attempt to focus itself when opened.
* The focus will be typically set on the dialog's main content wrapper.
*/
autoFocus?: boolean;
}

export interface DialogState {
Expand Down Expand Up @@ -225,7 +234,8 @@ export default class Dialog extends PureComponent<DialogProps, DialogState> {
shouldCallbackOnMount: false,
instantShowAndHide: false,
addKeyboardHideShowTriggersByDefault: false,
observeContentResize: false
observeContentResize: false,
autoFocus: false
};
private showTimeout: NodeJS.Timeout;
private hideTimeout: NodeJS.Timeout;
Expand Down Expand Up @@ -542,7 +552,8 @@ export default class Dialog extends PureComponent<DialogProps, DialogState> {
containerSelector,
observeContentResize,
id,
"data-testid": dataTestId
"data-testid": dataTestId,
autoFocus
} = this.props;
const { preventAnimation } = this.state;
const overrideDataTestId = dataTestId || getTestId(ComponentDefaultTestId.DIALOG, id);
Expand Down Expand Up @@ -631,37 +642,43 @@ export default class Dialog extends PureComponent<DialogProps, DialogState> {
}

return (
<DialogContent
data-testid={overrideDataTestId}
isReferenceHidden={hideWhenReferenceHidden && isReferenceHidden}
onMouseEnter={this.onDialogEnter}
onMouseLeave={this.onDialogLeave}
onClickOutside={this.onClickOutside}
onContextMenu={this.onContextMenu}
onEsc={this.onEsc}
animationType={animationTypeCalculated}
position={placement}
wrapperClassName={wrapperClassName}
startingEdge={startingEdge}
isOpen={this.isShown()}
showDelay={showDelay}
styleObject={style}
ref={ref}
onClick={this.onContentClick}
hasTooltip={!!tooltip}
containerSelector={containerSelector}
disableContainerScroll={disableContainerScroll}
<FocusLockComponent
disabled={!this.isShown()}
returnFocus
autoFocus={autoFocus}
>
{contentRendered}
{tooltip && (
<div
style={arrowProps.style}
ref={arrowProps.ref}
className={cx(styles.arrow, tooltipClassName)}
data-placement={placement}
/>
)}
</DialogContent>
<DialogContent
data-testid={overrideDataTestId}
isReferenceHidden={hideWhenReferenceHidden && isReferenceHidden}
onMouseEnter={this.onDialogEnter}
onMouseLeave={this.onDialogLeave}
onClickOutside={this.onClickOutside}
onContextMenu={this.onContextMenu}
onEsc={this.onEsc}
animationType={animationTypeCalculated}
position={placement}
wrapperClassName={wrapperClassName}
startingEdge={startingEdge}
isOpen={this.isShown()}
showDelay={showDelay}
styleObject={style}
ref={ref}
onClick={this.onContentClick}
hasTooltip={!!tooltip}
containerSelector={containerSelector}
disableContainerScroll={disableContainerScroll}
>
{contentRendered}
{tooltip && (
<div
style={arrowProps.style}
ref={arrowProps.ref}
className={cx(styles.arrow, tooltipClassName)}
data-placement={placement}
/>
)}
</DialogContent>
</FocusLockComponent>
);
}}
</Popper>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ const DialogContent = forwardRef(
}: DialogContentProps,
forwardRef: React.ForwardedRef<HTMLElement>
) => {
const ref = useRef(null);
const clickOutsideRef = useRef(null);

const onOutSideClick = useCallback(
(event: React.MouseEvent) => {
if (isOpen) {
Expand All @@ -136,8 +137,8 @@ const DialogContent = forwardRef(
[isOpen, onContextMenu]
);
useKeyEvent({ keys: ESCAPE_KEYS, callback: onEsc });
useClickOutside({ callback: onOutSideClick, ref });
useClickOutside({ eventName: "contextmenu", callback: overrideOnContextMenu, ref });
useClickOutside({ callback: onOutSideClick, ref: clickOutsideRef });
useClickOutside({ eventName: "contextmenu", callback: overrideOnContextMenu, ref: clickOutsideRef });
const selectorToDisable = typeof disableContainerScroll === "string" ? disableContainerScroll : containerSelector;
const { disableScroll, enableScroll } = useDisableScroll(selectorToDisable);

Expand Down Expand Up @@ -170,7 +171,6 @@ const DialogContent = forwardRef(
}
return (
<span
// don't remove old classname - override from Monolith
className={cx("monday-style-dialog-content-wrapper", styles.contentWrapper, wrapperClassName)}
ref={forwardRef}
data-testid={dataTestId}
Expand All @@ -184,7 +184,7 @@ const DialogContent = forwardRef(
[getStyle(styles, camelCase("edge-" + startingEdge))]: startingEdge,
[styles.hasTooltip]: hasTooltip
})}
ref={ref}
ref={clickOutsideRef}
>
{React.Children.toArray(children).map((child: ReactElement) => {
return cloneElement(child, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,57 @@ export const ControlledDialog = {
name: "Controlled Dialog"
};

export const AutoFocusDialog = {
render: () => {
const { isChecked: isOpen, onChange: setIsOpen } = useSwitch({
defaultChecked: false
});

// For preventing dialog from moving while scrolling in stories
const modifiers = [
{
name: "preventOverflow",
options: {
mainAxis: false
}
}
];

return (
<div className="monday-storybook-dialog--story-padding">
<Button
onClick={() => setIsOpen((prev: boolean) => !prev)}
style={{ marginBottom: "16px" }}
>
{isOpen ? "Close" : "Open"} Dialog with AutoFocus
</Button>
<Dialog
open={isOpen}
autoFocus // The new prop
showTrigger={[]} // Manually controlled by open prop
hideTrigger={[]} // Manually controlled
onClickOutside={() => setIsOpen(false)}
position="right"
modifiers={modifiers}
content={
<DialogContentContainer>
<div style={{ padding: "16px", width: "300px" }}>
<p>This dialog should be focused on open.</p>
<input type="text" placeholder="Try tabbing..." style={{ margin: "8px 0" }} />
<Button onClick={() => setIsOpen(false)}>Close me</Button>
</div>
</DialogContentContainer>
}
>
{/* Reference element (hidden, as dialog is controlled by button above) */}
<div />
</Dialog>
</div>
);
},
name: "AutoFocus Dialog"
};

export const DialogWithTooltip = {
// for prevent dialog to move while scrolling
render: () => {
Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/components/Dialog/__tests__/Dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,69 @@ describe("Dialog tests", () => {
expect(onClickOutsideMock).not.toBeCalled();
});
});

describe("autoFocus with FocusLock", () => {
it("should focus the first focusable element within dialog when autoFocus is true", async () => {
const buttonText = "Focus Me";
render(
<Dialog
shouldShowOnMount
autoFocus
content={ // Content for FocusLock to find a focusable element
<div>
<p>Some text</p>
<button type="button">{buttonText}</button>
<input type="text" aria-label="another focusable" />
</div>
}
>
<span>trigger</span>
</Dialog>
);

// react-focus-lock might take a moment to apply focus to the first focusable element
const focusableButton = await screen.findByText(buttonText);
expect(focusableButton).toHaveFocus();
});

it("should not auto-focus dialog content when autoFocus is false", async () => {
const buttonText = "Focus Me If AutoFocused";
const triggerButtonText = "Open Dialog For No AutoFocus Test";
// Keep a ref to an element outside the dialog to check if focus remains outside
const outerButtonRef = React.createRef<HTMLButtonElement>();

render(
<>
<button ref={outerButtonRef} type="button">Button Outside</button>
<Dialog
autoFocus={false}
content={
<div>
<button type="button">{buttonText}</button>
</div>
}
showTrigger={["click"]}
hideTrigger={[]} // Keep it simple for testing focus on open
>
<button type="button">{triggerButtonText}</button>
</Dialog>
</>
);

const trigger = screen.getByText(triggerButtonText);
outerButtonRef.current?.focus(); // Ensure focus is outside before dialog opens
expect(outerButtonRef.current).toHaveFocus();

userEvent.click(trigger); // Open the dialog

// Wait for dialog to be visible and any potential focus shifts to settle
const focusableButtonInDialog = await screen.findByText(buttonText);
expect(focusableButtonInDialog).not.toHaveFocus();
// Check if focus remained on the button that opened it or returned to body/outer button
// For this specific test, if it's not on the dialog content, it's a pass for autoFocus=false.
// Depending on how FocusLock and dialog interactions are set, focus might go to the trigger or body.
// A more robust check might be that document.activeElement is NOT within the dialog.
expect(document.body).toHaveFocus(); // Or expect(trigger).toHaveFocus(); if that's the behavior
});
});
});
Loading