From fb24ddb27a769014b22ab8586ddb90b2a6fc6f82 Mon Sep 17 00:00:00 2001 From: Fahim Faisal Date: Thu, 23 Jan 2025 11:42:15 +0600 Subject: [PATCH 1/5] Add FocusTrap component and integrate it into modal components for improved accessibility --- .../ai-course-modal/BasicPrompt.tsx | 1 + .../modals/AICourseBuilderModal.tsx | 11 ++- .../react/v3/shared/components/AutoFocus.tsx | 5 + .../react/v3/shared/components/FocusTrap.tsx | 53 +++++++++++ .../components/modals/BasicModalWrapper.tsx | 78 ++++++++-------- .../shared/components/modals/ModalWrapper.tsx | 91 ++++++++++--------- assets/react/v3/shared/molecules/Popover.tsx | 29 +++--- 7 files changed, 170 insertions(+), 98 deletions(-) create mode 100644 assets/react/v3/shared/components/AutoFocus.tsx create mode 100644 assets/react/v3/shared/components/FocusTrap.tsx diff --git a/assets/react/v3/entries/course-builder/components/ai-course-modal/BasicPrompt.tsx b/assets/react/v3/entries/course-builder/components/ai-course-modal/BasicPrompt.tsx index 43b76dd908..02e3b65842 100644 --- a/assets/react/v3/entries/course-builder/components/ai-course-modal/BasicPrompt.tsx +++ b/assets/react/v3/entries/course-builder/components/ai-course-modal/BasicPrompt.tsx @@ -77,6 +77,7 @@ const BasicPrompt = ({ onClose }: BasicPromptProps) => { }; export default BasicPrompt; + const styles = { container: css` position: absolute; diff --git a/assets/react/v3/entries/course-builder/components/modals/AICourseBuilderModal.tsx b/assets/react/v3/entries/course-builder/components/modals/AICourseBuilderModal.tsx index 7072e9af38..fdb47bc3b7 100644 --- a/assets/react/v3/entries/course-builder/components/modals/AICourseBuilderModal.tsx +++ b/assets/react/v3/entries/course-builder/components/modals/AICourseBuilderModal.tsx @@ -1,10 +1,11 @@ -import type { ModalProps } from '@TutorShared/components/modals/Modal'; import BasicPrompt from '@CourseBuilderComponents/ai-course-modal/BasicPrompt'; import ContentGeneration from '@CourseBuilderComponents/ai-course-modal/ContentGeneration'; import ContentGenerationContextProvider, { useContentGenerationContext, } from '@CourseBuilderComponents/ai-course-modal/ContentGenerationContext'; import { css } from '@emotion/react'; +import FocusTrap from '@TutorShared/components/FocusTrap'; +import type { ModalProps } from '@TutorShared/components/modals/Modal'; import { useEffect } from 'react'; type AICourseBuilderModalProps = ModalProps; @@ -29,9 +30,11 @@ const AICourseBuilderModal = ({ closeModal }: AICourseBuilderModalProps) => { return ( -
- -
+ +
+ +
+
); }; diff --git a/assets/react/v3/shared/components/AutoFocus.tsx b/assets/react/v3/shared/components/AutoFocus.tsx new file mode 100644 index 0000000000..41ddaa2271 --- /dev/null +++ b/assets/react/v3/shared/components/AutoFocus.tsx @@ -0,0 +1,5 @@ +const AutoFocus = () => { + return
AutoFocus
; +}; + +export default AutoFocus; diff --git a/assets/react/v3/shared/components/FocusTrap.tsx b/assets/react/v3/shared/components/FocusTrap.tsx new file mode 100644 index 0000000000..2929ecd5ab --- /dev/null +++ b/assets/react/v3/shared/components/FocusTrap.tsx @@ -0,0 +1,53 @@ +import { Children, cloneElement, useEffect, useRef, type ReactNode } from 'react'; + +interface FocusTrapProps { + children: ReactNode; +} + +const FocusTrap = ({ children }: FocusTrapProps) => { + const containerRef = useRef(null); + + useEffect(() => { + const allFocusable = () => + containerRef.current?.querySelectorAll( + 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])', + ) || []; + + const focusableElements = () => Array.from(allFocusable()).filter((el) => !el.hasAttribute('disabled')); + + const handleTab = (event: KeyboardEvent) => { + const elements = Array.from(focusableElements()); + const firstElement = elements[0]; + const lastElement = elements[elements.length - 1]; + + if (event.key === 'Tab' && elements.length) { + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement?.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement?.focus(); + } + } + }; + + const handleFocusOut = (event: FocusEvent) => { + const elements = focusableElements(); + if (!containerRef.current?.contains(event.target as Node)) { + elements[0]?.focus(); + } + }; + + document.addEventListener('keydown', handleTab); + document.addEventListener('focusin', handleFocusOut); + + return () => { + document.removeEventListener('keydown', handleTab); + document.removeEventListener('focusin', handleFocusOut); + }; + }, []); + + return cloneElement(Children.only(children) as React.ReactElement, { ref: containerRef }); +}; + +export default FocusTrap; diff --git a/assets/react/v3/shared/components/modals/BasicModalWrapper.tsx b/assets/react/v3/shared/components/modals/BasicModalWrapper.tsx index 7d051b605f..0f39b1c687 100644 --- a/assets/react/v3/shared/components/modals/BasicModalWrapper.tsx +++ b/assets/react/v3/shared/components/modals/BasicModalWrapper.tsx @@ -1,12 +1,14 @@ +import { type SerializedStyles, css } from '@emotion/react'; +import type React from 'react'; +import { useEffect } from 'react'; + import SVGIcon from '@TutorShared/atoms/SVGIcon'; +import FocusTrap from '@TutorShared/components/FocusTrap'; import { modal } from '@TutorShared/config/constants'; import { Breakpoint, borderRadius, colorTokens, shadow, spacing } from '@TutorShared/config/styles'; import { typography } from '@TutorShared/config/typography'; import Show from '@TutorShared/controls/Show'; import { styleUtils } from '@TutorShared/utils/style-utils'; -import { type SerializedStyles, css } from '@emotion/react'; -import type React from 'react'; -import { useEffect } from 'react'; interface BasicModalWrapperProps { children: React.ReactNode; @@ -42,49 +44,51 @@ const BasicModalWrapper = ({ }, []); return ( -
+
- -
-
- {icon} - -

{title}

-
-
- - {subtitle} - -
-
- - - - } - > - {actions} + +
+
+ {icon} + +

{title}

+
+
+ + {subtitle} + +
+
+ + + + } + > + {actions} + +
+
{children}
-
{children}
-
+ ); }; diff --git a/assets/react/v3/shared/components/modals/ModalWrapper.tsx b/assets/react/v3/shared/components/modals/ModalWrapper.tsx index 55fc40f297..a0fa540a32 100644 --- a/assets/react/v3/shared/components/modals/ModalWrapper.tsx +++ b/assets/react/v3/shared/components/modals/ModalWrapper.tsx @@ -7,6 +7,7 @@ import { styleUtils } from '@TutorShared/utils/style-utils'; import { css } from '@emotion/react'; import type React from 'react'; import { useEffect } from 'react'; +import FocusTrap from '../FocusTrap'; interface ModalWrapperProps { children: React.ReactNode; @@ -40,56 +41,58 @@ const ModalWrapper = ({ }, []); return ( -
+
- -
-
- {icon} - -
- {title} -
+
+ +
+
+ {icon} + +
+ {title} +
+
+
+ + {subtitle}
- - {subtitle} - -
-
- {headerChildren} -
-
- - - - } - > - {actions} - -
- - } - > - {entireHeader} -
+
+ {headerChildren} +
+
+ + + + } + > + {actions} + +
+ + } + > + {entireHeader} + +
+
{children}
-
{children}
-
+
); }; diff --git a/assets/react/v3/shared/molecules/Popover.tsx b/assets/react/v3/shared/molecules/Popover.tsx index 54d6bb9c9a..a03a3ca063 100644 --- a/assets/react/v3/shared/molecules/Popover.tsx +++ b/assets/react/v3/shared/molecules/Popover.tsx @@ -1,3 +1,4 @@ +import FocusTrap from '@TutorShared/components/FocusTrap'; import { isRTL } from '@TutorShared/config/constants'; import { borderRadius, colorTokens, shadow, spacing, zIndex } from '@TutorShared/config/styles'; import { AnimationType } from '@TutorShared/hooks/useAnimation'; @@ -45,19 +46,21 @@ const Popover = ({ animationType={animationType} onEscape={closeOnEscape ? closePopover : undefined} > -
-
{children}
-
+ +
+
{children}
+
+
); }; From 985bf4faef5e18b32f251e4029e6ab5f5304e2e5 Mon Sep 17 00:00:00 2001 From: Fahim Faisal Date: Thu, 23 Jan 2025 15:31:39 +0600 Subject: [PATCH 2/5] Refactor FocusTrap component and integrate it into Popover and Portal for enhanced accessibility --- .../react/v3/shared/components/AutoFocus.tsx | 5 -- .../react/v3/shared/components/FocusTrap.tsx | 86 +++++++++++-------- .../v3/shared/hooks/usePortalPopover.tsx | 25 +++--- assets/react/v3/shared/molecules/Popover.tsx | 29 +++---- 4 files changed, 77 insertions(+), 68 deletions(-) delete mode 100644 assets/react/v3/shared/components/AutoFocus.tsx diff --git a/assets/react/v3/shared/components/AutoFocus.tsx b/assets/react/v3/shared/components/AutoFocus.tsx deleted file mode 100644 index 41ddaa2271..0000000000 --- a/assets/react/v3/shared/components/AutoFocus.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const AutoFocus = () => { - return
AutoFocus
; -}; - -export default AutoFocus; diff --git a/assets/react/v3/shared/components/FocusTrap.tsx b/assets/react/v3/shared/components/FocusTrap.tsx index 2929ecd5ab..284097950a 100644 --- a/assets/react/v3/shared/components/FocusTrap.tsx +++ b/assets/react/v3/shared/components/FocusTrap.tsx @@ -1,53 +1,67 @@ -import { Children, cloneElement, useEffect, useRef, type ReactNode } from 'react'; +import { Children, cloneElement, useEffect, useRef, type ReactElement, type ReactNode } from 'react'; -interface FocusTrapProps { - children: ReactNode; -} - -const FocusTrap = ({ children }: FocusTrapProps) => { +const FocusTrap = ({ children }: { children: ReactNode }) => { const containerRef = useRef(null); useEffect(() => { - const allFocusable = () => - containerRef.current?.querySelectorAll( - 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])', - ) || []; - - const focusableElements = () => Array.from(allFocusable()).filter((el) => !el.hasAttribute('disabled')); - - const handleTab = (event: KeyboardEvent) => { - const elements = Array.from(focusableElements()); - const firstElement = elements[0]; - const lastElement = elements[elements.length - 1]; - - if (event.key === 'Tab' && elements.length) { - if (event.shiftKey && document.activeElement === firstElement) { - event.preventDefault(); - lastElement?.focus(); - } else if (!event.shiftKey && document.activeElement === lastElement) { - event.preventDefault(); - firstElement?.focus(); - } - } + const container = containerRef.current; + if (!container) { + return; + } + + const getFocusableElements = () => { + const focusableSelectors = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'; + return Array.from(container.querySelectorAll(focusableSelectors)).filter( + (el) => !el.hasAttribute('disabled') && !(el as HTMLElement).hidden, + ) as HTMLElement[]; }; - const handleFocusOut = (event: FocusEvent) => { - const elements = focusableElements(); - if (!containerRef.current?.contains(event.target as Node)) { - elements[0]?.focus(); + const handleKeyDown = (event: KeyboardEvent) => { + // Check if this is the topmost trap + const allTraps = document.querySelectorAll('[data-focus-trap="true"]'); + const isTopTrap = allTraps.length > 0 && Array.from(allTraps)[allTraps.length - 1] === container; + + if (!isTopTrap || event.key !== 'Tab') { + return; + } + + const focusableElements = getFocusableElements(); + if (focusableElements.length === 0) { + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + const activeElement = document.activeElement; + + if (!container.contains(activeElement)) { + event.preventDefault(); + firstElement.focus(); + return; + } + + if (event.shiftKey && activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); } }; - document.addEventListener('keydown', handleTab); - document.addEventListener('focusin', handleFocusOut); + document.addEventListener('keydown', handleKeyDown, true); return () => { - document.removeEventListener('keydown', handleTab); - document.removeEventListener('focusin', handleFocusOut); + document.removeEventListener('keydown', handleKeyDown, true); }; }, []); - return cloneElement(Children.only(children) as React.ReactElement, { ref: containerRef }); + return cloneElement(Children.only(children) as ReactElement, { + ref: containerRef, + 'data-focus-trap': 'true', + tabIndex: -1, + }); }; export default FocusTrap; diff --git a/assets/react/v3/shared/hooks/usePortalPopover.tsx b/assets/react/v3/shared/hooks/usePortalPopover.tsx index ecddbd6a2d..62da30ae95 100644 --- a/assets/react/v3/shared/hooks/usePortalPopover.tsx +++ b/assets/react/v3/shared/hooks/usePortalPopover.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/react'; import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import FocusTrap from '@TutorShared/components/FocusTrap'; import { useModal } from '@TutorShared/components/modals/Modal'; import { zIndex } from '@TutorShared/config/styles'; import { AnimatedDiv, AnimationType, useAnimation } from '@TutorShared/hooks/useAnimation'; @@ -223,17 +224,19 @@ export const Portal = ({ if (openState) { return createPortal( -
-
{ - event.stopPropagation(); - onClickOutside?.(); - }} - /> - {children} -
+ +
+
{ + event.stopPropagation(); + onClickOutside?.(); + }} + /> + {children} +
+ , document.body, ); diff --git a/assets/react/v3/shared/molecules/Popover.tsx b/assets/react/v3/shared/molecules/Popover.tsx index a03a3ca063..54d6bb9c9a 100644 --- a/assets/react/v3/shared/molecules/Popover.tsx +++ b/assets/react/v3/shared/molecules/Popover.tsx @@ -1,4 +1,3 @@ -import FocusTrap from '@TutorShared/components/FocusTrap'; import { isRTL } from '@TutorShared/config/constants'; import { borderRadius, colorTokens, shadow, spacing, zIndex } from '@TutorShared/config/styles'; import { AnimationType } from '@TutorShared/hooks/useAnimation'; @@ -46,21 +45,19 @@ const Popover = ({ animationType={animationType} onEscape={closeOnEscape ? closePopover : undefined} > - -
-
{children}
-
-
+
+
{children}
+
); }; From 54330a1bb5e5914fb16e8b41b65e6b93536b41a4 Mon Sep 17 00:00:00 2001 From: Fahim Faisal Date: Thu, 23 Jan 2025 19:11:28 +0600 Subject: [PATCH 3/5] Refactor FocusTrap component for improved readability and clarity in key event handling --- assets/react/v3/shared/components/FocusTrap.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/assets/react/v3/shared/components/FocusTrap.tsx b/assets/react/v3/shared/components/FocusTrap.tsx index 284097950a..155cb1a760 100644 --- a/assets/react/v3/shared/components/FocusTrap.tsx +++ b/assets/react/v3/shared/components/FocusTrap.tsx @@ -12,16 +12,17 @@ const FocusTrap = ({ children }: { children: ReactNode }) => { const getFocusableElements = () => { const focusableSelectors = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'; return Array.from(container.querySelectorAll(focusableSelectors)).filter( - (el) => !el.hasAttribute('disabled') && !(el as HTMLElement).hidden, + (focusableElement) => !focusableElement.hasAttribute('disabled') && !(focusableElement as HTMLElement).hidden, ) as HTMLElement[]; }; const handleKeyDown = (event: KeyboardEvent) => { // Check if this is the topmost trap - const allTraps = document.querySelectorAll('[data-focus-trap="true"]'); - const isTopTrap = allTraps.length > 0 && Array.from(allTraps)[allTraps.length - 1] === container; + const allFocusTraps = document.querySelectorAll('[data-focus-trap="true"]'); + const isTopmostFocusTrap = + allFocusTraps.length > 0 && Array.from(allFocusTraps)[allFocusTraps.length - 1] === container; - if (!isTopTrap || event.key !== 'Tab') { + if (!isTopmostFocusTrap || event.key !== 'Tab') { return; } From bbca8590b6fbf9818eb3dc33eafe7fe667c940d6 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Sat, 8 Feb 2025 11:15:36 +0600 Subject: [PATCH 4/5] No payment configured issue fixed --- .../v3/entries/payment-settings/components/PaymentItem.tsx | 3 +-- includes/tutor-general-functions.php | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/assets/react/v3/entries/payment-settings/components/PaymentItem.tsx b/assets/react/v3/entries/payment-settings/components/PaymentItem.tsx index bc90d6a469..b656810588 100644 --- a/assets/react/v3/entries/payment-settings/components/PaymentItem.tsx +++ b/assets/react/v3/entries/payment-settings/components/PaymentItem.tsx @@ -164,7 +164,7 @@ const PaymentItem = ({ data, paymentIndex, isOverlay = false }: PaymentItemProps {__('Update now', 'tutor')} - + }> {__('Plugin not installed', 'tutor')} @@ -175,7 +175,6 @@ const PaymentItem = ({ data, paymentIndex, isOverlay = false }: PaymentItemProps render={(controllerProps) => ( { const isValid = await form.trigger(`payment_methods.${paymentIndex}.fields`); diff --git a/includes/tutor-general-functions.php b/includes/tutor-general-functions.php index 78d6595718..185a63fa94 100644 --- a/includes/tutor-general-functions.php +++ b/includes/tutor-general-functions.php @@ -1451,7 +1451,7 @@ function tutor_get_all_active_payment_gateways() { $name = $method['name']; $basename = "tutor-{$name}/tutor-{$name}.php"; $is_plugin_activated = is_plugin_active( $basename ); - if ( ! $is_manual && ! $is_plugin_activated ) { + if ( ! $is_manual && 'paypal' !== $name && ! $is_plugin_activated ) { continue; } From 1afee78da90a23ca11a12c76b07019b7b4c9b370 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Wed, 5 Feb 2025 17:37:21 +0600 Subject: [PATCH 5/5] Settings scroll to view in search issue fixed --- assets/react/admin-dashboard/segments/options.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/assets/react/admin-dashboard/segments/options.js b/assets/react/admin-dashboard/segments/options.js index 1577c2472a..6e506635c6 100644 --- a/assets/react/admin-dashboard/segments/options.js +++ b/assets/react/admin-dashboard/segments/options.js @@ -1,4 +1,3 @@ - import { get_response_message } from '../../helper/response'; // SVG Icons Totor V2 @@ -384,15 +383,14 @@ document.addEventListener('DOMContentLoaded', function () { function highlightSearchedItem(dataKey) { const target = document.querySelector(`#${dataKey}`); const targetEl = target && target.querySelector(`[tutor-option-name]`); - const scrollTargetEl = target && target.parentNode.querySelector('.tutor-option-field-row'); - if (scrollTargetEl) { + if (targetEl) { targetEl.classList.add('isHighlighted'); setTimeout(() => { targetEl.classList.remove('isHighlighted'); }, 6000); - scrollTargetEl.scrollIntoView({ + targetEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest',