From 816653e2e6ced14aefcfaa8acbb7c17ba0013429 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Fri, 27 Jan 2023 20:54:18 +0800 Subject: [PATCH] Add j/k keyboard navigation to status page At the same time, fix shift+k not working in Home page --- src/app.css | 2 +- src/app.jsx | 2 + src/pages/home.jsx | 201 +++++++++++++++++++++++-------------------- src/pages/status.jsx | 78 ++++++++++++++++- src/utils/states.js | 6 +- 5 files changed, 192 insertions(+), 97 deletions(-) diff --git a/src/app.css b/src/app.css index 127d244f..70d35b1f 100644 --- a/src/app.css +++ b/src/app.css @@ -365,7 +365,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { -webkit-tap-highlight-color: transparent; animation: appear 0.2s ease-out; } -.status-link:is(:focus, .is-active) { +:is(.status-link, .status-focus):is(:focus, .is-active) { background-color: var(--link-bg-hover-color); outline-offset: -2px; } diff --git a/src/app.jsx b/src/app.jsx index babf85c1..83c83aee 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -134,6 +134,8 @@ function App() { }, []); let location = useLocation(); + states.currentLocation = location.pathname; + const locationDeckMap = { '/': 'home-page', '/notifications': 'notifications-page', diff --git a/src/pages/home.jsx b/src/pages/home.jsx index 71060aa0..8bd60935 100644 --- a/src/pages/home.jsx +++ b/src/pages/home.jsx @@ -17,6 +17,7 @@ const LIMIT = 20; function Home({ hidden }) { const snapStates = useSnapshot(states); + const isHomeLocation = snapStates.currentLocation === '/'; const [uiState, setUIState] = useState('default'); const [showMore, setShowMore] = useState(false); @@ -134,103 +135,121 @@ function Home({ hidden }) { const scrollableRef = useRef(); - useHotkeys('j, shift+j', (_, handler) => { - // focus on next status after active status - // Traverses .timeline li .status-link, focus on .status-link - const activeStatus = document.activeElement.closest( - '.status-link, .status-boost-link', - ); - const activeStatusRect = activeStatus?.getBoundingClientRect(); - const allStatusLinks = Array.from( - scrollableRef.current.querySelectorAll( + useHotkeys( + 'j, shift+j', + (_, handler) => { + // focus on next status after active status + // Traverses .timeline li .status-link, focus on .status-link + const activeStatus = document.activeElement.closest( '.status-link, .status-boost-link', - ), - ); - if ( - activeStatus && - activeStatusRect.top < scrollableRef.current.clientHeight && - activeStatusRect.bottom > 0 - ) { - const activeStatusIndex = allStatusLinks.indexOf(activeStatus); - let nextStatus = allStatusLinks[activeStatusIndex + 1]; - if (handler.shift) { - // get next status that's not .status-boost-link - nextStatus = allStatusLinks.find( - (statusLink, index) => - index > activeStatusIndex && - !statusLink.classList.contains('status-boost-link'), - ); + ); + const activeStatusRect = activeStatus?.getBoundingClientRect(); + const allStatusLinks = Array.from( + scrollableRef.current.querySelectorAll( + '.status-link, .status-boost-link', + ), + ); + if ( + activeStatus && + activeStatusRect.top < scrollableRef.current.clientHeight && + activeStatusRect.bottom > 0 + ) { + const activeStatusIndex = allStatusLinks.indexOf(activeStatus); + let nextStatus = allStatusLinks[activeStatusIndex + 1]; + if (handler.shift) { + // get next status that's not .status-boost-link + nextStatus = allStatusLinks.find( + (statusLink, index) => + index > activeStatusIndex && + !statusLink.classList.contains('status-boost-link'), + ); + } + if (nextStatus) { + nextStatus.focus(); + nextStatus.scrollIntoViewIfNeeded?.(); + } + } else { + // If active status is not in viewport, get the topmost status-link in viewport + const topmostStatusLink = allStatusLinks.find((statusLink) => { + const statusLinkRect = statusLink.getBoundingClientRect(); + return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real + }); + if (topmostStatusLink) { + topmostStatusLink.focus(); + topmostStatusLink.scrollIntoViewIfNeeded?.(); + } } - if (nextStatus) { - nextStatus.focus(); - nextStatus.scrollIntoViewIfNeeded?.(); - } - } else { - // If active status is not in viewport, get the topmost status-link in viewport - const topmostStatusLink = allStatusLinks.find((statusLink) => { - const statusLinkRect = statusLink.getBoundingClientRect(); - return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real - }); - if (topmostStatusLink) { - topmostStatusLink.focus(); - topmostStatusLink.scrollIntoViewIfNeeded?.(); - } - } - }); + }, + { + enabled: isHomeLocation, + }, + ); - useHotkeys('k, shift+k', (_, handler) => { - // focus on previous status after active status - // Traverses .timeline li .status-link, focus on .status-link - const activeStatus = document.activeElement.closest( - '.status-link, .status-boost-link', - ); - const activeStatusRect = activeStatus?.getBoundingClientRect(); - const allStatusLinks = Array.from( - scrollableRef.current.querySelectorAll( + useHotkeys( + 'k, shift+k', + (_, handler) => { + // focus on previous status after active status + // Traverses .timeline li .status-link, focus on .status-link + const activeStatus = document.activeElement.closest( '.status-link, .status-boost-link', - ), - ); - if ( - activeStatus && - activeStatusRect.top < scrollableRef.current.clientHeight && - activeStatusRect.bottom > 0 - ) { - const activeStatusIndex = allStatusLinks.indexOf(activeStatus); - let prevStatus = allStatusLinks[activeStatusIndex - 1]; - if (handler.shift) { - // get prev status that's not .status-boost-link - prevStatus = allStatusLinks.find( - (statusLink, index) => - index < activeStatusIndex && - !statusLink.classList.contains('status-boost-link'), - ); + ); + const activeStatusRect = activeStatus?.getBoundingClientRect(); + const allStatusLinks = Array.from( + scrollableRef.current.querySelectorAll( + '.status-link, .status-boost-link', + ), + ); + if ( + activeStatus && + activeStatusRect.top < scrollableRef.current.clientHeight && + activeStatusRect.bottom > 0 + ) { + const activeStatusIndex = allStatusLinks.indexOf(activeStatus); + let prevStatus = allStatusLinks[activeStatusIndex - 1]; + if (handler.shift) { + // get prev status that's not .status-boost-link + prevStatus = allStatusLinks.findLast( + (statusLink, index) => + index < activeStatusIndex && + !statusLink.classList.contains('status-boost-link'), + ); + } + if (prevStatus) { + prevStatus.focus(); + prevStatus.scrollIntoViewIfNeeded?.(); + } + } else { + // If active status is not in viewport, get the topmost status-link in viewport + const topmostStatusLink = allStatusLinks.find((statusLink) => { + const statusLinkRect = statusLink.getBoundingClientRect(); + return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real + }); + if (topmostStatusLink) { + topmostStatusLink.focus(); + topmostStatusLink.scrollIntoViewIfNeeded?.(); + } } - if (prevStatus) { - prevStatus.focus(); - prevStatus.scrollIntoViewIfNeeded?.(); - } - } else { - // If active status is not in viewport, get the topmost status-link in viewport - const topmostStatusLink = allStatusLinks.find((statusLink) => { - const statusLinkRect = statusLink.getBoundingClientRect(); - return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real - }); - if (topmostStatusLink) { - topmostStatusLink.focus(); - topmostStatusLink.scrollIntoViewIfNeeded?.(); - } - } - }); + }, + { + enabled: isHomeLocation, + }, + ); - useHotkeys(['enter', 'o'], () => { - // open active status - const activeStatus = document.activeElement.closest( - '.status-link, .status-boost-link', - ); - if (activeStatus) { - activeStatus.click(); - } - }); + useHotkeys( + ['enter', 'o'], + () => { + // open active status + const activeStatus = document.activeElement.closest( + '.status-link, .status-boost-link', + ); + if (activeStatus) { + activeStatus.click(); + } + }, + { + enabled: isHomeLocation, + }, + ); const { scrollDirection, diff --git a/src/pages/status.jsx b/src/pages/status.jsx index 708e2982..c54c4445 100644 --- a/src/pages/status.jsx +++ b/src/pages/status.jsx @@ -5,7 +5,7 @@ import pRetry from 'p-retry'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; -import { useLocation, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useSnapshot } from 'valtio'; import Icon from '../components/icon'; @@ -33,6 +33,7 @@ function resetScrollPosition(id) { function StatusPage() { const { id } = useParams(); const location = useLocation(); + const navigate = useNavigate(); const snapStates = useSnapshot(states); const [statuses, setStatuses] = useState([]); const [uiState, setUIState] = useState('default'); @@ -317,7 +318,73 @@ function StatusPage() { }, [heroInView]); useHotkeys(['esc', 'backspace'], () => { - location.hash = closeLink; + // location.hash = closeLink; + navigate(closeLink); + }); + + useHotkeys('j', () => { + const activeStatus = document.activeElement.closest( + '.status-link, .status-focus', + ); + const activeStatusRect = activeStatus?.getBoundingClientRect(); + const allStatusLinks = Array.from( + scrollableRef.current.querySelectorAll('.status-link, .status-focus'), + ); + console.log({ allStatusLinks }); + if ( + activeStatus && + activeStatusRect.top < scrollableRef.current.clientHeight && + activeStatusRect.bottom > 0 + ) { + const activeStatusIndex = allStatusLinks.indexOf(activeStatus); + let nextStatus = allStatusLinks[activeStatusIndex + 1]; + if (nextStatus) { + nextStatus.focus(); + nextStatus.scrollIntoViewIfNeeded?.(); + } + } else { + // If active status is not in viewport, get the topmost status-link in viewport + const topmostStatusLink = allStatusLinks.find((statusLink) => { + const statusLinkRect = statusLink.getBoundingClientRect(); + return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real + }); + if (topmostStatusLink) { + topmostStatusLink.focus(); + topmostStatusLink.scrollIntoViewIfNeeded?.(); + } + } + }); + + useHotkeys('k', () => { + const activeStatus = document.activeElement.closest( + '.status-link, .status-focus', + ); + const activeStatusRect = activeStatus?.getBoundingClientRect(); + const allStatusLinks = Array.from( + scrollableRef.current.querySelectorAll('.status-link, .status-focus'), + ); + if ( + activeStatus && + activeStatusRect.top < scrollableRef.current.clientHeight && + activeStatusRect.bottom > 0 + ) { + const activeStatusIndex = allStatusLinks.indexOf(activeStatus); + let prevStatus = allStatusLinks[activeStatusIndex - 1]; + if (prevStatus) { + prevStatus.focus(); + prevStatus.scrollIntoViewIfNeeded?.(); + } + } else { + // If active status is not in viewport, get the topmost status-link in viewport + const topmostStatusLink = allStatusLinks.find((statusLink) => { + const statusLinkRect = statusLink.getBoundingClientRect(); + return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real + }); + if (topmostStatusLink) { + topmostStatusLink.focus(); + topmostStatusLink.scrollIntoViewIfNeeded?.(); + } + } }); const { nearReachStart } = useScroll({ @@ -434,7 +501,12 @@ function StatusPage() { } ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`} > {isHero ? ( - + ) : ( diff --git a/src/utils/states.js b/src/utils/states.js index 97a07501..c6dafdf8 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -3,11 +3,13 @@ import { proxy, subscribe } from 'valtio'; import store from './store'; const states = proxy({ - history: [], + // history: [], + prevLocation: null, + currentLocation: null, statuses: {}, statusThreadNumber: {}, home: [], - specialHome: [], + // specialHome: [], homeNew: [], homeLast: null, // Last item in 'home' list homeLastFetchTime: null,