mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Updated AdminX scroll handling to work more consistently (#18106)
refs https://github.com/TryGhost/Product/issues/3832 --- Refactored the scrolling logic for the settings page into a custom hook and context. This improves the navigation and user experience of the settings page and simplifies the code. Added the file `useScrollSection.tsx` and updated the files `SettingGroup.tsx`, `SettingNavItem.tsx`, `RoutingProvider.tsx`, and `useRouting.tsx` accordingly.
This commit is contained in:
parent
279ce77226
commit
7cc9d959fc
5 changed files with 150 additions and 91 deletions
|
@ -1,10 +1,11 @@
|
|||
import ButtonGroup from '../global/ButtonGroup';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import SettingGroupHeader from './SettingGroupHeader';
|
||||
import clsx from 'clsx';
|
||||
import useRouting from '../../hooks/useRouting';
|
||||
import {ButtonProps} from '../global/Button';
|
||||
import {SaveState} from '../../hooks/useForm';
|
||||
import {useScrollSection} from '../../hooks/useScrollSection';
|
||||
import {useSearch} from '../../components/providers/ServiceProvider';
|
||||
|
||||
interface SettingGroupProps {
|
||||
|
@ -55,12 +56,9 @@ const SettingGroup: React.FC<SettingGroupProps> = ({
|
|||
onCancel
|
||||
}) => {
|
||||
const {checkVisible} = useSearch();
|
||||
const {yScroll, updateScrolled, route} = useRouting();
|
||||
const {route} = useRouting();
|
||||
const [highlight, setHighlight] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [currentRect, setCurrentRect] = useState<{top: number, bottom: number}>({top: 0, bottom: 0});
|
||||
const topOffset = -193.5;
|
||||
const bottomOffset = 36;
|
||||
const {ref} = useScrollSection(navid);
|
||||
|
||||
const handleEdit = () => {
|
||||
onEditingChange?.(true);
|
||||
|
@ -132,24 +130,6 @@ const SettingGroup: React.FC<SettingGroupProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const rootElement = document.getElementById('admin-x-settings-content');
|
||||
const rootRect = rootElement?.getBoundingClientRect() || DOMRect.fromRect();
|
||||
const sectionRect = scrollRef.current.getBoundingClientRect();
|
||||
setCurrentRect({
|
||||
top: sectionRect.top - rootRect!.top,
|
||||
bottom: (sectionRect.top - rootRect!.top) + sectionRect.height
|
||||
});
|
||||
}
|
||||
}, [checkVisible, navid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRect.top && yScroll! >= currentRect.top + topOffset && yScroll! < currentRect.bottom + topOffset + bottomOffset) {
|
||||
updateScrolled(navid!);
|
||||
}
|
||||
}, [yScroll, currentRect, navid, updateScrolled, topOffset, bottomOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlight(route === navid);
|
||||
}, [route, navid]);
|
||||
|
@ -172,9 +152,8 @@ const SettingGroup: React.FC<SettingGroupProps> = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={containerClasses} data-testid={testId}>
|
||||
{/* {yScroll} / {currentRect.top + topOffset} / {currentRect.bottom + topOffset + bottomOffset} */}
|
||||
<div className='absolute top-[-193px]' id={navid && navid}></div>
|
||||
<div className={containerClasses} data-testid={testId}>
|
||||
<div ref={ref} className='absolute' id={navid && navid}></div>
|
||||
{customHeader ? customHeader :
|
||||
<SettingGroupHeader description={description} title={title!}>
|
||||
{customButtons ? customButtons :
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import useRouting from '../../hooks/useRouting';
|
||||
import {useScrollSectionContext} from '../../hooks/useScrollSection';
|
||||
|
||||
interface Props {
|
||||
title: React.ReactNode;
|
||||
|
@ -13,11 +13,11 @@ const SettingNavItem: React.FC<Props> = ({
|
|||
navid = '',
|
||||
onClick = () => {}
|
||||
}) => {
|
||||
const {scrolledRoute} = useRouting();
|
||||
const {currentSection} = useScrollSectionContext();
|
||||
|
||||
const classNames = clsx(
|
||||
'block px-0 py-1 text-sm dark:text-white',
|
||||
(scrolledRoute === navid) && 'font-bold'
|
||||
(currentSection === navid) && 'font-bold'
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -25,4 +25,4 @@ const SettingNavItem: React.FC<Props> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default SettingNavItem;
|
||||
export default SettingNavItem;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import NiceModal, {NiceModalHocProps} from '@ebay/nice-modal-react';
|
||||
|
||||
import React, {createContext, useCallback, useEffect, useState} from 'react';
|
||||
import {ScrollSectionProvider} from '../../hooks/useScrollSection';
|
||||
|
||||
export type RouteParams = {[key: string]: string}
|
||||
|
||||
|
@ -17,18 +17,12 @@ export type InternalLink = {
|
|||
|
||||
export type RoutingContextData = {
|
||||
route: string;
|
||||
scrolledRoute: string;
|
||||
yScroll: number;
|
||||
updateRoute: (to: string | InternalLink | ExternalLink) => void;
|
||||
updateScrolled: (newPath: string) => void;
|
||||
};
|
||||
|
||||
export const RouteContext = createContext<RoutingContextData>({
|
||||
route: '',
|
||||
scrolledRoute: '',
|
||||
yScroll: 0,
|
||||
updateRoute: () => {},
|
||||
updateScrolled: () => {}
|
||||
updateRoute: () => {}
|
||||
});
|
||||
|
||||
export type RoutingModalProps = {
|
||||
|
@ -101,14 +95,7 @@ function getHashPath(urlPath: string | undefined) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const scrollToSectionGroup = (pathName: string) => {
|
||||
const element = document.getElementById(pathName);
|
||||
if (element) {
|
||||
element.scrollIntoView({behavior: 'smooth'});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigation = (scroll: boolean = true) => {
|
||||
const handleNavigation = () => {
|
||||
// Get the hash from the URL
|
||||
let hash = window.location.hash;
|
||||
|
||||
|
@ -125,10 +112,6 @@ const handleNavigation = (scroll: boolean = true) => {
|
|||
modal().then(({default: component}) => NiceModal.show(component, {params: matchRoute(pathName, path)}));
|
||||
}
|
||||
|
||||
if (scroll) {
|
||||
scrollToSectionGroup(pathName);
|
||||
}
|
||||
|
||||
return pathName;
|
||||
}
|
||||
return '';
|
||||
|
@ -151,8 +134,6 @@ type RouteProviderProps = {
|
|||
|
||||
const RoutingProvider: React.FC<RouteProviderProps> = ({externalNavigate, children}) => {
|
||||
const [route, setRoute] = useState<string>('');
|
||||
const [yScroll, setYScroll] = useState(0);
|
||||
const [scrolledRoute, setScrolledRoute] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Preload all the modals after initial render to avoid a delay when opening them
|
||||
|
@ -172,19 +153,11 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({externalNavigate, childr
|
|||
const newPath = options.route;
|
||||
|
||||
if (newPath) {
|
||||
if (newPath === route) {
|
||||
scrollToSectionGroup(newPath);
|
||||
} else {
|
||||
window.location.hash = `/settings-x/${newPath}`;
|
||||
}
|
||||
window.location.hash = `/settings-x/${newPath}`;
|
||||
} else {
|
||||
window.location.hash = `/settings-x`;
|
||||
}
|
||||
}, [externalNavigate, route]);
|
||||
|
||||
const updateScrolled = useCallback((newPath: string) => {
|
||||
setScrolledRoute(newPath);
|
||||
}, []);
|
||||
}, [externalNavigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
|
@ -192,21 +165,12 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({externalNavigate, childr
|
|||
setRoute(matchedRoute);
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
const element = document.getElementById('admin-x-root');
|
||||
const scrollPosition = element!.scrollTop;
|
||||
setYScroll(scrollPosition);
|
||||
};
|
||||
|
||||
const element = document.getElementById('admin-x-root');
|
||||
const matchedRoute = handleNavigation();
|
||||
setRoute(matchedRoute);
|
||||
element!.addEventListener('scroll', handleScroll);
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
|
||||
return () => {
|
||||
element!.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
@ -215,13 +179,12 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({externalNavigate, childr
|
|||
<RouteContext.Provider
|
||||
value={{
|
||||
route,
|
||||
scrolledRoute,
|
||||
yScroll,
|
||||
updateRoute,
|
||||
updateScrolled
|
||||
updateRoute
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ScrollSectionProvider navigatedSection={route.split('/')[0]}>
|
||||
{children}
|
||||
</ScrollSectionProvider>
|
||||
</RouteContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,18 +1,6 @@
|
|||
import {RouteContext, RoutingContextData} from '../components/providers/RoutingProvider';
|
||||
import {RouteContext} from '../components/providers/RoutingProvider';
|
||||
import {useContext} from 'react';
|
||||
|
||||
export type RoutingHook = Pick<RoutingContextData, 'route' | 'scrolledRoute' | 'yScroll' | 'updateRoute' | 'updateScrolled'>;
|
||||
|
||||
const useRouting = (): RoutingHook => {
|
||||
const {route, scrolledRoute, yScroll, updateScrolled, updateRoute} = useContext(RouteContext);
|
||||
|
||||
return {
|
||||
route,
|
||||
scrolledRoute,
|
||||
yScroll,
|
||||
updateScrolled,
|
||||
updateRoute
|
||||
};
|
||||
};
|
||||
const useRouting = () => useContext(RouteContext);
|
||||
|
||||
export default useRouting;
|
||||
|
|
129
apps/admin-x-settings/src/hooks/useScrollSection.tsx
Normal file
129
apps/admin-x-settings/src/hooks/useScrollSection.tsx
Normal file
|
@ -0,0 +1,129 @@
|
|||
import {ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface ScrollSectionContextData {
|
||||
updateSection: (id: string, element: HTMLDivElement) => void;
|
||||
currentSection: string | null;
|
||||
}
|
||||
|
||||
const ScrollSectionContext = createContext<ScrollSectionContextData>({
|
||||
updateSection: () => {},
|
||||
currentSection: null
|
||||
});
|
||||
|
||||
export const useScrollSectionContext = () => useContext(ScrollSectionContext);
|
||||
|
||||
const scrollToSection = (element: HTMLDivElement) => {
|
||||
const root = document.getElementById('admin-x-root')!;
|
||||
const top = element.getBoundingClientRect().top + root.scrollTop;
|
||||
|
||||
root.scrollTo({
|
||||
behavior: 'smooth',
|
||||
top: top - 193
|
||||
});
|
||||
};
|
||||
|
||||
export const ScrollSectionProvider: React.FC<{
|
||||
navigatedSection: string;
|
||||
children: ReactNode;
|
||||
}> = ({navigatedSection, children}) => {
|
||||
const sectionElements = useRef<Record<string, HTMLDivElement>>({});
|
||||
const [intersectingSections, setIntersectingSections] = useState<string[]>([]);
|
||||
const [lastIntersectedSection, setLastIntersectedSection] = useState<string | null>(null);
|
||||
const [doneInitialScroll, setDoneInitialScroll] = useState(false);
|
||||
|
||||
const intersectionObserver = useMemo(() => new IntersectionObserver((entries) => {
|
||||
const entriesWithId = entries.map(({isIntersecting, target}) => ({
|
||||
isIntersecting,
|
||||
id: Object.entries(sectionElements.current).find(([, element]) => element === target)?.[0]
|
||||
})).filter(entry => entry.id) as {id: string; isIntersecting: boolean}[];
|
||||
|
||||
setIntersectingSections((sections) => {
|
||||
const newlyIntersectingIds = entriesWithId.filter(entry => !sections.includes(entry.id) && entry.isIntersecting).map(entry => entry.id);
|
||||
const unintersectingIds = entriesWithId.filter(entry => !entry.isIntersecting).map(entry => entry.id);
|
||||
|
||||
const newSections = sections.filter(section => !unintersectingIds.includes(section)).concat(newlyIntersectingIds);
|
||||
|
||||
newSections.sort((first, second) => {
|
||||
const firstElement = sectionElements.current[first];
|
||||
const secondElement = sectionElements.current[second];
|
||||
|
||||
if (!firstElement || !secondElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return firstElement.getBoundingClientRect().top - secondElement.getBoundingClientRect().top;
|
||||
});
|
||||
|
||||
if (newSections.length) {
|
||||
setLastIntersectedSection(newSections[0]);
|
||||
}
|
||||
|
||||
return newSections;
|
||||
});
|
||||
}, {
|
||||
rootMargin: '-20% 0px -40% 0px'
|
||||
}), []);
|
||||
|
||||
const updateSection = useCallback((id: string, element: HTMLDivElement) => {
|
||||
if (sectionElements.current[id] === element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sectionElements.current[id]) {
|
||||
intersectionObserver.unobserve(sectionElements.current[id]);
|
||||
}
|
||||
|
||||
sectionElements.current[id] = element;
|
||||
intersectionObserver.observe(element);
|
||||
|
||||
if (!doneInitialScroll && id === navigatedSection) {
|
||||
scrollToSection(element);
|
||||
|
||||
// element.scrollIntoView({behavior: 'smooth'});
|
||||
setDoneInitialScroll(true);
|
||||
}
|
||||
}, [intersectionObserver, navigatedSection, doneInitialScroll]);
|
||||
|
||||
const currentSection = useMemo(() => {
|
||||
if (navigatedSection && intersectingSections.includes(navigatedSection)) {
|
||||
return navigatedSection;
|
||||
}
|
||||
|
||||
if (intersectingSections.length) {
|
||||
return intersectingSections[0];
|
||||
}
|
||||
|
||||
return lastIntersectedSection;
|
||||
}, [intersectingSections, lastIntersectedSection, navigatedSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (navigatedSection && sectionElements.current[navigatedSection]) {
|
||||
scrollToSection(sectionElements.current[navigatedSection]);
|
||||
setDoneInitialScroll(true);
|
||||
}
|
||||
}, [navigatedSection]);
|
||||
|
||||
return (
|
||||
<ScrollSectionContext.Provider value={{
|
||||
updateSection,
|
||||
currentSection
|
||||
}}>
|
||||
{children}
|
||||
</ScrollSectionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useScrollSection = (id?: string) => {
|
||||
const {updateSection} = useScrollSectionContext();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && ref.current) {
|
||||
updateSection(id, ref.current);
|
||||
}
|
||||
}, [id, updateSection]);
|
||||
|
||||
return {
|
||||
ref
|
||||
};
|
||||
};
|
Loading…
Add table
Reference in a new issue