diff --git a/packages/react-core/src/components/Modal/Modal.tsx b/packages/react-core/src/components/Modal/Modal.tsx index fa83e709cb2..9364a2cf171 100644 --- a/packages/react-core/src/components/Modal/Modal.tsx +++ b/packages/react-core/src/components/Modal/Modal.tsx @@ -37,6 +37,10 @@ export interface ModalProps extends React.HTMLProps, OUIAProps { description?: React.ReactNode; /** Flag to disable focus trap. */ disableFocusTrap?: boolean; + /** The element to focus when the modal opens. By default the first + * focusable element will receive focus. + */ + elementToFocus?: HTMLElement | SVGElement | string; /** Custom footer. */ footer?: React.ReactNode; /** Flag indicating if modal content should be placed in a modal box body wrapper. */ @@ -235,6 +239,7 @@ export class Modal extends React.Component { ouiaId, ouiaSafe, position, + elementToFocus, ...props } = this.props; const { container } = this.state; @@ -260,6 +265,7 @@ export class Modal extends React.Component { ouiaId={ouiaId !== undefined ? ouiaId : this.state.ouiaStateId} ouiaSafe={ouiaSafe} position={position} + elementToFocus={elementToFocus} />, container ) as React.ReactElement; diff --git a/packages/react-core/src/components/Modal/ModalContent.tsx b/packages/react-core/src/components/Modal/ModalContent.tsx index 91fbc158b11..2ae5a134fce 100644 --- a/packages/react-core/src/components/Modal/ModalContent.tsx +++ b/packages/react-core/src/components/Modal/ModalContent.tsx @@ -47,6 +47,10 @@ export interface ModalContentProps extends OUIAProps { descriptorId: string; /** Flag to disable focus trap. */ disableFocusTrap?: boolean; + /** The element to focus when the modal opens. By default the first + * focusable element will receive focus. + */ + elementToFocus?: HTMLElement | SVGElement | string; /** Custom footer. */ footer?: React.ReactNode; /** Flag indicating if modal content should be placed in a modal box body wrapper. */ @@ -118,6 +122,7 @@ export const ModalContent: React.FunctionComponent = ({ hasNoBodyWrapper = false, ouiaId, ouiaSafe = true, + elementToFocus, ...props }: ModalContentProps) => { if (!isOpen) { @@ -202,7 +207,13 @@ export const ModalContent: React.FunctionComponent = ({ {modalBox} diff --git a/packages/react-core/src/components/Modal/examples/Modal.md b/packages/react-core/src/components/Modal/examples/Modal.md index c372ebf24b7..938802eca85 100644 --- a/packages/react-core/src/components/Modal/examples/Modal.md +++ b/packages/react-core/src/components/Modal/examples/Modal.md @@ -144,3 +144,11 @@ To submit the form from a button in the modal's footer (outside of the `
`) ```ts file="ModalWithForm.tsx" ``` + +### Custom focus + +Use the `elementToFocus` property to customize which element inside the Modal receives focus when initially opened. + +```ts file="./ModalCustomFocus.tsx" + +``` diff --git a/packages/react-core/src/components/Modal/examples/ModalCustomFocus.tsx b/packages/react-core/src/components/Modal/examples/ModalCustomFocus.tsx new file mode 100644 index 00000000000..eb797f7e167 --- /dev/null +++ b/packages/react-core/src/components/Modal/examples/ModalCustomFocus.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Modal, Button } from '@patternfly/react-core'; + +export const ModalCustomFocus: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + Confirm + , + + ]} + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est + laborum. + + + ); +}; diff --git a/packages/react-integration/cypress/integration/modal.spec.ts b/packages/react-integration/cypress/integration/modal.spec.ts index 24c61e72070..d5c08b00dac 100644 --- a/packages/react-integration/cypress/integration/modal.spec.ts +++ b/packages/react-integration/cypress/integration/modal.spec.ts @@ -83,4 +83,16 @@ describe('Modal Test', () => { cy.get('body').type('{esc}'); cy.get('.pf-v5-c-modal-box').should('not.exist'); }); + + it('Verify first focusable element receives focus by default', () => { + cy.get('#showDefaultModalButton').click(); + cy.get('.pf-v5-c-modal-box__close > .pf-v5-c-button.pf-m-plain').should('have.focus'); + cy.get('.pf-v5-c-modal-box__close > .pf-v5-c-button.pf-m-plain').click(); + }); + + it('Verify custom element receives focus', () => { + cy.get('#showCustomFocusModalButton').click(); + cy.get('#modal-custom-focus-confirm-button').should('have.focus'); + cy.get('#modal-custom-focus-cancel-button').click(); + }); }); diff --git a/packages/react-integration/demo-app-ts/src/components/demos/ModalDemo/ModalDemo.tsx b/packages/react-integration/demo-app-ts/src/components/demos/ModalDemo/ModalDemo.tsx index bdc93870459..24c94181eca 100644 --- a/packages/react-integration/demo-app-ts/src/components/demos/ModalDemo/ModalDemo.tsx +++ b/packages/react-integration/demo-app-ts/src/components/demos/ModalDemo/ModalDemo.tsx @@ -15,6 +15,7 @@ interface ModalDemoState { isModalCustomEscapeOpen: boolean; isModalAlertVariantOpen: boolean; customEscapePressed: boolean; + isCustomFocusModalOpen: boolean; } export class ModalDemo extends React.Component, ModalDemoState> { @@ -31,7 +32,8 @@ export class ModalDemo extends React.Component, isNoHeaderModalOpen: false, isModalCustomEscapeOpen: false, isModalAlertVariantOpen: false, - customEscapePressed: false + customEscapePressed: false, + isCustomFocusModalOpen: false }; handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { @@ -102,6 +104,12 @@ export class ModalDemo extends React.Component, })); }; + handleCustomFocusModalToggle = () => { + this.setState(({ isCustomFocusModalOpen }) => ({ + isCustomFocusModalOpen: !isCustomFocusModalOpen + })); + }; + componentDidMount() { window.scrollTo(0, 0); } @@ -426,6 +434,43 @@ export class ModalDemo extends React.Component, ); } + renderCustomFocusModal() { + const { isCustomFocusModalOpen } = this.state; + + return ( + + Confirm + , + + ]} + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est + laborum. + + ); + } + render() { const buttonStyle = { marginRight: 20, @@ -505,6 +550,14 @@ export class ModalDemo extends React.Component, + {this.renderModal()} {this.renderSmallModal()} @@ -517,6 +570,7 @@ export class ModalDemo extends React.Component, {this.renderModalWithCustomEscape()} {this.renderModalWithAlertVariant()} {this.renderHelpModal()} + {this.renderCustomFocusModal()} ); }