diff --git a/src/app.css b/src/app.css index 849d1a19..511aa318 100644 --- a/src/app.css +++ b/src/app.css @@ -41,6 +41,7 @@ a.mention span { overflow-x: hidden; transition: opacity 0.1s ease-in-out; overscroll-behavior: contain; + scroll-behavior: smooth; } .deck-container[hidden] { display: block; diff --git a/src/pages/home.jsx b/src/pages/home.jsx index e9423f8e..eb2e0af1 100644 --- a/src/pages/home.jsx +++ b/src/pages/home.jsx @@ -1,5 +1,6 @@ import { Link } from 'preact-router/match'; import { useEffect, useRef, useState } from 'preact/hooks'; +import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; import { useSnapshot } from 'valtio'; @@ -71,6 +72,88 @@ function Home({ hidden }) { const scrollableRef = useRef(); + useHotkeys('j', () => { + // focus on next status after active status + // Traverses .timeline li .status-link, focus on .status-link + const activeStatus = document.activeElement.closest('.status-link'); + const activeStatusRect = activeStatus?.getBoundingClientRect(); + if ( + activeStatus && + activeStatusRect.top < scrollableRef.current.clientHeight && + activeStatusRect.bottom > 0 + ) { + const nextStatus = activeStatus.parentElement.nextElementSibling; + if (nextStatus) { + const statusLink = nextStatus.querySelector('.status-link'); + if (statusLink) { + statusLink.focus(); + } + } + } else { + // If active status is not in viewport, get the topmost status-link in viewport + const statusLinks = document.querySelectorAll( + '.timeline li .status-link', + ); + let topmostStatusLink; + for (const statusLink of statusLinks) { + const statusLinkRect = statusLink.getBoundingClientRect(); + if (statusLinkRect.top >= 44) { + // 44 is the magic number for header height, not real + topmostStatusLink = statusLink; + break; + } + } + if (topmostStatusLink) { + topmostStatusLink.focus(); + } + } + }); + + useHotkeys('k', () => { + // focus on previous status after active status + // Traverses .timeline li .status-link, focus on .status-link + const activeStatus = document.activeElement.closest('.status-link'); + const activeStatusRect = activeStatus?.getBoundingClientRect(); + if ( + activeStatus && + activeStatusRect.top < scrollableRef.current.clientHeight && + activeStatusRect.bottom > 0 + ) { + const prevStatus = activeStatus.parentElement.previousElementSibling; + if (prevStatus) { + const statusLink = prevStatus.querySelector('.status-link'); + if (statusLink) { + statusLink.focus(); + } + } + } else { + // If active status is not in viewport, get the topmost status-link in viewport + const statusLinks = document.querySelectorAll( + '.timeline li .status-link', + ); + let topmostStatusLink; + for (const statusLink of statusLinks) { + const statusLinkRect = statusLink.getBoundingClientRect(); + if (statusLinkRect.top >= 44) { + // 44 is the magic number for header height, not real + topmostStatusLink = statusLink; + break; + } + } + if (topmostStatusLink) { + topmostStatusLink.focus(); + } + } + }); + + useHotkeys(['enter', 'o'], () => { + // open active status + const activeStatus = document.activeElement.closest('.status-link'); + if (activeStatus) { + activeStatus.click(); + } + }); + return (