Mm 61590 - trap focus in modals (#30622)
* MM-61590 - trap focus in modals * Adjust focus trap for dynamic loading elements and multi modal support * add tests and improve code in generic modal * fix snapshot --------- Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
@@ -3,7 +3,6 @@
|
|||||||
exports[`components/BrowseChannels should match snapshot and state 1`] = `
|
exports[`components/BrowseChannels should match snapshot and state 1`] = `
|
||||||
<GenericModal
|
<GenericModal
|
||||||
aria-modal={true}
|
aria-modal={true}
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={false}
|
autoCloseOnConfirmButton={false}
|
||||||
bodyPadding={false}
|
bodyPadding={false}
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
@@ -32,18 +31,13 @@ exports[`components/BrowseChannels should match snapshot and state 1`] = `
|
|||||||
</Memo(TeamPermissionGate)>
|
</Memo(TeamPermissionGate)>
|
||||||
}
|
}
|
||||||
id="browseChannelsModal"
|
id="browseChannelsModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<Memo(MemoizedFormattedMessage)
|
<Memo(MemoizedFormattedMessage)
|
||||||
defaultMessage="Browse Channels"
|
defaultMessage="Browse Channels"
|
||||||
id="more_channels.title"
|
id="more_channels.title"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
show={true}
|
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<injectIntl(SearchableChannelList)
|
<injectIntl(SearchableChannelList)
|
||||||
canShowArchivedChannels={true}
|
canShowArchivedChannels={true}
|
||||||
|
|||||||
@@ -2,13 +2,9 @@
|
|||||||
|
|
||||||
exports[`components/drafts/draft_actions/delete_draft_modal should have called onConfirm 1`] = `
|
exports[`components/drafts/draft_actions/delete_draft_modal should have called onConfirm 1`] = `
|
||||||
<GenericModal
|
<GenericModal
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
autoFocusConfirmButton={true}
|
autoFocusConfirmButton={true}
|
||||||
bodyPadding={true}
|
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
confirmButtonText="Yes, delete"
|
confirmButtonText="Yes, delete"
|
||||||
enforceFocus={true}
|
|
||||||
handleCancel={[Function]}
|
handleCancel={[Function]}
|
||||||
handleConfirm={
|
handleConfirm={
|
||||||
[MockFunction] {
|
[MockFunction] {
|
||||||
@@ -23,15 +19,9 @@ exports[`components/drafts/draft_actions/delete_draft_modal should have called o
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
id="genericModal"
|
|
||||||
isDeleteModal={true}
|
isDeleteModal={true}
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Delete draft"
|
modalHeaderText="Delete draft"
|
||||||
modalLocation="center"
|
|
||||||
onExited={[MockFunction]}
|
onExited={[MockFunction]}
|
||||||
show={true}
|
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<MemoizedFormattedMessage
|
<MemoizedFormattedMessage
|
||||||
defaultMessage="Are you sure you want to delete this draft to <strong>{displayName}</strong>?"
|
defaultMessage="Are you sure you want to delete this draft to <strong>{displayName}</strong>?"
|
||||||
@@ -48,20 +38,13 @@ exports[`components/drafts/draft_actions/delete_draft_modal should have called o
|
|||||||
|
|
||||||
exports[`components/drafts/draft_actions/delete_draft_modal should have called onExited 1`] = `
|
exports[`components/drafts/draft_actions/delete_draft_modal should have called onExited 1`] = `
|
||||||
<GenericModal
|
<GenericModal
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
autoFocusConfirmButton={true}
|
autoFocusConfirmButton={true}
|
||||||
bodyPadding={true}
|
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
confirmButtonText="Yes, delete"
|
confirmButtonText="Yes, delete"
|
||||||
enforceFocus={true}
|
|
||||||
handleCancel={[Function]}
|
handleCancel={[Function]}
|
||||||
handleConfirm={[MockFunction]}
|
handleConfirm={[MockFunction]}
|
||||||
id="genericModal"
|
|
||||||
isDeleteModal={true}
|
isDeleteModal={true}
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Delete draft"
|
modalHeaderText="Delete draft"
|
||||||
modalLocation="center"
|
|
||||||
onExited={
|
onExited={
|
||||||
[MockFunction] {
|
[MockFunction] {
|
||||||
"calls": Array [
|
"calls": Array [
|
||||||
@@ -75,9 +58,6 @@ exports[`components/drafts/draft_actions/delete_draft_modal should have called o
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
show={true}
|
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<MemoizedFormattedMessage
|
<MemoizedFormattedMessage
|
||||||
defaultMessage="Are you sure you want to delete this draft to <strong>{displayName}</strong>?"
|
defaultMessage="Are you sure you want to delete this draft to <strong>{displayName}</strong>?"
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
exports[`components/drafts/draft_actions/send_draft_modal should have called onConfirm 1`] = `
|
exports[`components/drafts/draft_actions/send_draft_modal should have called onConfirm 1`] = `
|
||||||
<GenericModal
|
<GenericModal
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
confirmButtonText="Yes, send now"
|
confirmButtonText="Yes, send now"
|
||||||
enforceFocus={true}
|
|
||||||
handleCancel={[Function]}
|
handleCancel={[Function]}
|
||||||
handleConfirm={
|
handleConfirm={
|
||||||
[MockFunction] {
|
[MockFunction] {
|
||||||
@@ -22,14 +18,8 @@ exports[`components/drafts/draft_actions/send_draft_modal should have called onC
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
id="genericModal"
|
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Send message now"
|
modalHeaderText="Send message now"
|
||||||
modalLocation="center"
|
|
||||||
onExited={[MockFunction]}
|
onExited={[MockFunction]}
|
||||||
show={true}
|
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<MemoizedFormattedMessage
|
<MemoizedFormattedMessage
|
||||||
defaultMessage="Are you sure you want to send this message to <strong>{displayName}</strong>?"
|
defaultMessage="Are you sure you want to send this message to <strong>{displayName}</strong>?"
|
||||||
@@ -46,18 +36,11 @@ exports[`components/drafts/draft_actions/send_draft_modal should have called onC
|
|||||||
|
|
||||||
exports[`components/drafts/draft_actions/send_draft_modal should have called onExited 1`] = `
|
exports[`components/drafts/draft_actions/send_draft_modal should have called onExited 1`] = `
|
||||||
<GenericModal
|
<GenericModal
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
confirmButtonText="Yes, send now"
|
confirmButtonText="Yes, send now"
|
||||||
enforceFocus={true}
|
|
||||||
handleCancel={[Function]}
|
handleCancel={[Function]}
|
||||||
handleConfirm={[MockFunction]}
|
handleConfirm={[MockFunction]}
|
||||||
id="genericModal"
|
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Send message now"
|
modalHeaderText="Send message now"
|
||||||
modalLocation="center"
|
|
||||||
onExited={
|
onExited={
|
||||||
[MockFunction] {
|
[MockFunction] {
|
||||||
"calls": Array [
|
"calls": Array [
|
||||||
@@ -71,9 +54,6 @@ exports[`components/drafts/draft_actions/send_draft_modal should have called onE
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
show={true}
|
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<MemoizedFormattedMessage
|
<MemoizedFormattedMessage
|
||||||
defaultMessage="Are you sure you want to send this message to <strong>{displayName}</strong>?"
|
defaultMessage="Are you sure you want to send this message to <strong>{displayName}</strong>?"
|
||||||
|
|||||||
@@ -499,21 +499,13 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
|
|||||||
>
|
>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabelledby="confirmModalLabel"
|
ariaLabelledby="confirmModalLabel"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
className="ConfirmModal a11y__modal "
|
className="ConfirmModal a11y__modal "
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
|
||||||
id="confirmModal"
|
id="confirmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Save Outgoing OAuth Connection"
|
modalHeaderText="Save Outgoing OAuth Connection"
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={false}
|
show={false}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
animation={true}
|
animation={true}
|
||||||
@@ -1103,21 +1095,13 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
|
|||||||
>
|
>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabelledby="confirmModalLabel"
|
ariaLabelledby="confirmModalLabel"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
className="ConfirmModal a11y__modal "
|
className="ConfirmModal a11y__modal "
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
|
||||||
id="confirmModal"
|
id="confirmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Save Outgoing OAuth Connection"
|
modalHeaderText="Save Outgoing OAuth Connection"
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={false}
|
show={false}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
animation={true}
|
animation={true}
|
||||||
|
|||||||
@@ -488,21 +488,13 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho
|
|||||||
>
|
>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabelledby="confirmModalLabel"
|
ariaLabelledby="confirmModalLabel"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
className="ConfirmModal a11y__modal "
|
className="ConfirmModal a11y__modal "
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
|
||||||
id="confirmModal"
|
id="confirmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Save Outgoing OAuth Connection"
|
modalHeaderText="Save Outgoing OAuth Connection"
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={false}
|
show={false}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
animation={true}
|
animation={true}
|
||||||
|
|||||||
@@ -550,26 +550,18 @@ https://myothersite.com/api/v2"
|
|||||||
>
|
>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabelledby="confirmModalLabel"
|
ariaLabelledby="confirmModalLabel"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
className="ConfirmModal a11y__modal integrations-backstage-modal"
|
className="ConfirmModal a11y__modal integrations-backstage-modal"
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
|
||||||
id="confirmModal"
|
id="confirmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<Memo(MemoizedFormattedMessage)
|
<Memo(MemoizedFormattedMessage)
|
||||||
defaultMessage="Edit Outgoing OAuth Connection"
|
defaultMessage="Edit Outgoing OAuth Connection"
|
||||||
id="update_outgoing_oauth_connection.confirm"
|
id="update_outgoing_oauth_connection.confirm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={false}
|
show={false}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
animation={true}
|
animation={true}
|
||||||
@@ -673,21 +665,13 @@ https://myothersite.com/api/v2"
|
|||||||
>
|
>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabelledby="confirmModalLabel"
|
ariaLabelledby="confirmModalLabel"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
className="ConfirmModal a11y__modal "
|
className="ConfirmModal a11y__modal "
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
|
||||||
id="confirmModal"
|
id="confirmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Save Outgoing OAuth Connection"
|
modalHeaderText="Save Outgoing OAuth Connection"
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={false}
|
show={false}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
animation={true}
|
animation={true}
|
||||||
@@ -1310,26 +1294,18 @@ https://myothersite.com/api/v2"
|
|||||||
>
|
>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabelledby="confirmModalLabel"
|
ariaLabelledby="confirmModalLabel"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
className="ConfirmModal a11y__modal integrations-backstage-modal"
|
className="ConfirmModal a11y__modal integrations-backstage-modal"
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
|
||||||
id="confirmModal"
|
id="confirmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<Memo(MemoizedFormattedMessage)
|
<Memo(MemoizedFormattedMessage)
|
||||||
defaultMessage="Edit Outgoing OAuth Connection"
|
defaultMessage="Edit Outgoing OAuth Connection"
|
||||||
id="update_outgoing_oauth_connection.confirm"
|
id="update_outgoing_oauth_connection.confirm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={false}
|
show={false}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
animation={true}
|
animation={true}
|
||||||
@@ -1433,21 +1409,13 @@ https://myothersite.com/api/v2"
|
|||||||
>
|
>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabelledby="confirmModalLabel"
|
ariaLabelledby="confirmModalLabel"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
className="ConfirmModal a11y__modal "
|
className="ConfirmModal a11y__modal "
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
|
||||||
id="confirmModal"
|
id="confirmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Save Outgoing OAuth Connection"
|
modalHeaderText="Save Outgoing OAuth Connection"
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={false}
|
show={false}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
animation={true}
|
animation={true}
|
||||||
@@ -2069,26 +2037,18 @@ https://myothersite.com/api/v2"
|
|||||||
>
|
>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabelledby="confirmModalLabel"
|
ariaLabelledby="confirmModalLabel"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
className="ConfirmModal a11y__modal integrations-backstage-modal"
|
className="ConfirmModal a11y__modal integrations-backstage-modal"
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
|
||||||
id="confirmModal"
|
id="confirmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<Memo(MemoizedFormattedMessage)
|
<Memo(MemoizedFormattedMessage)
|
||||||
defaultMessage="Edit Outgoing OAuth Connection"
|
defaultMessage="Edit Outgoing OAuth Connection"
|
||||||
id="update_outgoing_oauth_connection.confirm"
|
id="update_outgoing_oauth_connection.confirm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={false}
|
show={false}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
animation={true}
|
animation={true}
|
||||||
@@ -2192,21 +2152,13 @@ https://myothersite.com/api/v2"
|
|||||||
>
|
>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabelledby="confirmModalLabel"
|
ariaLabelledby="confirmModalLabel"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={true}
|
|
||||||
className="ConfirmModal a11y__modal "
|
className="ConfirmModal a11y__modal "
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
|
||||||
id="confirmModal"
|
id="confirmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText="Save Outgoing OAuth Connection"
|
modalHeaderText="Save Outgoing OAuth Connection"
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={false}
|
show={false}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
animation={true}
|
animation={true}
|
||||||
|
|||||||
@@ -2,14 +2,11 @@
|
|||||||
|
|
||||||
exports[`components/MoreDirectChannels should exclude deleted users if there is not direct channel between users 1`] = `
|
exports[`components/MoreDirectChannels should exclude deleted users if there is not direct channel between users 1`] = `
|
||||||
<GenericModal
|
<GenericModal
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={false}
|
bodyPadding={false}
|
||||||
className="a11y__modal more-modal more-direct-channels more-direct-channels-generic-modal"
|
className="a11y__modal more-modal more-direct-channels more-direct-channels-generic-modal"
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
delayFocusTrap={true}
|
||||||
id="moreDmModal"
|
id="moreDmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<Memo(MemoizedFormattedMessage)
|
<Memo(MemoizedFormattedMessage)
|
||||||
defaultMessage="Direct Messages"
|
defaultMessage="Direct Messages"
|
||||||
@@ -21,8 +18,6 @@ exports[`components/MoreDirectChannels should exclude deleted users if there is
|
|||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={true}
|
show={true}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="application"
|
role="application"
|
||||||
@@ -251,14 +246,11 @@ exports[`components/MoreDirectChannels should exclude deleted users if there is
|
|||||||
|
|
||||||
exports[`components/MoreDirectChannels should match snapshot 1`] = `
|
exports[`components/MoreDirectChannels should match snapshot 1`] = `
|
||||||
<GenericModal
|
<GenericModal
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={false}
|
bodyPadding={false}
|
||||||
className="a11y__modal more-modal more-direct-channels more-direct-channels-generic-modal"
|
className="a11y__modal more-modal more-direct-channels more-direct-channels-generic-modal"
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={true}
|
delayFocusTrap={true}
|
||||||
id="moreDmModal"
|
id="moreDmModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<Memo(MemoizedFormattedMessage)
|
<Memo(MemoizedFormattedMessage)
|
||||||
defaultMessage="Direct Messages"
|
defaultMessage="Direct Messages"
|
||||||
@@ -270,8 +262,6 @@ exports[`components/MoreDirectChannels should match snapshot 1`] = `
|
|||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={true}
|
show={true}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="application"
|
role="application"
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
|||||||
bodyPadding={false}
|
bodyPadding={false}
|
||||||
onEntered={this.loadModalData}
|
onEntered={this.loadModalData}
|
||||||
modalLocation={'top'}
|
modalLocation={'top'}
|
||||||
|
delayFocusTrap={true}
|
||||||
>
|
>
|
||||||
<div role='application'>
|
<div role='application'>
|
||||||
{body}
|
{body}
|
||||||
|
|||||||
@@ -225,7 +225,6 @@ export default class MultiSelectList<T extends Value> extends React.PureComponen
|
|||||||
id='multiSelectList'
|
id='multiSelectList'
|
||||||
className='more-modal__options'
|
className='more-modal__options'
|
||||||
role='presentation'
|
role='presentation'
|
||||||
aria-hidden={true}
|
|
||||||
>
|
>
|
||||||
{optionControls}
|
{optionControls}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ exports[`ProductNoticesModal Match snapshot for single notice 1`] = `
|
|||||||
<GenericModal
|
<GenericModal
|
||||||
autoCloseOnCancelButton={false}
|
autoCloseOnCancelButton={false}
|
||||||
autoCloseOnConfirmButton={true}
|
autoCloseOnConfirmButton={true}
|
||||||
bodyPadding={true}
|
|
||||||
cancelButtonText={null}
|
cancelButtonText={null}
|
||||||
className="productNotices"
|
className="productNotices"
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
@@ -13,21 +12,14 @@ exports[`ProductNoticesModal Match snapshot for single notice 1`] = `
|
|||||||
Download
|
Download
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
enforceFocus={true}
|
|
||||||
handleConfirm={[Function]}
|
handleConfirm={[Function]}
|
||||||
handleEnterKeyPress={[Function]}
|
handleEnterKeyPress={[Function]}
|
||||||
id="genericModal"
|
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<span>
|
<span>
|
||||||
title
|
title
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
show={true}
|
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="productNotices__helpText"
|
className="productNotices__helpText"
|
||||||
@@ -49,7 +41,6 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
|
|||||||
<GenericModal
|
<GenericModal
|
||||||
autoCloseOnCancelButton={false}
|
autoCloseOnCancelButton={false}
|
||||||
autoCloseOnConfirmButton={true}
|
autoCloseOnConfirmButton={true}
|
||||||
bodyPadding={true}
|
|
||||||
cancelButtonText={
|
cancelButtonText={
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Memo(PreviousIcon) />
|
<Memo(PreviousIcon) />
|
||||||
@@ -67,22 +58,15 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
|
|||||||
id="generic.done"
|
id="generic.done"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
enforceFocus={true}
|
|
||||||
handleCancel={[Function]}
|
handleCancel={[Function]}
|
||||||
handleConfirm={[Function]}
|
handleConfirm={[Function]}
|
||||||
handleEnterKeyPress={[Function]}
|
handleEnterKeyPress={[Function]}
|
||||||
id="genericModal"
|
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<span>
|
<span>
|
||||||
title
|
title
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
show={true}
|
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="productNotices__helpText"
|
className="productNotices__helpText"
|
||||||
@@ -130,7 +114,6 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
|
|||||||
<GenericModal
|
<GenericModal
|
||||||
autoCloseOnCancelButton={false}
|
autoCloseOnCancelButton={false}
|
||||||
autoCloseOnConfirmButton={false}
|
autoCloseOnConfirmButton={false}
|
||||||
bodyPadding={true}
|
|
||||||
cancelButtonText={null}
|
cancelButtonText={null}
|
||||||
className="productNotices"
|
className="productNotices"
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
@@ -143,21 +126,14 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
|
|||||||
<Memo(NextIcon) />
|
<Memo(NextIcon) />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
}
|
}
|
||||||
enforceFocus={true}
|
|
||||||
handleConfirm={[Function]}
|
handleConfirm={[Function]}
|
||||||
handleEnterKeyPress={[Function]}
|
handleEnterKeyPress={[Function]}
|
||||||
id="genericModal"
|
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<span>
|
<span>
|
||||||
for sysadmin
|
for sysadmin
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
modalLocation="center"
|
|
||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
show={true}
|
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="productNotices__helpText"
|
className="productNotices__helpText"
|
||||||
|
|||||||
@@ -3,14 +3,11 @@
|
|||||||
exports[`components/QuickSwitchModal should match snapshot 1`] = `
|
exports[`components/QuickSwitchModal should match snapshot 1`] = `
|
||||||
<GenericModal
|
<GenericModal
|
||||||
ariaLabel="Find Channels"
|
ariaLabel="Find Channels"
|
||||||
autoCloseOnCancelButton={true}
|
|
||||||
autoCloseOnConfirmButton={true}
|
|
||||||
bodyPadding={false}
|
bodyPadding={false}
|
||||||
className="a11y__modal channel-switcher"
|
className="a11y__modal channel-switcher"
|
||||||
compassDesign={true}
|
compassDesign={true}
|
||||||
enforceFocus={false}
|
enforceFocus={false}
|
||||||
id="quickSwitchModal"
|
id="quickSwitchModal"
|
||||||
keyboardEscape={true}
|
|
||||||
modalHeaderText={
|
modalHeaderText={
|
||||||
<div
|
<div
|
||||||
className="channel-switcher__header"
|
className="channel-switcher__header"
|
||||||
@@ -25,7 +22,6 @@ exports[`components/QuickSwitchModal should match snapshot 1`] = `
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
modalLocation="center"
|
|
||||||
modalSubheaderText={
|
modalSubheaderText={
|
||||||
<div
|
<div
|
||||||
className="channel-switcher__hint"
|
className="channel-switcher__hint"
|
||||||
@@ -45,8 +41,6 @@ exports[`components/QuickSwitchModal should match snapshot 1`] = `
|
|||||||
onExited={[Function]}
|
onExited={[Function]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
show={true}
|
show={true}
|
||||||
showCloseButton={true}
|
|
||||||
showHeader={true}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="channel-switcher__suggestion-box"
|
className="channel-switcher__suggestion-box"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import QuickSwitchModal from 'components/quick_switch_modal/quick_switch_modal';
|
|||||||
import ChannelNavigator from 'components/sidebar/channel_navigator/channel_navigator';
|
import ChannelNavigator from 'components/sidebar/channel_navigator/channel_navigator';
|
||||||
|
|
||||||
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
|
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
|
||||||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
import {act, renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||||
import Constants from 'utils/constants';
|
import Constants from 'utils/constants';
|
||||||
|
|
||||||
describe('components/QuickSwitchModal', () => {
|
describe('components/QuickSwitchModal', () => {
|
||||||
@@ -149,7 +149,7 @@ describe('components/QuickSwitchModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('accessibility', () => {
|
describe('accessibility', () => {
|
||||||
it('should restore focus to button', () => {
|
it('should restore focus to button', async () => {
|
||||||
const channelNavigatorProps = {
|
const channelNavigatorProps = {
|
||||||
showUnreadsCategory: false,
|
showUnreadsCategory: false,
|
||||||
isQuickSwitcherOpen: false,
|
isQuickSwitcherOpen: false,
|
||||||
@@ -168,8 +168,10 @@ describe('components/QuickSwitchModal', () => {
|
|||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
userEvent.click(screen.getByTestId('SidebarChannelNavigatorButton'));
|
await act(async () => {
|
||||||
userEvent.keyboard('{escape}');
|
userEvent.click(await screen.getByTestId('SidebarChannelNavigatorButton'));
|
||||||
|
userEvent.keyboard('{escape}');
|
||||||
|
});
|
||||||
expect(screen.getByTestId('SidebarChannelNavigatorButton')).toHaveFocus();
|
expect(screen.getByTestId('SidebarChannelNavigatorButton')).toHaveFocus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ describe('components/TeamGroupsManageModal', () => {
|
|||||||
const wrapper = renderWithContext(<TeamGroupsManageModal {...baseProps}/>);
|
const wrapper = renderWithContext(<TeamGroupsManageModal {...baseProps}/>);
|
||||||
expect(await wrapper.findByTestId('group-name')).toBeInTheDocument();
|
expect(await wrapper.findByTestId('group-name')).toBeInTheDocument();
|
||||||
userEvent.click(wrapper.getByTestId('menu-button'));
|
userEvent.click(wrapper.getByTestId('menu-button'));
|
||||||
|
|
||||||
userEvent.click(wrapper.getByTestId('remove-group-button'));
|
userEvent.click(wrapper.getByTestId('remove-group-button'));
|
||||||
expect(wrapper.getByTestId('confirm-modal')).toBeInTheDocument();
|
|
||||||
|
expect(await wrapper.findByTestId('confirm-modal')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call loadItems on render', async () => {
|
test('should call loadItems on render', async () => {
|
||||||
@@ -44,10 +46,12 @@ describe('components/TeamGroupsManageModal', () => {
|
|||||||
|
|
||||||
test('should hide confirm modal when cancel button is clicked', async () => {
|
test('should hide confirm modal when cancel button is clicked', async () => {
|
||||||
const wrapper = renderWithContext(<TeamGroupsManageModal {...baseProps}/>);
|
const wrapper = renderWithContext(<TeamGroupsManageModal {...baseProps}/>);
|
||||||
await wrapper.findByTestId('group-name');
|
expect(await wrapper.findByTestId('group-name')).toBeInTheDocument();
|
||||||
userEvent.click(wrapper.getByTestId('menu-button'));
|
userEvent.click(wrapper.getByTestId('menu-button'));
|
||||||
|
|
||||||
userEvent.click(wrapper.getByTestId('remove-group-button'));
|
userEvent.click(wrapper.getByTestId('remove-group-button'));
|
||||||
expect(wrapper.getByTestId('confirm-modal')).toBeInTheDocument();
|
expect(await wrapper.findByTestId('confirm-modal')).toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(wrapper.getByTestId('cancel-button'));
|
userEvent.click(wrapper.getByTestId('cancel-button'));
|
||||||
await waitForElementToBeRemoved(() => wrapper.queryByTestId('confirm-modal'));
|
await waitForElementToBeRemoved(() => wrapper.queryByTestId('confirm-modal'));
|
||||||
expect(wrapper.queryByTestId('confirm-modal')).toBeNull();
|
expect(wrapper.queryByTestId('confirm-modal')).toBeNull();
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
|
|||||||
bodyPadding={false}
|
bodyPadding={false}
|
||||||
modalHeaderText={headerTitle}
|
modalHeaderText={headerTitle}
|
||||||
modalLocation='top'
|
modalLocation='top'
|
||||||
|
delayFocusTrap={true}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={this.modalBodyRef}
|
ref={this.modalBodyRef}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
import React, {useState, useEffect, useCallback, useRef} from 'react';
|
||||||
import {Modal} from 'react-bootstrap';
|
import {Modal} from 'react-bootstrap';
|
||||||
import {FormattedMessage} from 'react-intl';
|
import {FormattedMessage} from 'react-intl';
|
||||||
|
|
||||||
|
import {useFocusTrap} from '../hooks/useFocusTrap';
|
||||||
import './generic_modal.scss';
|
import './generic_modal.scss';
|
||||||
|
|
||||||
export type ModalLocation = 'top' | 'center' | 'bottom';
|
export type ModalLocation = 'top' | 'center' | 'bottom';
|
||||||
@@ -28,7 +29,7 @@ export type Props = {
|
|||||||
cancelButtonClassName?: string;
|
cancelButtonClassName?: string;
|
||||||
isConfirmDisabled?: boolean;
|
isConfirmDisabled?: boolean;
|
||||||
isDeleteModal?: boolean;
|
isDeleteModal?: boolean;
|
||||||
id: string;
|
id?: string;
|
||||||
autoCloseOnCancelButton?: boolean;
|
autoCloseOnCancelButton?: boolean;
|
||||||
autoCloseOnConfirmButton?: boolean;
|
autoCloseOnConfirmButton?: boolean;
|
||||||
enforceFocus?: boolean;
|
enforceFocus?: boolean;
|
||||||
@@ -67,249 +68,277 @@ export type Props = {
|
|||||||
* in tests (Cypress, Playwright, etc.)
|
* in tests (Cypress, Playwright, etc.)
|
||||||
*/
|
*/
|
||||||
dataTestId?: string;
|
dataTestId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to delay activating the focus trap.
|
||||||
|
*
|
||||||
|
* This is useful for modals with dynamic content that might not be fully
|
||||||
|
* rendered when the modal is opened. The delay allows the DOM to settle
|
||||||
|
* before the focus trap identifies focusable elements. ie. MultiSelect
|
||||||
|
*
|
||||||
|
* When true, applies a 500ms delay.
|
||||||
|
*/
|
||||||
|
delayFocusTrap?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = {
|
export const GenericModal: React.FC<Props> = ({
|
||||||
show: boolean;
|
show = true,
|
||||||
isFocalTrapActive: boolean;
|
id = 'genericModal',
|
||||||
}
|
autoCloseOnCancelButton = true,
|
||||||
export class GenericModal extends React.PureComponent<Props, State> {
|
autoCloseOnConfirmButton = true,
|
||||||
static defaultProps: Partial<Props> = {
|
enforceFocus = true,
|
||||||
show: true,
|
keyboardEscape = true,
|
||||||
id: 'genericModal',
|
bodyPadding = true,
|
||||||
autoCloseOnCancelButton: true,
|
showCloseButton = true,
|
||||||
autoCloseOnConfirmButton: true,
|
showHeader = true,
|
||||||
enforceFocus: true,
|
modalLocation = 'center',
|
||||||
keyboardEscape: true,
|
className,
|
||||||
bodyPadding: true,
|
onExited,
|
||||||
showCloseButton: true,
|
onEntered,
|
||||||
showHeader: true,
|
onHide,
|
||||||
modalLocation: 'center',
|
modalHeaderText,
|
||||||
};
|
modalSubheaderText,
|
||||||
|
handleCancel,
|
||||||
|
handleConfirm,
|
||||||
|
handleEnterKeyPress,
|
||||||
|
handleKeydown,
|
||||||
|
confirmButtonText,
|
||||||
|
confirmButtonClassName,
|
||||||
|
cancelButtonText,
|
||||||
|
cancelButtonClassName,
|
||||||
|
isConfirmDisabled,
|
||||||
|
isDeleteModal,
|
||||||
|
container,
|
||||||
|
ariaLabel,
|
||||||
|
ariaLabelledby,
|
||||||
|
errorText,
|
||||||
|
compassDesign,
|
||||||
|
backdrop,
|
||||||
|
backdropClassName,
|
||||||
|
tabIndex,
|
||||||
|
children,
|
||||||
|
autoFocusConfirmButton,
|
||||||
|
headerInput,
|
||||||
|
bodyDivider,
|
||||||
|
bodyOverflowVisible,
|
||||||
|
footerContent,
|
||||||
|
footerDivider,
|
||||||
|
appendedContent,
|
||||||
|
headerButton,
|
||||||
|
dataTestId,
|
||||||
|
delayFocusTrap,
|
||||||
|
}) => {
|
||||||
|
// Create a ref for the modal container
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
constructor(props: Props) {
|
const [showState, setShowState] = useState(show);
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
// Use focus trap to keep focus within the modal when it's open
|
||||||
show: props.show!,
|
useFocusTrap(showState, containerRef, {
|
||||||
isFocalTrapActive: false,
|
delayMs: delayFocusTrap ? 500 : undefined,
|
||||||
};
|
});
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Props) {
|
useEffect(() => {
|
||||||
if (prevProps.show !== this.props.show) {
|
setShowState(show);
|
||||||
this.setState({show: Boolean(this.props.show)});
|
}, [show]);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onHide = () => {
|
const onHideCallback = useCallback(() => {
|
||||||
this.setState({show: false});
|
setShowState(false);
|
||||||
this.props.onHide?.();
|
onHide?.();
|
||||||
};
|
}, [onHide]);
|
||||||
|
|
||||||
handleCancel = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
const handleCancelCallback = useCallback((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.props.autoCloseOnCancelButton) {
|
if (autoCloseOnCancelButton) {
|
||||||
this.onHide();
|
onHideCallback();
|
||||||
}
|
}
|
||||||
if (this.props.handleCancel) {
|
handleCancel?.();
|
||||||
this.props.handleCancel();
|
}, [autoCloseOnCancelButton, onHideCallback, handleCancel]);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleConfirm = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
const handleConfirmCallback = useCallback((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.props.autoCloseOnConfirmButton) {
|
if (autoCloseOnConfirmButton) {
|
||||||
this.onHide();
|
onHideCallback();
|
||||||
}
|
}
|
||||||
if (this.props.handleConfirm) {
|
handleConfirm?.();
|
||||||
this.props.handleConfirm();
|
}, [autoCloseOnConfirmButton, onHideCallback, handleConfirm]);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onEnterKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
const onEnterKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
if (event.nativeEvent.isComposing) {
|
if (event.nativeEvent.isComposing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.props.handleConfirm && this.props.autoCloseOnConfirmButton) {
|
if (handleConfirm && autoCloseOnConfirmButton) {
|
||||||
this.onHide();
|
onHideCallback();
|
||||||
}
|
|
||||||
if (this.props.handleEnterKeyPress) {
|
|
||||||
this.props.handleEnterKeyPress();
|
|
||||||
}
|
}
|
||||||
|
handleEnterKeyPress?.();
|
||||||
}
|
}
|
||||||
this.props.handleKeydown?.(event);
|
handleKeydown?.(event);
|
||||||
};
|
}, [handleConfirm, autoCloseOnConfirmButton, onHideCallback, handleEnterKeyPress, handleKeydown]);
|
||||||
|
|
||||||
render() {
|
// Build confirm button if provided.
|
||||||
let confirmButton;
|
let confirmButtonElement;
|
||||||
if (this.props.handleConfirm) {
|
if (handleConfirm) {
|
||||||
const isConfirmOrDeleteClassName = this.props.isDeleteModal ? 'delete' : 'confirm';
|
const buttonTypeClass = isDeleteModal ? 'delete' : 'confirm';
|
||||||
let confirmButtonText: React.ReactNode = (
|
let confirmButtonTextContent: React.ReactNode = (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='generic_modal.confirm'
|
id='generic_modal.confirm'
|
||||||
defaultMessage='Confirm'
|
defaultMessage='Confirm'
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
if (this.props.confirmButtonText) {
|
|
||||||
confirmButtonText = this.props.confirmButtonText;
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmButton = (
|
|
||||||
<button
|
|
||||||
autoFocus={this.props.autoFocusConfirmButton}
|
|
||||||
type='submit'
|
|
||||||
className={classNames('GenericModal__button btn btn-primary', isConfirmOrDeleteClassName, this.props.confirmButtonClassName, {
|
|
||||||
disabled: this.props.isConfirmDisabled,
|
|
||||||
})}
|
|
||||||
onClick={this.handleConfirm}
|
|
||||||
disabled={this.props.isConfirmDisabled}
|
|
||||||
>
|
|
||||||
{confirmButtonText}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelButton;
|
|
||||||
if (this.props.handleCancel) {
|
|
||||||
let cancelButtonText: React.ReactNode = (
|
|
||||||
<FormattedMessage
|
|
||||||
id='generic_modal.cancel'
|
|
||||||
defaultMessage='Cancel'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
if (this.props.cancelButtonText) {
|
|
||||||
cancelButtonText = this.props.cancelButtonText;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelButton = (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className={classNames('GenericModal__button btn btn-tertiary', this.props.cancelButtonClassName)}
|
|
||||||
onClick={this.handleCancel}
|
|
||||||
>
|
|
||||||
{cancelButtonText}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerText = this.props.modalHeaderText && (
|
|
||||||
<div className='GenericModal__header'>
|
|
||||||
<h1
|
|
||||||
id='genericModalLabel'
|
|
||||||
className='modal-title'
|
|
||||||
>
|
|
||||||
{this.props.modalHeaderText}
|
|
||||||
</h1>
|
|
||||||
{this.props.headerButton}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
if (confirmButtonText) {
|
||||||
const locationClassMapping: Record<Required<Props>['modalLocation'], string> = {
|
confirmButtonTextContent = confirmButtonText;
|
||||||
top: 'GenericModal__location--top',
|
}
|
||||||
center: 'GenericModal__location--center',
|
confirmButtonElement = (
|
||||||
bottom: 'GenericModal__location--bottom',
|
<button
|
||||||
};
|
autoFocus={autoFocusConfirmButton}
|
||||||
|
type='submit'
|
||||||
const modalLocationClass = locationClassMapping[this.props.modalLocation ?? 'center'];
|
className={classNames('GenericModal__button btn btn-primary', buttonTypeClass, confirmButtonClassName, {
|
||||||
|
disabled: isConfirmDisabled,
|
||||||
// Accessibility labeling strategy:
|
})}
|
||||||
// 1. We always set aria-labelledby to ensure the modal has a proper label
|
onClick={handleConfirmCallback}
|
||||||
// - First try to use the provided ariaLabeledBy prop
|
disabled={isConfirmDisabled}
|
||||||
// - Fall back to 'genericModalLabel' which references the modal title
|
|
||||||
// 2. We also support aria-label as a secondary option
|
|
||||||
// - This will only be used by screen readers if the element referenced by aria-labelledby doesn't exist
|
|
||||||
// - This provides a fallback for accessibility in case the referenced element is missing
|
|
||||||
// Note: When both aria-labelledby and aria-label are present, aria-labelledby takes precedence
|
|
||||||
const ariaLabelledby = this.props.ariaLabelledby || 'genericModalLabel';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
id={this.props.id}
|
|
||||||
role='none'
|
|
||||||
aria-label={this.props.ariaLabel}
|
|
||||||
aria-labelledby={ariaLabelledby}
|
|
||||||
aria-modal='true'
|
|
||||||
dialogClassName={classNames(
|
|
||||||
modalLocationClass,
|
|
||||||
'a11y__modal GenericModal',
|
|
||||||
{
|
|
||||||
GenericModal__compassDesign: this.props.compassDesign,
|
|
||||||
'modal--overflow': this.props.bodyOverflowVisible,
|
|
||||||
},
|
|
||||||
this.props.className,
|
|
||||||
)}
|
|
||||||
show={this.state.show}
|
|
||||||
restoreFocus={true}
|
|
||||||
enforceFocus={this.props.enforceFocus}
|
|
||||||
onHide={this.onHide}
|
|
||||||
onExited={this.props.onExited}
|
|
||||||
backdrop={this.props.backdrop}
|
|
||||||
backdropClassName={this.props.backdropClassName}
|
|
||||||
container={this.props.container}
|
|
||||||
keyboard={this.props.keyboardEscape}
|
|
||||||
onEntered={this.props.onEntered}
|
|
||||||
data-testid={this.props.dataTestId}
|
|
||||||
>
|
>
|
||||||
<div
|
{confirmButtonTextContent}
|
||||||
onKeyDown={this.onEnterKeyDown}
|
</button>
|
||||||
tabIndex={this.props.tabIndex || 0}
|
|
||||||
className='GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher'
|
|
||||||
>
|
|
||||||
{this.props.showHeader && <Modal.Header closeButton={this.props.showCloseButton}>
|
|
||||||
<div
|
|
||||||
className='GenericModal__header__text_container'
|
|
||||||
>
|
|
||||||
{this.props.compassDesign && (
|
|
||||||
<>
|
|
||||||
{headerText}
|
|
||||||
{this.props.headerInput}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{
|
|
||||||
this.props.modalSubheaderText &&
|
|
||||||
<div className='modal-subheading-container'>
|
|
||||||
<div
|
|
||||||
id='genericModalSubheading'
|
|
||||||
className='modal-subheading'
|
|
||||||
>
|
|
||||||
{this.props.modalSubheaderText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</Modal.Header>}
|
|
||||||
<Modal.Body className={classNames({divider: this.props.bodyDivider, 'overflow-visible': this.props.bodyOverflowVisible})}>
|
|
||||||
{this.props.compassDesign ? (
|
|
||||||
this.props.errorText && (
|
|
||||||
<div className='genericModalError'>
|
|
||||||
<i className='icon icon-alert-outline'/>
|
|
||||||
<span>{this.props.errorText}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
headerText
|
|
||||||
)}
|
|
||||||
<div className={classNames('GenericModal__body', {padding: this.props.bodyPadding})}>
|
|
||||||
{this.props.children}
|
|
||||||
</div>
|
|
||||||
</Modal.Body>
|
|
||||||
{(cancelButton || confirmButton || this.props.footerContent) && (
|
|
||||||
<Modal.Footer className={classNames({divider: this.props.footerDivider})}>
|
|
||||||
{(cancelButton || confirmButton) ? (
|
|
||||||
<>
|
|
||||||
{cancelButton}
|
|
||||||
{confirmButton}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
this.props.footerContent
|
|
||||||
)}
|
|
||||||
</Modal.Footer>
|
|
||||||
)}
|
|
||||||
{Boolean(this.props.appendedContent) && this.props.appendedContent}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Build cancel button if provided.
|
||||||
|
let cancelButtonElement;
|
||||||
|
if (handleCancel) {
|
||||||
|
let cancelButtonTextContent: React.ReactNode = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='generic_modal.cancel'
|
||||||
|
defaultMessage='Cancel'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (cancelButtonText) {
|
||||||
|
cancelButtonTextContent = cancelButtonText;
|
||||||
|
}
|
||||||
|
cancelButtonElement = (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={classNames('GenericModal__button btn btn-tertiary', cancelButtonClassName)}
|
||||||
|
onClick={handleCancelCallback}
|
||||||
|
>
|
||||||
|
{cancelButtonTextContent}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build header text if provided.
|
||||||
|
const headerText = modalHeaderText && (
|
||||||
|
<div className='GenericModal__header'>
|
||||||
|
<h1 id='genericModalLabel' className='modal-title'>
|
||||||
|
{modalHeaderText}
|
||||||
|
</h1>
|
||||||
|
{headerButton}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map modalLocation to a CSS class.
|
||||||
|
const locationClassMapping: Record<ModalLocation, string> = {
|
||||||
|
top: 'GenericModal__location--top',
|
||||||
|
center: 'GenericModal__location--center',
|
||||||
|
bottom: 'GenericModal__location--bottom',
|
||||||
|
};
|
||||||
|
const modalLocationClass = locationClassMapping[modalLocation];
|
||||||
|
|
||||||
|
// Accessibility labeling strategy:
|
||||||
|
// 1. We always set aria-labelledby to ensure the modal has a proper label
|
||||||
|
// - First try to use the provided ariaLabeledBy prop
|
||||||
|
// - Fall back to 'genericModalLabel' which references the modal title
|
||||||
|
// 2. We also support aria-label as a secondary option
|
||||||
|
// - This will only be used by screen readers if the element referenced by aria-labelledby doesn't exist
|
||||||
|
// - This provides a fallback for accessibility in case the referenced element is missing
|
||||||
|
// Note: When both aria-labelledby and aria-label are present, aria-labelledby takes precedence
|
||||||
|
const ariaLabelledbyValue = ariaLabelledby || 'genericModalLabel';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
id={id}
|
||||||
|
role='none'
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabelledbyValue}
|
||||||
|
aria-modal='true'
|
||||||
|
dialogClassName={classNames(
|
||||||
|
modalLocationClass,
|
||||||
|
'a11y__modal GenericModal',
|
||||||
|
{
|
||||||
|
GenericModal__compassDesign: compassDesign,
|
||||||
|
'modal--overflow': bodyOverflowVisible,
|
||||||
|
},
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
show={showState}
|
||||||
|
restoreFocus={true}
|
||||||
|
enforceFocus={enforceFocus}
|
||||||
|
onHide={onHideCallback}
|
||||||
|
onExited={onExited}
|
||||||
|
backdrop={backdrop}
|
||||||
|
backdropClassName={backdropClassName}
|
||||||
|
container={container}
|
||||||
|
keyboard={keyboardEscape}
|
||||||
|
onEntered={onEntered}
|
||||||
|
data-testid={dataTestId}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onKeyDown={onEnterKeyDown}
|
||||||
|
tabIndex={tabIndex || 0}
|
||||||
|
className='GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher'
|
||||||
|
>
|
||||||
|
{showHeader && (
|
||||||
|
<Modal.Header closeButton={showCloseButton}>
|
||||||
|
<div className='GenericModal__header__text_container'>
|
||||||
|
{compassDesign && (
|
||||||
|
<>
|
||||||
|
{headerText}
|
||||||
|
{headerInput}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{modalSubheaderText && (
|
||||||
|
<div className='modal-subheading-container'>
|
||||||
|
<div id='genericModalSubheading' className='modal-subheading'>
|
||||||
|
{modalSubheaderText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
)}
|
||||||
|
<Modal.Body className={classNames({divider: bodyDivider, 'overflow-visible': bodyOverflowVisible})}>
|
||||||
|
{compassDesign ? (
|
||||||
|
errorText && (
|
||||||
|
<div className='genericModalError'>
|
||||||
|
<i className='icon icon-alert-outline'/>
|
||||||
|
<span>{errorText}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
headerText
|
||||||
|
)}
|
||||||
|
<div className={classNames('GenericModal__body', {padding: bodyPadding})}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
{(cancelButtonElement || confirmButtonElement || footerContent) && (
|
||||||
|
<Modal.Footer className={classNames({divider: footerDivider})}>
|
||||||
|
{(cancelButtonElement || confirmButtonElement) ? (
|
||||||
|
<>
|
||||||
|
{cancelButtonElement}
|
||||||
|
{confirmButtonElement}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
footerContent
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
)}
|
||||||
|
{Boolean(appendedContent) && appendedContent}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenericModal;
|
||||||
|
|||||||
259
webapp/platform/components/src/hooks/useFocusTrap.test.tsx
Обычный файл
259
webapp/platform/components/src/hooks/useFocusTrap.test.tsx
Обычный файл
@@ -0,0 +1,259 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {render, screen} from '@testing-library/react';
|
||||||
|
import React, {useRef} from 'react';
|
||||||
|
|
||||||
|
import {useFocusTrap} from './useFocusTrap';
|
||||||
|
|
||||||
|
// Test component that uses the hook
|
||||||
|
function FocusTrapTestComponent({
|
||||||
|
isActive = true,
|
||||||
|
initialFocus = false,
|
||||||
|
restoreFocus = false,
|
||||||
|
delayMs = 0,
|
||||||
|
}: {
|
||||||
|
isActive?: boolean;
|
||||||
|
initialFocus?: boolean;
|
||||||
|
restoreFocus?: boolean;
|
||||||
|
delayMs?: number;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useFocusTrap(isActive, containerRef, {
|
||||||
|
initialFocus,
|
||||||
|
restoreFocus,
|
||||||
|
delayMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} data-testid='container'>
|
||||||
|
<button data-testid='button1'>Button 1</button>
|
||||||
|
<button data-testid='button2'>Button 2</button>
|
||||||
|
<button data-testid='button3'>Button 3</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test component with nested focus traps
|
||||||
|
function NestedFocusTrapsComponent() {
|
||||||
|
const outerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const innerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useFocusTrap(true, outerRef);
|
||||||
|
useFocusTrap(true, innerRef);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={outerRef} data-testid='outer-container'>
|
||||||
|
<button data-testid='outer-button1'>Outer Button 1</button>
|
||||||
|
<div ref={innerRef} data-testid='inner-container'>
|
||||||
|
<button data-testid='inner-button1'>Inner Button 1</button>
|
||||||
|
<button data-testid='inner-button2'>Inner Button 2</button>
|
||||||
|
</div>
|
||||||
|
<button data-testid='outer-button2'>Outer Button 2</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useFocusTrap', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create a div to hold our rendered components
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'root';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
// Create an element outside the focus trap for testing restoreFocus
|
||||||
|
const outsideButton = document.createElement('button');
|
||||||
|
outsideButton.setAttribute('data-testid', 'outside-button');
|
||||||
|
outsideButton.textContent = 'Outside Button';
|
||||||
|
document.body.appendChild(outsideButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to simulate Tab key press
|
||||||
|
const simulateTabKey = (shiftKey = false) => {
|
||||||
|
const tabEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Tab',
|
||||||
|
code: 'Tab',
|
||||||
|
shiftKey,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(tabEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to simulate tab navigation with focus trap
|
||||||
|
const simulateTabWithFocusTrap = (container: HTMLElement, shiftKey = false) => {
|
||||||
|
const focusableElements = Array.from(
|
||||||
|
container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
|
||||||
|
) as HTMLElement[];
|
||||||
|
|
||||||
|
if (focusableElements.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstElement = focusableElements[0];
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1];
|
||||||
|
const currentElement = document.activeElement as HTMLElement;
|
||||||
|
|
||||||
|
// Find the current element index
|
||||||
|
const currentIndex = focusableElements.indexOf(currentElement);
|
||||||
|
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
// If not found, focus the first element
|
||||||
|
firstElement.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shiftKey) {
|
||||||
|
// Backward navigation
|
||||||
|
if (currentElement === firstElement) {
|
||||||
|
lastElement.focus();
|
||||||
|
} else {
|
||||||
|
const prevIndex = ((currentIndex - 1) + focusableElements.length) % focusableElements.length;
|
||||||
|
focusableElements[prevIndex].focus();
|
||||||
|
}
|
||||||
|
} else if (currentElement === lastElement) {
|
||||||
|
// Forward navigation - if at last element, go to first
|
||||||
|
firstElement.focus();
|
||||||
|
} else {
|
||||||
|
// Forward navigation - go to next element
|
||||||
|
const nextIndex = (currentIndex + 1) % focusableElements.length;
|
||||||
|
focusableElements[nextIndex].focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('should trap focus within the container', async () => {
|
||||||
|
const {container} = render(<FocusTrapTestComponent />);
|
||||||
|
|
||||||
|
// Focus the first button
|
||||||
|
const button1 = screen.getByTestId('button1');
|
||||||
|
button1.focus();
|
||||||
|
expect(document.activeElement).toBe(button1);
|
||||||
|
|
||||||
|
// Tab to the next button
|
||||||
|
simulateTabWithFocusTrap(container);
|
||||||
|
expect(document.activeElement).toBe(screen.getByTestId('button2'));
|
||||||
|
|
||||||
|
// Tab to the last button
|
||||||
|
simulateTabWithFocusTrap(container);
|
||||||
|
expect(document.activeElement).toBe(screen.getByTestId('button3'));
|
||||||
|
|
||||||
|
// Tab again should cycle back to the first button
|
||||||
|
simulateTabWithFocusTrap(container);
|
||||||
|
expect(document.activeElement).toBe(button1);
|
||||||
|
|
||||||
|
// Shift+Tab should go to the last button
|
||||||
|
simulateTabWithFocusTrap(container, true);
|
||||||
|
expect(document.activeElement).toBe(screen.getByTestId('button3'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set initial focus when initialFocus is true', () => {
|
||||||
|
render(<FocusTrapTestComponent initialFocus={true} />);
|
||||||
|
|
||||||
|
// The first focusable element should be focused automatically
|
||||||
|
// We need to wait for the focus to be set
|
||||||
|
setTimeout(() => {
|
||||||
|
expect(document.activeElement).toBe(screen.getByTestId('button1'));
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should restore focus when restoreFocus is true', () => {
|
||||||
|
// Focus the outside button first
|
||||||
|
const outsideButton = screen.getByTestId('outside-button');
|
||||||
|
outsideButton.focus();
|
||||||
|
expect(document.activeElement).toBe(outsideButton);
|
||||||
|
|
||||||
|
// Render the component with restoreFocus=true
|
||||||
|
const {unmount} = render(<FocusTrapTestComponent restoreFocus={true} />);
|
||||||
|
|
||||||
|
// Unmount the component
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// Focus should be restored to the outside button
|
||||||
|
expect(document.activeElement).toBe(outsideButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle delay option', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
const {container} = render(<FocusTrapTestComponent delayMs={500} />);
|
||||||
|
|
||||||
|
// Focus the first button
|
||||||
|
const button1 = screen.getByTestId('button1');
|
||||||
|
button1.focus();
|
||||||
|
expect(document.activeElement).toBe(button1);
|
||||||
|
|
||||||
|
// Tab to the next button - should not be trapped yet due to delay
|
||||||
|
// We'll use simulateTabKey here to simulate what happens without the trap
|
||||||
|
simulateTabKey();
|
||||||
|
|
||||||
|
// Advance timers
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
|
||||||
|
// Now focus the first button again and try tabbing
|
||||||
|
button1.focus();
|
||||||
|
simulateTabWithFocusTrap(container);
|
||||||
|
|
||||||
|
// Now the focus trap should be active
|
||||||
|
expect(document.activeElement).toBe(screen.getByTestId('button2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not activate when isActive is false', () => {
|
||||||
|
render(<FocusTrapTestComponent isActive={false} />);
|
||||||
|
|
||||||
|
// Focus the first button
|
||||||
|
const button1 = screen.getByTestId('button1');
|
||||||
|
button1.focus();
|
||||||
|
expect(document.activeElement).toBe(button1);
|
||||||
|
|
||||||
|
// Tab to the next button - should not be trapped
|
||||||
|
// We'll use simulateTabKey here to simulate what happens without the trap
|
||||||
|
simulateTabKey();
|
||||||
|
|
||||||
|
// Focus should not be trapped within the container
|
||||||
|
expect(document.activeElement).not.toBe(screen.getByTestId('button2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle nested focus traps', () => {
|
||||||
|
render(<NestedFocusTrapsComponent />);
|
||||||
|
|
||||||
|
// Focus the first inner button
|
||||||
|
const innerButton1 = screen.getByTestId('inner-button1');
|
||||||
|
innerButton1.focus();
|
||||||
|
expect(document.activeElement).toBe(innerButton1);
|
||||||
|
|
||||||
|
// Find the inner container
|
||||||
|
const innerContainer = screen.getByTestId('inner-container');
|
||||||
|
|
||||||
|
// Tab to the next button in the inner trap
|
||||||
|
simulateTabWithFocusTrap(innerContainer);
|
||||||
|
expect(document.activeElement).toBe(screen.getByTestId('inner-button2'));
|
||||||
|
|
||||||
|
// Tab again should cycle back to the first inner button
|
||||||
|
simulateTabWithFocusTrap(innerContainer);
|
||||||
|
expect(document.activeElement).toBe(innerButton1);
|
||||||
|
|
||||||
|
// The outer trap should not interfere with the inner trap
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty containers gracefully', () => {
|
||||||
|
// Create a component with no focusable elements
|
||||||
|
function EmptyComponent() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
useFocusTrap(true, containerRef);
|
||||||
|
return <div ref={containerRef} data-testid='empty-container'></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<EmptyComponent />);
|
||||||
|
|
||||||
|
// No errors should be thrown
|
||||||
|
const container = screen.getByTestId('empty-container');
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
211
webapp/platform/components/src/hooks/useFocusTrap.ts
Обычный файл
211
webapp/platform/components/src/hooks/useFocusTrap.ts
Обычный файл
@@ -0,0 +1,211 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {useEffect, useRef} from 'react';
|
||||||
|
|
||||||
|
// A global stack to hold active focus trap containers.
|
||||||
|
// This ensures that only the topmost trap processes Tab events.
|
||||||
|
const activeFocusTraps: HTMLElement[] = [];
|
||||||
|
|
||||||
|
type FocusTrapOptions = {
|
||||||
|
initialFocus?: boolean;
|
||||||
|
restoreFocus?: boolean;
|
||||||
|
delayMs?: number; // Delay in milliseconds before activating the focus trap
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook that traps focus within a container element.
|
||||||
|
* When multiple focus traps are active, only the topmost one will process Tab key events.
|
||||||
|
* @param isActive Whether the focus trap is active
|
||||||
|
* @param containerRef A ref to the container element
|
||||||
|
* @param options FocusTrapOptions Options for the focus trap
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
export function useFocusTrap(
|
||||||
|
isActive: boolean,
|
||||||
|
containerRef: React.RefObject<HTMLElement>,
|
||||||
|
options: FocusTrapOptions = {initialFocus: false, restoreFocus: false},
|
||||||
|
): void {
|
||||||
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Add a ref to store the cached focusable elements
|
||||||
|
const focusableElementsRef = useRef<HTMLElement[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!isActive || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the previously focused element for restoration if needed
|
||||||
|
if (options.restoreFocus) {
|
||||||
|
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
let trapActive = false;
|
||||||
|
|
||||||
|
// Function to cache focusable elements and activate the trap
|
||||||
|
const activateFocusTrap = () => {
|
||||||
|
// Cache the focusable elements
|
||||||
|
focusableElementsRef.current = getFocusableElements(container);
|
||||||
|
|
||||||
|
// Register this focus trap (push it onto the global stack)
|
||||||
|
activeFocusTraps.push(container);
|
||||||
|
trapActive = true;
|
||||||
|
|
||||||
|
if (focusableElementsRef.current.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial focus if needed
|
||||||
|
if (options.initialFocus && focusableElementsRef.current.length > 0) {
|
||||||
|
focusableElementsRef.current[0].focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to refresh the cached elements if needed
|
||||||
|
const refreshFocusableElements = () => {
|
||||||
|
focusableElementsRef.current = getFocusableElements(container);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delay the activation if delayMs is specified
|
||||||
|
if (options.delayMs && options.delayMs > 0) {
|
||||||
|
timeoutId = setTimeout(activateFocusTrap, options.delayMs);
|
||||||
|
} else {
|
||||||
|
// Activate immediately if no delay
|
||||||
|
activateFocusTrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tab key navigation - only trap Tab key, let other keys propagate
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Only handle Tab key for focus trapping
|
||||||
|
if (e.key !== 'Tab') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process if this container is the top-most active focus trap
|
||||||
|
// AND if the focus trap has been activated (after delay)
|
||||||
|
if (!trapActive || activeFocusTraps[activeFocusTraps.length - 1] !== container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the cached focusable elements
|
||||||
|
const elements = focusableElementsRef.current;
|
||||||
|
if (elements.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstElement = elements[0];
|
||||||
|
const lastElement = elements[elements.length - 1];
|
||||||
|
|
||||||
|
// If shift+tab on first element, move to last element
|
||||||
|
if (e.shiftKey && document.activeElement === firstElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastElement.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === lastElement) { // If tab on last element, move to first element
|
||||||
|
e.preventDefault();
|
||||||
|
firstElement.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up a MutationObserver to detect DOM changes that might affect focusable elements
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
// Only refresh if the trap is active
|
||||||
|
if (trapActive) {
|
||||||
|
refreshFocusableElements();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing the container for changes that might affect focusability
|
||||||
|
observer.observe(container, {
|
||||||
|
childList: true, // Watch for changes to child elements
|
||||||
|
subtree: true, // Watch the entire subtree
|
||||||
|
attributes: true, // Watch for attribute changes
|
||||||
|
attributeFilter: ['tabindex', 'disabled'], // Only care about attributes that affect focusability
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
// eslint-disable-next-line consistent-return
|
||||||
|
return () => {
|
||||||
|
// Clear the timeout if component unmounts during delay
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the observer
|
||||||
|
observer.disconnect();
|
||||||
|
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// Only remove from stack if it was actually added
|
||||||
|
if (trapActive) {
|
||||||
|
const index = activeFocusTraps.indexOf(container);
|
||||||
|
if (index > -1) {
|
||||||
|
activeFocusTraps.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore focus when trap is deactivated
|
||||||
|
if (options.restoreFocus && previousFocusRef.current) {
|
||||||
|
previousFocusRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isActive, containerRef, options.initialFocus, options.restoreFocus, options.delayMs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get all focusable elements within a container
|
||||||
|
* @param container The container element
|
||||||
|
* @returns An array of focusable elements
|
||||||
|
*/
|
||||||
|
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||||
|
const selector = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const elements = Array.from(container.querySelectorAll(selector)) as HTMLElement[];
|
||||||
|
|
||||||
|
// Filter out hidden elements
|
||||||
|
return elements.filter((element) => isElementVisible(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an element is visible in the DOM
|
||||||
|
* @param element The element to check
|
||||||
|
* @returns true if the element is visible, false otherwise
|
||||||
|
*/
|
||||||
|
function isElementVisible(element: HTMLElement): boolean {
|
||||||
|
// Check if the element has zero dimensions
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 && rect.height === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check computed styles for this element and its ancestors
|
||||||
|
let currentElement: HTMLElement | null = element;
|
||||||
|
while (currentElement) {
|
||||||
|
const style = window.getComputedStyle(currentElement);
|
||||||
|
|
||||||
|
// Check common ways elements can be hidden
|
||||||
|
if (
|
||||||
|
style.display === 'none' ||
|
||||||
|
style.visibility === 'hidden' ||
|
||||||
|
style.opacity === '0' ||
|
||||||
|
currentElement.hasAttribute('hidden')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentElement = currentElement.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Ссылка в новой задаче
Block a user