From 470f21f9080c139bee15576e41408cd9ae6a1fc1 Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Mon, 3 May 2021 14:37:16 -0700 Subject: [PATCH] group nav stuff into a single hook instead of HOC --- .../src/NarrativeLayout/NarrativeLayout.tsx | 111 ++---------- .../src/NarrativeLayout/NarrativeSection.tsx | 4 +- .../NarrativeLayout/useInternalNavigation.ts | 171 ++++++++++++++++++ .../NarrativeLayout/withNarrativeParams.tsx | 74 -------- 4 files changed, 193 insertions(+), 167 deletions(-) create mode 100644 spotlight-client/src/NarrativeLayout/useInternalNavigation.ts delete mode 100644 spotlight-client/src/NarrativeLayout/withNarrativeParams.tsx diff --git a/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx b/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx index 72a65518..faebe96d 100644 --- a/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx +++ b/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx @@ -16,9 +16,9 @@ // ============================================================================= import useBreakpoint from "@w11r/use-breakpoint"; -import { range } from "d3-array"; +import { observer } from "mobx-react-lite"; import { rem } from "polished"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React from "react"; import Sticker from "react-stickyfill"; import styled from "styled-components/macro"; import { NAV_BAR_HEIGHT } from "../constants"; @@ -26,7 +26,7 @@ import { X_PADDING } from "../SystemNarrativePage/constants"; import NarrativeNavigation from "./NarrativeNavigation"; import NarrativeSection from "./NarrativeSection"; import { LayoutSection } from "./types"; -import { InjectedProps, withNarrativeParams } from "./withNarrativeParams"; +import { useInternalNavigation } from "./useInternalNavigation"; const Wrapper = styled.article` display: flex; @@ -51,90 +51,23 @@ const SectionsWrapper = styled.div` min-width: 0; `; -type NarrativeLayoutProps = InjectedProps & { +type NarrativeLayoutProps = { sections: LayoutSection[]; }; -const NarrativeLayout: React.FC = ({ - navigateToSection, - sectionNumber: activeSectionNumber, - sections, -}) => { - const sectionsContainerRef = useRef() as React.MutableRefObject; - const isMobile = useBreakpoint(false, ["mobile-", true]); - const showSectionNavigation = !isMobile; +const NarrativeLayout: React.FC = ({ sections }) => { + const showSectionNavigation = useBreakpoint(true, ["mobile-", false]); - const scrollToSection = useCallback( - (targetSection: number) => { - const sectionEl = sectionsContainerRef.current.querySelector( - `#section${targetSection}` - ); - - if (sectionEl) { - const { top } = sectionEl.getBoundingClientRect(); - // NOTE: we are using a polyfill to make sure this method works in all browsers; - // native support is spotty as of this writing - window.scrollBy({ - top: top - NAV_BAR_HEIGHT, - behavior: "smooth", - }); - } - }, - [sectionsContainerRef] - ); - - // needed for handling direct section links without layout jank - const [initialSection] = useState(activeSectionNumber); - // if we have navigated directly to a section, bring it into the viewport; - // this should only run once when the component first mounts - useEffect(() => { - scrollToSection(initialSection); - }, [initialSection, navigateToSection, scrollToSection]); - - // when navigating directly to a section at page load, we will - // restrict the heights of any sections above it - // to prevent them from pushing other content down the page as they load - const [fixedHeightSections, setFixedHeightSections] = useState( - range(1, initialSection) - ); - // scroll snapping and fixed height sections do not play nicely together; - // we won't enable it until all sections have been expanded - const [enableSnapping, setEnableSnapping] = useState(initialSection === 1); - - // remove sections from the fixed-height list as we pass through their range; - // retain any still above the current section until we get all the way to the top - useEffect(() => { - const fixedHeightEnd = Math.min(activeSectionNumber, initialSection); - if (fixedHeightSections.length) { - setFixedHeightSections( - // make sure we don't add any sections back when we scroll down again - range(1, fixedHeightEnd).slice(0, fixedHeightSections.length) - ); - } - }, [activeSectionNumber, initialSection, fixedHeightSections.length]); - - // some navigation features need to be disabled until we have made sure - // the initial section indicated by the URL is in the viewport, so let's keep track of that - const [initialScrollComplete, setInitialScrollComplete] = useState( - // if we have landed on the first section there won't be any initial scroll - initialSection === 1 - ); - - // when new sections come into view, call this to sync state and URL with section visibility - const onInViewChange = useCallback( - ({ inView, sectionNumber }: { inView: boolean; sectionNumber: number }) => { - if (inView) { - if (initialScrollComplete) { - navigateToSection(sectionNumber); - } else if (sectionNumber === initialSection) { - navigateToSection(sectionNumber); - scrollToSection(sectionNumber); - setInitialScrollComplete(true); - } - } - }, - [initialScrollComplete, initialSection, navigateToSection, scrollToSection] - ); + const { + alwaysExpanded, + currentSectionNumber, + enableSnapping, + fixedHeightSections, + scrollToSection, + sectionsContainerRef, + getOnSectionExpanded, + onInViewChange, + } = useInternalNavigation(); return ( @@ -143,7 +76,7 @@ const NarrativeLayout: React.FC = ({ @@ -165,13 +98,9 @@ const NarrativeLayout: React.FC = ({ }} > { - if (sectionNumber === 1) { - setEnableSnapping(true); - } - }} + onSectionExpanded={getOnSectionExpanded(sectionNumber)} restrictHeight={fixedHeightSections.includes(sectionNumber)} sectionNumber={sectionNumber} > @@ -185,4 +114,4 @@ const NarrativeLayout: React.FC = ({ ); }; -export default withNarrativeParams(NarrativeLayout); +export default observer(NarrativeLayout); diff --git a/spotlight-client/src/NarrativeLayout/NarrativeSection.tsx b/spotlight-client/src/NarrativeLayout/NarrativeSection.tsx index d9383359..03a63a41 100644 --- a/spotlight-client/src/NarrativeLayout/NarrativeSection.tsx +++ b/spotlight-client/src/NarrativeLayout/NarrativeSection.tsx @@ -42,7 +42,7 @@ const InViewSensor = styled.div` type NarrativeSectionProps = { alwaysExpanded: boolean; onInViewChange: (props: { inView: boolean; sectionNumber: number }) => void; - onSectionExpanded: () => void; + onSectionExpanded?: () => void; restrictHeight: boolean; sectionNumber: number; }; @@ -69,7 +69,7 @@ const NarrativeSection: React.FC = ({ ? window.innerHeight - actualNavBarHeight : contentHeight, onRest: () => { - if (!restrictHeight) { + if (!restrictHeight && onSectionExpanded) { onSectionExpanded(); } }, diff --git a/spotlight-client/src/NarrativeLayout/useInternalNavigation.ts b/spotlight-client/src/NarrativeLayout/useInternalNavigation.ts new file mode 100644 index 00000000..5c851f5d --- /dev/null +++ b/spotlight-client/src/NarrativeLayout/useInternalNavigation.ts @@ -0,0 +1,171 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 Recidiviz, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +import { navigate } from "@reach/router"; +import { range } from "d3-array"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { NAV_BAR_HEIGHT } from "../constants"; +import getUrlForResource from "../routerUtils/getUrlForResource"; +import { useDataStore } from "../StoreProvider"; + +/** + * Encapsulates the internal narrative navigation functionality + * and returns the properties and methods necessary for rendering. + */ +export const useInternalNavigation = (): { + alwaysExpanded: boolean; + currentSectionNumber: number; + enableSnapping: boolean; + fixedHeightSections: number[]; + getOnSectionExpanded: (s: number) => (() => void) | undefined; + onInViewChange: (props: { inView: boolean; sectionNumber: number }) => void; + scrollToSection: (s: number) => void; + sectionsContainerRef: React.MutableRefObject; +} => { + const { + tenantStore: { + currentTenantId: tenantId, + currentNarrativeTypeId: narrativeTypeId, + currentSectionNumber = 1, + }, + } = useDataStore(); + + // call this to update the URL, which will in turn update + // the currentSectionNumber we get from the data store + const navigateToSection = useCallback( + (newSectionNumber: number) => { + // this is just type safety; should always be defined in practice + if (!tenantId || !narrativeTypeId) return; + + navigate( + getUrlForResource({ + page: "narrative", + params: { + tenantId, + narrativeTypeId, + sectionNumber: newSectionNumber, + }, + }), + { replace: true } + ); + }, + [narrativeTypeId, tenantId] + ); + + // attach this to the element containing sections so we can inspect its children + const sectionsContainerRef = useRef(null); + + // call this to scroll a section into view + const scrollToSection = useCallback( + (targetSection: number) => { + const sectionEl = sectionsContainerRef.current?.querySelector( + `#section${targetSection}` + ); + + if (sectionEl) { + const { top } = sectionEl.getBoundingClientRect(); + // NOTE: we are using a polyfill to make sure this method works in all browsers; + // native support is spotty as of this writing + window.scrollBy({ + top: top - NAV_BAR_HEIGHT, + behavior: "smooth", + }); + } + }, + [sectionsContainerRef] + ); + + // needed for handling direct section links without layout jank + const [initialSection] = useState(currentSectionNumber); + + // if we have navigated directly to a section, bring it into the viewport; + // this should only run once when the component first mounts + useEffect(() => { + scrollToSection(initialSection); + }, [initialSection, navigateToSection, scrollToSection]); + + // when navigating directly to a section at page load, we will + // restrict the heights of any sections above it + // to prevent them from pushing other content down the page as they load + const [fixedHeightSections, setFixedHeightSections] = useState( + range(1, initialSection) + ); + // we can skip the height restrictions and animations if we landed at the top + const alwaysExpanded = initialSection === 1; + + // scroll snapping and fixed-height sections do not play nicely together, + // so disable snapping if we have any sections that may need to expand + const [enableSnapping, setEnableSnapping] = useState(initialSection === 1); + // call this on animation end; when all sections are fully expanded, it will enable snapping + const getOnSectionExpanded = (sectionNumber: number) => { + if (sectionNumber === 1) { + return () => { + setEnableSnapping(true); + }; + } + }; + + // remove sections from the fixed-height list as we pass through their range; + // retain any still above the current section until we get all the way to the top + useEffect(() => { + const fixedHeightEnd = Math.min(currentSectionNumber, initialSection); + if (fixedHeightSections.length) { + setFixedHeightSections( + // make sure we don't add any sections back when we scroll down again + range(1, fixedHeightEnd).slice(0, fixedHeightSections.length) + ); + } + }, [currentSectionNumber, initialSection, fixedHeightSections.length]); + + // the IntersectionObservers within sections need to be disabled until we have made sure + // the initial section indicated by the URL is in the viewport, so let's keep track of that + const [initialScrollComplete, setInitialScrollComplete] = useState( + // if we have landed on the first section there won't be any initial scroll + initialSection === 1 + ); + + // call this when new sections come into view; it makes sure the initial section is aligned + // with the viewport, and it updates the URL to reflect what's currently in view + const onInViewChange = useCallback( + ({ inView, sectionNumber }: { inView: boolean; sectionNumber: number }) => { + if (inView) { + if (initialScrollComplete) { + navigateToSection(sectionNumber); + } else if (sectionNumber === initialSection) { + // section number could be missing or out of bounds; make sure URL matches view + navigateToSection(sectionNumber); + // section could be offset from the top due to saved browser state; align it + scrollToSection(sectionNumber); + // clearing this activates the IntersectionObservers for all sections + setInitialScrollComplete(true); + } + } + }, + [initialScrollComplete, initialSection, navigateToSection, scrollToSection] + ); + + return { + alwaysExpanded, + currentSectionNumber, + enableSnapping, + fixedHeightSections, + getOnSectionExpanded, + onInViewChange, + scrollToSection, + sectionsContainerRef, + }; +}; diff --git a/spotlight-client/src/NarrativeLayout/withNarrativeParams.tsx b/spotlight-client/src/NarrativeLayout/withNarrativeParams.tsx deleted file mode 100644 index a14c9189..00000000 --- a/spotlight-client/src/NarrativeLayout/withNarrativeParams.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// Recidiviz - a data platform for criminal justice reform -// Copyright (C) 2021 Recidiviz, Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . -// ============================================================================= - -import { navigate } from "@reach/router"; -import { observer } from "mobx-react-lite"; -import React, { useCallback } from "react"; -import getUrlForResource from "../routerUtils/getUrlForResource"; -import { useDataStore } from "../StoreProvider"; - -export type InjectedProps = { - navigateToSection: (s: number) => void; - sectionNumber: number; -}; - -export const withNarrativeParams = ( - OriginalComponent: React.ComponentType -): React.ComponentType => { - const NarrativeParamsProvider = (props: OriginalProps) => { - const { - tenantStore: { - currentTenantId: tenantId, - currentNarrativeTypeId: narrativeTypeId, - currentSectionNumber: sectionNumber, - }, - } = useDataStore(); - - const navigateToSection = useCallback( - (newSectionNumber: number) => { - if (!tenantId || !narrativeTypeId) return; - - navigate( - getUrlForResource({ - page: "narrative", - params: { - tenantId, - narrativeTypeId, - sectionNumber: newSectionNumber, - }, - }), - { replace: true } - ); - }, - [narrativeTypeId, tenantId] - ); - - if ( - tenantId === undefined || - narrativeTypeId === undefined || - sectionNumber === undefined - ) { - return null; - } - - return ( - - ); - }; - - return observer(NarrativeParamsProvider); -};