0
Fork 0
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:
Jono M 2023-09-13 11:52:00 +01:00 committed by GitHub
parent 279ce77226
commit 7cc9d959fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 150 additions and 91 deletions

View file

@ -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 :

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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;

View 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
};
};