3ef8b53fad
refs. https://linear.app/tryghost/issue/DES-21/empty-screen-is-missing-for-search-in-settings - Search is one of the most useful functions in Settings and currently the screen when there's no result for a searchterm is just a plain white screen. Very non user-friendly. - This update gives us an opportunity to improve the overall visual hierarchy and focus of Settings in general. --------- Co-authored-by: Ronald Langeveld <hi@ronaldlangeveld.com>
247 lines
8.4 KiB
TypeScript
247 lines
8.4 KiB
TypeScript
import {ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
|
|
|
|
interface ScrollSectionContextData {
|
|
updateSection: (id: string, element: HTMLDivElement) => void;
|
|
updateNav: (id: string, element: HTMLLIElement) => void;
|
|
currentSection: string | null;
|
|
updateNavigatedSection: (id: string) => void;
|
|
scrollToSection: (id: string) => void;
|
|
}
|
|
|
|
const ScrollSectionContext = createContext<ScrollSectionContextData>({
|
|
updateSection: () => {},
|
|
updateNav: () => {},
|
|
currentSection: null,
|
|
updateNavigatedSection: () => {},
|
|
scrollToSection: () => {}
|
|
});
|
|
|
|
export const useScrollSectionContext = () => useContext(ScrollSectionContext);
|
|
|
|
const scrollMargin = 193;
|
|
|
|
const scrollToSection = (element: HTMLDivElement, doneInitialScroll: boolean) => {
|
|
const root = document.getElementById('admin-x-settings-scroller')!;
|
|
const top = element.getBoundingClientRect().top + root.scrollTop;
|
|
|
|
root.scrollTo({
|
|
behavior: doneInitialScroll ? 'smooth' : 'instant',
|
|
top: top - scrollMargin
|
|
});
|
|
};
|
|
|
|
const scrollSidebarNav = (navElement: HTMLLIElement, doneInitialScroll: boolean) => {
|
|
// const sidebar = document.getElementById('admin-x-settings-sidebar')!;
|
|
const sidebar = document.getElementById('admin-x-settings-sidebar-scroller')!;
|
|
|
|
const bounds = navElement.getBoundingClientRect();
|
|
|
|
const parentBounds = sidebar.getBoundingClientRect();
|
|
const offsetTop = parentBounds.top + 40;
|
|
|
|
if (bounds.top >= offsetTop && bounds.left >= parentBounds.left && bounds.right <= parentBounds.right && bounds.bottom <= parentBounds.bottom) {
|
|
return;
|
|
}
|
|
|
|
if (!['auto', 'scroll'].includes(getComputedStyle(sidebar).overflowY)) {
|
|
return;
|
|
}
|
|
|
|
const behavior = doneInitialScroll ? 'smooth' : 'instant';
|
|
|
|
// If this is the first nav item, scroll to top
|
|
if (sidebar.querySelector('[data-setting-nav-item]') === navElement) {
|
|
sidebar.scrollTo({
|
|
top: 0,
|
|
behavior
|
|
});
|
|
} else if (bounds.top < offsetTop) {
|
|
sidebar.scrollTo({
|
|
top: sidebar.scrollTop + bounds.top - offsetTop,
|
|
behavior
|
|
});
|
|
} else {
|
|
sidebar.scrollTo({
|
|
top: sidebar.scrollTop + bounds.top - parentBounds.top - parentBounds.height + bounds.height + 4,
|
|
behavior
|
|
});
|
|
}
|
|
};
|
|
|
|
const getIntersectingSections = (current: string[], entries: IntersectionObserverEntry[], sectionElements: Record<string, HTMLDivElement>) => {
|
|
const entriesWithId = entries.map(({isIntersecting, target}) => ({
|
|
isIntersecting,
|
|
id: Object.entries(sectionElements).find(([, element]) => element === target)?.[0]
|
|
})).filter(entry => entry.id) as {id: string; isIntersecting: boolean}[];
|
|
|
|
const newlyIntersectingIds = entriesWithId.filter(entry => !current.includes(entry.id) && entry.isIntersecting).map(entry => entry.id);
|
|
const unintersectingIds = entriesWithId.filter(entry => !entry.isIntersecting).map(entry => entry.id);
|
|
|
|
const newSections = current.filter(section => !unintersectingIds.includes(section)).concat(newlyIntersectingIds);
|
|
|
|
newSections.sort((first, second) => {
|
|
const firstElement = sectionElements[first];
|
|
const secondElement = sectionElements[second];
|
|
|
|
if (!firstElement || !secondElement) {
|
|
return 0;
|
|
}
|
|
|
|
return firstElement.getBoundingClientRect().top - secondElement.getBoundingClientRect().top;
|
|
});
|
|
|
|
return newSections;
|
|
};
|
|
|
|
export const ScrollSectionProvider: React.FC<{
|
|
children: ReactNode;
|
|
}> = ({children}) => {
|
|
const [navigatedSection, _setNavigatedSection] = useState<string | null>(null);
|
|
const sectionElements = useRef<Record<string, HTMLDivElement>>({});
|
|
const intersectionObserver = useRef<IntersectionObserver | null>(null);
|
|
const [intersectingSections, setIntersectingSections] = useState<string[]>([]);
|
|
const [lastIntersectedSection, setLastIntersectedSection] = useState<string | null>(null);
|
|
|
|
const [hasUpdatedNavigatedSection, setHasUpdatedNavigatedSection] = useState(false);
|
|
const [doneInitialScroll, setDoneInitialScroll] = useState(false);
|
|
const [, setDoneSidebarScroll] = useState(false);
|
|
|
|
const setNavigatedSection = useCallback((value: string) => {
|
|
_setNavigatedSection(value);
|
|
setHasUpdatedNavigatedSection(true);
|
|
}, []);
|
|
|
|
const navElements = useRef<Record<string, HTMLLIElement>>({});
|
|
|
|
const setupIntersectionObserver = useCallback(() => {
|
|
const observer = new IntersectionObserver((entries) => {
|
|
setIntersectingSections((sections) => {
|
|
const newSections = getIntersectingSections(sections, entries, sectionElements.current);
|
|
|
|
if (newSections.length) {
|
|
setLastIntersectedSection(newSections[0]);
|
|
}
|
|
|
|
return newSections;
|
|
});
|
|
}, {
|
|
rootMargin: `-${scrollMargin - 50}px 0px -40% 0px`
|
|
});
|
|
|
|
Object.values(sectionElements.current).forEach(element => observer.observe(element));
|
|
|
|
return observer;
|
|
}, []);
|
|
|
|
const updateSection = useCallback((id: string, element: HTMLDivElement) => {
|
|
if (sectionElements.current[id] === element) {
|
|
return;
|
|
}
|
|
|
|
if (sectionElements.current[id]) {
|
|
intersectionObserver.current?.unobserve(sectionElements.current[id]);
|
|
}
|
|
|
|
sectionElements.current[id] = element;
|
|
intersectionObserver.current?.observe(element);
|
|
|
|
if (!doneInitialScroll && id === navigatedSection) {
|
|
scrollToSection(element, false);
|
|
setDoneInitialScroll(true);
|
|
}
|
|
}, [intersectionObserver, navigatedSection, doneInitialScroll]);
|
|
|
|
const updateNav = useCallback((id: string, element: HTMLLIElement) => {
|
|
navElements.current[id] = element;
|
|
}, []);
|
|
|
|
const scrollTo = useCallback((id: string) => {
|
|
if (sectionElements.current[id]) {
|
|
scrollToSection(sectionElements.current[id], true);
|
|
}
|
|
}, []);
|
|
|
|
const currentSection = useMemo(() => {
|
|
if (navigatedSection && intersectingSections.includes(navigatedSection)) {
|
|
return navigatedSection;
|
|
}
|
|
|
|
if (intersectingSections.length) {
|
|
return intersectingSections[0];
|
|
}
|
|
|
|
return lastIntersectedSection;
|
|
}, [intersectingSections, lastIntersectedSection, navigatedSection]);
|
|
|
|
useEffect(() => {
|
|
if (!hasUpdatedNavigatedSection) {
|
|
return;
|
|
}
|
|
|
|
if (navigatedSection && sectionElements.current[navigatedSection]) {
|
|
setDoneInitialScroll((done) => {
|
|
scrollToSection(sectionElements.current[navigatedSection], done);
|
|
return true;
|
|
});
|
|
} else {
|
|
// No navigated section means opening settings without a path
|
|
setDoneInitialScroll(true);
|
|
}
|
|
|
|
// Wait for the initial scroll so that the intersecting sections are correct
|
|
setTimeout(() => setupIntersectionObserver());
|
|
}, [hasUpdatedNavigatedSection, navigatedSection, setupIntersectionObserver]);
|
|
|
|
useEffect(() => {
|
|
if (hasUpdatedNavigatedSection && currentSection && navElements.current[currentSection]) {
|
|
setDoneSidebarScroll((done) => {
|
|
scrollSidebarNav(navElements.current[currentSection], done);
|
|
return true;
|
|
});
|
|
}
|
|
}, [hasUpdatedNavigatedSection, currentSection]);
|
|
|
|
return (
|
|
<ScrollSectionContext.Provider value={{
|
|
updateSection,
|
|
updateNav,
|
|
currentSection,
|
|
updateNavigatedSection: setNavigatedSection,
|
|
scrollToSection: scrollTo
|
|
}}>
|
|
{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
|
|
};
|
|
};
|
|
|
|
export const useScrollSectionNav = (id?: string) => {
|
|
const {updateNav} = useScrollSectionContext();
|
|
const ref = useRef<HTMLLIElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (id && ref.current) {
|
|
updateNav(id, ref.current);
|
|
}
|
|
}, [id, updateNav]);
|
|
|
|
return {
|
|
ref,
|
|
props: {'data-setting-nav-item': true}
|
|
};
|
|
};
|