From b4a1d7f7b91264035743ba91a9dd8a11cb6ae44d Mon Sep 17 00:00:00 2001 From: Daniel Brady Date: Sat, 23 Sep 2023 16:50:29 +0100 Subject: [PATCH] Terminav 2.0 (#124) * almost there, just gotta finalize touch event triggers * terminav triggers more-or-less finalized * disable Terminav interactivity on touch devices * disable scrolling on body when Terminav is visible * fix NPE in server-side rendering --- src/components/BaseContentLayout.jsx | 56 ++--- src/components/CoreLayout.jsx | 4 + src/components/FullscreenNoScrollLayout.jsx | 2 +- src/components/Terminav.jsx | 214 +++++++++++------- src/pages/index.jsx | 2 +- src/styles/theme.js | 6 +- src/utils/hooks/useEdgeScrollListener.hook.js | 109 +++++++++ src/utils/isTouchDevice.js | 5 + 8 files changed, 281 insertions(+), 117 deletions(-) create mode 100644 src/utils/hooks/useEdgeScrollListener.hook.js create mode 100644 src/utils/isTouchDevice.js diff --git a/src/components/BaseContentLayout.jsx b/src/components/BaseContentLayout.jsx index 544ba016..463abc5d 100644 --- a/src/components/BaseContentLayout.jsx +++ b/src/components/BaseContentLayout.jsx @@ -9,35 +9,37 @@ import Terminav from "@components/Terminav"; export default function BaseContentLayout({ children, className }) { return ( - - {/* Main Content */} - + + {/* Main Content */} + h1:nth-of-type(1)': { - color: 'accent', - paddingTop: '0.4rem', - marginBottom: '2rem', - }, - }}> - {children} - + '& > h1:nth-of-type(1)': { + color: 'accent', + paddingTop: '0.4rem', + marginBottom: '2rem', + }, + }}> + {children} + - {/* Footer */} - - - - + {/* Footer */} + {/* */} + {/* */} + {/* */} + + ); } diff --git a/src/components/CoreLayout.jsx b/src/components/CoreLayout.jsx index 71941fe7..19ea77d3 100644 --- a/src/components/CoreLayout.jsx +++ b/src/components/CoreLayout.jsx @@ -1,11 +1,15 @@ /** @jsxImportSource theme-ui */ import SiteHelmet from '@components/SiteHelmet'; +import Terminav from "@components/Terminav"; export default function CoreLayout({ children }) { return ( <> + {/* */} + {/* Show the Terminav when a user _tries_ to scroll */} + {children} ); diff --git a/src/components/FullscreenNoScrollLayout.jsx b/src/components/FullscreenNoScrollLayout.jsx index 58bf13db..8a87ea33 100644 --- a/src/components/FullscreenNoScrollLayout.jsx +++ b/src/components/FullscreenNoScrollLayout.jsx @@ -4,13 +4,13 @@ import { Global, Container } from "theme-ui"; import { useState } from 'react'; import CoreLayout from '@components/CoreLayout'; -import Terminav from "@components/Terminav"; export default function FullscreenNoScrollLayout({ children, className }) { return ( ; }, - ['cd']: function navigateTo(link) { + ['cd']: function navigateTo(link, close) { + // Just reset if they try to navigate to the current page. + if (link == currentPageLink()) { + // Close the terminav. + close(); + // Prep the display text for the next time they open it. + return COMMANDS['ls'](); + } + // Navigate to the link destination. if (link in NAV_LINKS) { navigate(NAV_LINKS[link].props.href); - return null; + return close(); } // Bonus feature: `cd -` acts as a back-button. if (link == '-') { navigate(-1); - return null; + return close(); } // If you don't provide a known link, we show you your options. @@ -64,94 +75,121 @@ const COMMANDS = { /** A navigation component with the look and feel of a terminal. */ export default function Terminav({ scrollVisibilityThreshold = 85 }) { + // NOTE(dabrady) Certain interactivity is not suited to touch interfaces. + var interactive = !isTouchDevice(); + var inputRef = useRef(); var [output, setOutput] = useState(COMMANDS.ls()); - var [opacity, setOpacity] = useState(0); + + var [opacity, _setOpacity] = useState(0); + function show() { + _setOpacity(1); + // Prevent body from scrolling underneath. + document.body.setAttribute('style', 'overflow: hidden'); + } + function hide() { + _setOpacity(0); + // Reactivate scrolling on body. + document.body.removeAttribute('style'); + } /** - * This effect makes the Terminav fade in based on user's scroll position. + * This effect makes the Terminav fade in when you scroll past the edge of the screen. */ - useEffect(function() { - function adjustOpacity(ev) { - var focused = inputRef?.current && document.activeElement == inputRef.current; - if (focused) return; - - var root = document.documentElement; - var navPosition = inputRef.current.getBoundingClientRect(); - var navInView = navPosition.bottom <= root.clientHeight; - - var maxScrollPosition = root.scrollHeight - root.clientHeight; - var currentScrollPosition = root.scrollTop; - var scrollProgress = (currentScrollPosition / maxScrollPosition) * 100; - - if ( - maxScrollPosition == 0 - || isNaN(scrollProgress) - || (navInView && scrollProgress >= scrollVisibilityThreshold) - ) { - setOpacity(1); - // Focus on visible when not on touch devices. - // Input focus on touch devices tends to automatically open a keyboard, - // and that's annoying. - if (ev.type == 'wheel' && navInView) { - inputRef.current.focus(); - } - } else { - setOpacity(0); - } - } - - // NOTE(dabrady) Using 'wheel' event instead of 'scroll' here because it - // fires when a user _attempts_ to scroll, even if the page is not scrollable. - // The 'scroll' event only fires when the page actually scrolls. - window.addEventListener('wheel', adjustOpacity); - window.addEventListener('touchmove', adjustOpacity); - return function stopListening() { - window.removeEventListener('wheel', adjustOpacity); - window.removeEventListener('touchmove', adjustOpacity); - }; - }, []); + useEdgeScrollListener({ + handler: function showTerminav() { + show(); + // Focus once visible. + setTimeout(function focusTerminav() { + inputRef.current?.focus(); + }, 250); + }, + pauseWhen: function terminavIsVisible() { + return opacity == 1; + }, + deps: [opacity], + }); return ( - `calc(${lineHeights.body} * 4rem)`, - marginBottom: '0.6rem', - visibility: opacity ? 'visible' : 'hidden', - opacity, - transition: 'visibility 0.3s linear, opacity 0.3s linear', - }}> - command(...args)); - inputRef.current.value = ''; + /* Fullscreen overlay */ + + {/* Overlay body (for relative positioning of contents) */} + - - - - {output} + {/* Overlay contents */} + + `calc(${lineHeights.body} * 4rem)`, + marginBottom: '0.6rem', + }}> + command(...args, hide)); + inputRef.current.value = ''; + }} + > + + {interactive && + } + + {output} + + + ); } @@ -216,9 +254,13 @@ function MonoList({ heading, items }) { ); } +/** Finds the nav link to the current page. */ +function currentPageLink() { + var currentPage = typeof window != 'undefined' ? window.location.pathname : ''; + return _.findKey(NAV_LINKS, ['props.href', currentPage]); +} + /** Filters out the current page from the set of possible nav links. */ function availableNavLinks() { - var currentPage = typeof window != 'undefined' ? window.location.pathname : ''; - var currentPageLink = _.findKey(NAV_LINKS, ['props.href', currentPage]); - return _.omit(NAV_LINKS, currentPageLink); + return _.omit(NAV_LINKS, currentPageLink()); } diff --git a/src/pages/index.jsx b/src/pages/index.jsx index c051c84b..6753711e 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -18,7 +18,7 @@ export default function Home() { {/* */} {/* Show the Terminav when a user _tries_ to scroll */} - + {/* */} 0; + var atTopEdge = root.scrollTop <= 0; + var atBottomEdge = root.scrollTop >= maxScrollPosition; + + // If the user attempts to scroll passed the edge of the page, trigger the handler. + if (wheeledUp && atTopEdge) { + handler(); + } else if(wheeledDown && atBottomEdge) { + handler(); + } + } + + // Track the wheeling. (There're no native 'wheelstart' or 'wheelstop' events.) + wheeling = true; + clearTimeout(wheelingTimer); + wheelingTimer = setTimeout(function recordWheelingStop() { + wheeling = false; + }, 250); // Consider wheel to have stopped if enough time passes between events. + } + }, deps); + + /* For touch-based devices */ + useEffect(function () { + if (pauseWhen()) return cleanup; + + window.addEventListener('touchstart', recordTouchStarts); + window.addEventListener('touchmove', triggerHandlerAtEdge); + + return cleanup; + + /********/ + + var touchStart = 0; + + function recordTouchStarts(ev) { + touchStart = ev.changedTouches[0].screenY; + } + + function triggerHandlerAtEdge(ev) { + /** + * Show the nav bar when the user attempts to scroll past the top or bottom of the + * current page. + * + * NOTE(dabrady) After much experimentation and research, I found that on my iPhone, + * the `window.innerHeight` property updates in response to the mobile browser hiding + * or showing the toolbar, and `document.documentElement.clientHeight` assumes the + * toolbar is hidden. + **/ + var maxScrollPosition = document.documentElement.scrollHeight - window.innerHeight; + var touchEnd = ev.changedTouches[0].screenY; + + var swipedUp = touchEnd >= touchStart; + var swipedDown = touchEnd < touchStart; + var atTopEdge = window.scrollY <= 0; + var atBottomEdge = window.scrollY >= maxScrollPosition; + if (swipedUp && atTopEdge) { + handler(); + } else if (swipedDown && atBottomEdge) { + handler(); + } + } + + function cleanup() { + window.removeEventListener('touchmove', triggerHandlerAtEdge); + window.removeEventListener('touchstart', recordTouchStarts); + } + }, deps); +} diff --git a/src/utils/isTouchDevice.js b/src/utils/isTouchDevice.js new file mode 100644 index 00000000..9456587a --- /dev/null +++ b/src/utils/isTouchDevice.js @@ -0,0 +1,5 @@ +/** A simple but effective way of detecting touch-based devices. **/ +export default function isTouchDevice() { + return (typeof window != 'undefined' && 'ontouchstart' in window) + || (typeof navigator != 'undefined' && navigator.maxTouchPoints > 0); +}