Skip to content

Commit

Permalink
group nav stuff into a single hook instead of HOC
Browse files Browse the repository at this point in the history
  • Loading branch information
macfarlandian committed May 3, 2021
1 parent 0e4e53d commit 470f21f
Show file tree
Hide file tree
Showing 4 changed files with 193 additions and 167 deletions.
111 changes: 20 additions & 91 deletions spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@
// =============================================================================

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";
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;
Expand All @@ -51,90 +51,23 @@ const SectionsWrapper = styled.div`
min-width: 0;
`;

type NarrativeLayoutProps = InjectedProps & {
type NarrativeLayoutProps = {
sections: LayoutSection[];
};

const NarrativeLayout: React.FC<NarrativeLayoutProps> = ({
navigateToSection,
sectionNumber: activeSectionNumber,
sections,
}) => {
const sectionsContainerRef = useRef() as React.MutableRefObject<HTMLDivElement>;
const isMobile = useBreakpoint(false, ["mobile-", true]);
const showSectionNavigation = !isMobile;
const NarrativeLayout: React.FC<NarrativeLayoutProps> = ({ 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 (
<Wrapper>
Expand All @@ -143,7 +76,7 @@ const NarrativeLayout: React.FC<NarrativeLayoutProps> = ({
<Sticker>
<NavStickyWrapper>
<NarrativeNavigation
activeSection={activeSectionNumber}
activeSection={currentSectionNumber}
goToSection={scrollToSection}
sections={sections}
/>
Expand All @@ -165,13 +98,9 @@ const NarrativeLayout: React.FC<NarrativeLayoutProps> = ({
}}
>
<NarrativeSection
alwaysExpanded={initialSection === 1}
alwaysExpanded={alwaysExpanded}
onInViewChange={onInViewChange}
onSectionExpanded={() => {
if (sectionNumber === 1) {
setEnableSnapping(true);
}
}}
onSectionExpanded={getOnSectionExpanded(sectionNumber)}
restrictHeight={fixedHeightSections.includes(sectionNumber)}
sectionNumber={sectionNumber}
>
Expand All @@ -185,4 +114,4 @@ const NarrativeLayout: React.FC<NarrativeLayoutProps> = ({
);
};

export default withNarrativeParams(NarrativeLayout);
export default observer(NarrativeLayout);
4 changes: 2 additions & 2 deletions spotlight-client/src/NarrativeLayout/NarrativeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -69,7 +69,7 @@ const NarrativeSection: React.FC<NarrativeSectionProps> = ({
? window.innerHeight - actualNavBarHeight
: contentHeight,
onRest: () => {
if (!restrictHeight) {
if (!restrictHeight && onSectionExpanded) {
onSectionExpanded();
}
},
Expand Down
171 changes: 171 additions & 0 deletions spotlight-client/src/NarrativeLayout/useInternalNavigation.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
// =============================================================================

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<HTMLDivElement | null>;
} => {
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<HTMLDivElement | null>(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,
};
};

0 comments on commit 470f21f

Please sign in to comment.