1 import type { ReactNode } from 'react';
2 import { Children, cloneElement, isValidElement, useEffect, useRef } from 'react';
3 import { useLocation } from 'react-router';
5 import SettingsPageTitle from '@proton/components/containers/account/SettingsPageTitle';
6 import SettingsParagraph from '@proton/components/containers/account/SettingsParagraph';
7 import clsx from '@proton/utils/clsx';
9 import createScrollIntoView from '../../helpers/createScrollIntoView';
10 import useAppTitle from '../../hooks/useAppTitle';
11 import ErrorBoundary from '../app/ErrorBoundary';
12 import PrivateMainArea from './PrivateMainArea';
13 import SubSettingsSection from './SubSettingsSection';
14 import { getIsSubsectionAvailable } from './helper';
15 import type { SettingsAreaConfig } from './interface';
17 interface PrivateMainSettingsAreaBaseProps {
18 breadcrumbs?: ReactNode;
21 description?: ReactNode;
25 export const PrivateMainSettingsAreaBase = ({
31 }: PrivateMainSettingsAreaBaseProps) => {
32 const location = useLocation();
34 const mainAreaRef = useRef<HTMLDivElement>(null);
39 if (mainAreaRef.current) {
40 mainAreaRef.current.scrollTop = 0;
42 }, [location.pathname]);
45 const { hash } = location;
51 if (!mainAreaRef.current) {
54 const mainArea = mainAreaRef.current;
55 let el: Element | null | undefined;
57 el = mainArea.querySelector(hash);
63 const abortScroll = createScrollIntoView(el, mainArea, true);
64 let removeListeners: () => void;
76 // Abort on any user interaction such as scrolling, touching, or keyboard interaction
77 window.addEventListener('wheel', abort, options);
78 window.addEventListener('keydown', abort, options);
79 window.addEventListener('mousedown', abort, options);
80 window.addEventListener('touchstart', abort, options);
81 // Automatically abort after some time where it's assumed to have successfully scrolled into place.
82 const timeoutId = window.setTimeout(abort, 15000);
84 removeListeners = () => {
85 window.removeEventListener('wheel', abort, options);
86 window.removeEventListener('keydown', abort, options);
87 window.removeEventListener('mousedown', abort, options);
88 window.removeEventListener('touchstart', abort, options);
89 window.clearTimeout(timeoutId);
95 // Listen to location instead of location.hash since it's possible to click the same #section multiple times and end up with a new entry in history
98 const wrappedSections = Children.toArray(children).map((child) => {
99 if (!isValidElement<{ observer: IntersectionObserver; className: string }>(child)) {
103 return cloneElement(child);
107 <PrivateMainArea ref={mainAreaRef}>
108 <div className="container-section-sticky">
109 {breadcrumbs && <div className="mt-6 md:mt-0">{breadcrumbs}</div>}
111 <SettingsPageTitle className={clsx('mt-14', description ? 'mb-5' : 'mb-14')}>
115 {description && <SettingsParagraph className="mb-6">{description}</SettingsParagraph>}
116 <ErrorBoundary>{wrappedSections}</ErrorBoundary>
122 interface PrivateMainSettingsAreaProps {
124 config: SettingsAreaConfig;
127 const PrivateMainSettingsArea = ({ children, config }: PrivateMainSettingsAreaProps) => {
128 const { text, title, description, subsections } = config;
130 const wrappedSections = Children.toArray(children).map((child, i) => {
131 if (!isValidElement<{ observer: IntersectionObserver; className: string }>(child)) {
134 const subsectionConfig = subsections?.[i];
135 if (!subsectionConfig) {
136 throw new Error('Missing subsection');
138 if (!getIsSubsectionAvailable(subsectionConfig)) {
144 key={subsectionConfig.id}
145 id={subsectionConfig.id}
146 title={subsectionConfig.text}
147 beta={subsectionConfig.beta}
148 className="container-section-sticky-section"
151 </SubSettingsSection>
156 <PrivateMainSettingsAreaBase title={title || text} description={description}>
158 </PrivateMainSettingsAreaBase>
162 export default PrivateMainSettingsArea;