Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge dev to '3.3.0' #1584

Merged
merged 9 commits into from
Feb 11, 2025
6 changes: 2 additions & 4 deletions assets/react/admin-dashboard/segments/options.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import { get_response_message } from '../../helper/response';

// SVG Icons Totor V2
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const BasicPrompt = ({ onClose }: BasicPromptProps) => {
};

export default BasicPrompt;

const styles = {
container: css`
position: absolute;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -29,9 +30,11 @@ const AICourseBuilderModal = ({ closeModal }: AICourseBuilderModalProps) => {

return (
<ContentGenerationContextProvider>
<div css={styles.wrapper}>
<Component closeModal={closeModal} />
</div>
<FocusTrap>
<div css={styles.wrapper}>
<Component closeModal={closeModal} />
</div>
</FocusTrap>
</ContentGenerationContextProvider>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ const PaymentItem = ({ data, paymentIndex, isOverlay = false }: PaymentItemProps
{__('Update now', 'tutor')}
</Button>
</Show>
<Show when={!data.is_installed}>
<Show when={!data.is_manual && !data.is_installed}>
<Badge variant="warning" icon={<SVGIcon name="warning" width={24} height={24} />}>
{__('Plugin not installed', 'tutor')}
</Badge>
Expand All @@ -175,7 +175,6 @@ const PaymentItem = ({ data, paymentIndex, isOverlay = false }: PaymentItemProps
render={(controllerProps) => (
<FormSwitch
{...controllerProps}
disabled={!data.is_installed}
onChange={async (value) => {
const isValid = await form.trigger(`payment_methods.${paymentIndex}.fields`);

Expand Down
68 changes: 68 additions & 0 deletions assets/react/v3/shared/components/FocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Children, cloneElement, useEffect, useRef, type ReactElement, type ReactNode } from 'react';

const FocusTrap = ({ children }: { children: ReactNode }) => {
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
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(
(focusableElement) => !focusableElement.hasAttribute('disabled') && !(focusableElement as HTMLElement).hidden,
) as HTMLElement[];
};

const handleKeyDown = (event: KeyboardEvent) => {
// Check if this is the topmost trap
const allFocusTraps = document.querySelectorAll('[data-focus-trap="true"]');
const isTopmostFocusTrap =
allFocusTraps.length > 0 && Array.from(allFocusTraps)[allFocusTraps.length - 1] === container;

if (!isTopmostFocusTrap || 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', handleKeyDown, true);

return () => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, []);

return cloneElement(Children.only(children) as ReactElement, {
ref: containerRef,
'data-focus-trap': 'true',
tabIndex: -1,
});
};

export default FocusTrap;
65 changes: 35 additions & 30 deletions assets/react/v3/shared/components/modals/BasicModalWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -44,31 +46,32 @@ const BasicModalWrapper = ({
}, []);

return (
<div
css={[styles.container({ isFullScreen: fullScreen }), modalStyle]}
style={{
maxWidth: `${maxWidth}px`,
}}
>
<FocusTrap>
<div
css={styles.header({
hasEntireHeader: !!entireHeader,
})}
css={[styles.container({ isFullScreen: fullScreen }), modalStyle]}
style={{
maxWidth: `${maxWidth}px`,
}}
>
<Show when={!entireHeader} fallback={entireHeader}>
<div css={styles.headerContent}>
<div css={styles.iconWithTitle}>
<Show when={icon}>{icon}</Show>
<Show when={title}>
<p css={styles.title}>{title}</p>
<div
css={styles.header({
hasEntireHeader: !!entireHeader,
})}
>
<Show when={!entireHeader} fallback={entireHeader}>
<div css={styles.headerContent}>
<div css={styles.iconWithTitle}>
<Show when={icon}>{icon}</Show>
<Show when={title}>
<p css={styles.title}>{title}</p>
</Show>
</div>
<Show when={subtitle}>
<span css={styles.subtitle}>{subtitle}</span>
</Show>
</div>
<Show when={subtitle}>
<span css={styles.subtitle}>{subtitle}</span>
</Show>
</div>
</Show>
<Show when={isCloseAble}>
</Show>

<div
css={styles.actionsWrapper({
hasEntireHeader: !!entireHeader,
Expand All @@ -77,18 +80,20 @@ const BasicModalWrapper = ({
<Show
when={actions}
fallback={
<button type="button" css={styles.closeButton} onClick={onClose}>
<SVGIcon name="timesThin" width={24} height={24} />
</button>
<Show when={isCloseAble}>
<button type="button" css={styles.closeButton} onClick={onClose}>
<SVGIcon name="timesThin" width={24} height={24} />
</button>
</Show>
}
>
{actions}
</Show>
</div>
</Show>
</div>
<div css={styles.content({ isFullScreen: fullScreen })}>{children}</div>
</div>
<div css={styles.content({ isFullScreen: fullScreen })}>{children}</div>
</div>
</FocusTrap>
);
};

Expand Down
91 changes: 47 additions & 44 deletions assets/react/v3/shared/components/modals/ModalWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,56 +41,58 @@ const ModalWrapper = ({
}, []);

return (
<div
css={styles.container({
maxWidth,
})}
>
<FocusTrap>
<div
css={styles.header({
hasHeaderChildren: !!headerChildren,
css={styles.container({
maxWidth,
})}
>
<Show
when={entireHeader}
fallback={
<>
<div css={styles.headerContent}>
<div css={styles.iconWithTitle}>
<Show when={icon}>{icon}</Show>
<Show when={title}>
<h6 css={styles.title} title={typeof title === 'string' ? title : ''}>
{title}
</h6>
<div
css={styles.header({
hasHeaderChildren: !!headerChildren,
})}
>
<Show
when={entireHeader}
fallback={
<>
<div css={styles.headerContent}>
<div css={styles.iconWithTitle}>
<Show when={icon}>{icon}</Show>
<Show when={title}>
<h6 css={styles.title} title={typeof title === 'string' ? title : ''}>
{title}
</h6>
</Show>
</div>
<Show when={subtitle}>
<span css={styles.subtitle}>{subtitle}</span>
</Show>
</div>
<Show when={subtitle}>
<span css={styles.subtitle}>{subtitle}</span>
</Show>
</div>
<div css={styles.headerChildren}>
<Show when={headerChildren}>{headerChildren}</Show>
</div>
<div css={styles.actionsWrapper}>
<Show
when={actions}
fallback={
<button type="button" css={styles.closeButton} onClick={onClose}>
<SVGIcon name="times" width={14} height={14} />
</button>
}
>
{actions}
</Show>
</div>
</>
}
>
{entireHeader}
</Show>
<div css={styles.headerChildren}>
<Show when={headerChildren}>{headerChildren}</Show>
</div>
<div css={styles.actionsWrapper}>
<Show
when={actions}
fallback={
<button type="button" css={styles.closeButton} onClick={onClose}>
<SVGIcon name="times" width={14} height={14} />
</button>
}
>
{actions}
</Show>
</div>
</>
}
>
{entireHeader}
</Show>
</div>
<div css={styles.content}>{children}</div>
</div>
<div css={styles.content}>{children}</div>
</div>
</FocusTrap>
);
};

Expand Down
25 changes: 14 additions & 11 deletions assets/react/v3/shared/hooks/usePortalPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -223,17 +224,19 @@ export const Portal = ({
if (openState) {
return createPortal(
<AnimatedDiv css={styles.wrapper} style={style}>
<div className="tutor-portal-popover" role="presentation">
<div
css={styles.backdrop}
onKeyUp={noop}
onClick={(event) => {
event.stopPropagation();
onClickOutside?.();
}}
/>
{children}
</div>
<FocusTrap>
<div className="tutor-portal-popover" role="presentation">
<div
css={styles.backdrop}
onKeyUp={noop}
onClick={(event) => {
event.stopPropagation();
onClickOutside?.();
}}
/>
{children}
</div>
</FocusTrap>
</AnimatedDiv>,
document.body,
);
Expand Down
Loading
Loading