Add j/k keyboard navigation to status page

At the same time, fix shift+k not working in Home page
This commit is contained in:
Lim Chee Aun 2023-01-27 20:54:18 +08:00
parent ded6420c1a
commit 816653e2e6
5 changed files with 192 additions and 97 deletions

View file

@ -365,7 +365,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
animation: appear 0.2s ease-out; 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); background-color: var(--link-bg-hover-color);
outline-offset: -2px; outline-offset: -2px;
} }

View file

@ -134,6 +134,8 @@ function App() {
}, []); }, []);
let location = useLocation(); let location = useLocation();
states.currentLocation = location.pathname;
const locationDeckMap = { const locationDeckMap = {
'/': 'home-page', '/': 'home-page',
'/notifications': 'notifications-page', '/notifications': 'notifications-page',

View file

@ -17,6 +17,7 @@ const LIMIT = 20;
function Home({ hidden }) { function Home({ hidden }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const isHomeLocation = snapStates.currentLocation === '/';
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
@ -134,103 +135,121 @@ function Home({ hidden }) {
const scrollableRef = useRef(); const scrollableRef = useRef();
useHotkeys('j, shift+j', (_, handler) => { useHotkeys(
// focus on next status after active status 'j, shift+j',
// Traverses .timeline li .status-link, focus on .status-link (_, handler) => {
const activeStatus = document.activeElement.closest( // focus on next status after active status
'.status-link, .status-boost-link', // Traverses .timeline li .status-link, focus on .status-link
); const activeStatus = document.activeElement.closest(
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link', '.status-link, .status-boost-link',
), );
); const activeStatusRect = activeStatus?.getBoundingClientRect();
if ( const allStatusLinks = Array.from(
activeStatus && scrollableRef.current.querySelectorAll(
activeStatusRect.top < scrollableRef.current.clientHeight && '.status-link, .status-boost-link',
activeStatusRect.bottom > 0 ),
) { );
const activeStatusIndex = allStatusLinks.indexOf(activeStatus); if (
let nextStatus = allStatusLinks[activeStatusIndex + 1]; activeStatus &&
if (handler.shift) { activeStatusRect.top < scrollableRef.current.clientHeight &&
// get next status that's not .status-boost-link activeStatusRect.bottom > 0
nextStatus = allStatusLinks.find( ) {
(statusLink, index) => const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
index > activeStatusIndex && let nextStatus = allStatusLinks[activeStatusIndex + 1];
!statusLink.classList.contains('status-boost-link'), 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?.(); enabled: isHomeLocation,
} },
} 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, shift+k', (_, handler) => { useHotkeys(
// focus on previous status after active status 'k, shift+k',
// Traverses .timeline li .status-link, focus on .status-link (_, handler) => {
const activeStatus = document.activeElement.closest( // focus on previous status after active status
'.status-link, .status-boost-link', // Traverses .timeline li .status-link, focus on .status-link
); const activeStatus = document.activeElement.closest(
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link', '.status-link, .status-boost-link',
), );
); const activeStatusRect = activeStatus?.getBoundingClientRect();
if ( const allStatusLinks = Array.from(
activeStatus && scrollableRef.current.querySelectorAll(
activeStatusRect.top < scrollableRef.current.clientHeight && '.status-link, .status-boost-link',
activeStatusRect.bottom > 0 ),
) { );
const activeStatusIndex = allStatusLinks.indexOf(activeStatus); if (
let prevStatus = allStatusLinks[activeStatusIndex - 1]; activeStatus &&
if (handler.shift) { activeStatusRect.top < scrollableRef.current.clientHeight &&
// get prev status that's not .status-boost-link activeStatusRect.bottom > 0
prevStatus = allStatusLinks.find( ) {
(statusLink, index) => const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
index < activeStatusIndex && let prevStatus = allStatusLinks[activeStatusIndex - 1];
!statusLink.classList.contains('status-boost-link'), 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?.(); enabled: isHomeLocation,
} },
} 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(['enter', 'o'], () => { useHotkeys(
// open active status ['enter', 'o'],
const activeStatus = document.activeElement.closest( () => {
'.status-link, .status-boost-link', // open active status
); const activeStatus = document.activeElement.closest(
if (activeStatus) { '.status-link, .status-boost-link',
activeStatus.click(); );
} if (activeStatus) {
}); activeStatus.click();
}
},
{
enabled: isHomeLocation,
},
);
const { const {
scrollDirection, scrollDirection,

View file

@ -5,7 +5,7 @@ import pRetry from 'p-retry';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; 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 { useSnapshot } from 'valtio';
import Icon from '../components/icon'; import Icon from '../components/icon';
@ -33,6 +33,7 @@ function resetScrollPosition(id) {
function StatusPage() { function StatusPage() {
const { id } = useParams(); const { id } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [statuses, setStatuses] = useState([]); const [statuses, setStatuses] = useState([]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
@ -317,7 +318,73 @@ function StatusPage() {
}, [heroInView]); }, [heroInView]);
useHotkeys(['esc', 'backspace'], () => { 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({ const { nearReachStart } = useScroll({
@ -434,7 +501,12 @@ function StatusPage() {
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`} } ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
> >
{isHero ? ( {isHero ? (
<InView threshold={0.1} onChange={onView}> <InView
threshold={0.1}
onChange={onView}
class="status-focus"
tabIndex={0}
>
<Status statusID={statusID} withinContext size="l" /> <Status statusID={statusID} withinContext size="l" />
</InView> </InView>
) : ( ) : (

View file

@ -3,11 +3,13 @@ import { proxy, subscribe } from 'valtio';
import store from './store'; import store from './store';
const states = proxy({ const states = proxy({
history: [], // history: [],
prevLocation: null,
currentLocation: null,
statuses: {}, statuses: {},
statusThreadNumber: {}, statusThreadNumber: {},
home: [], home: [],
specialHome: [], // specialHome: [],
homeNew: [], homeNew: [],
homeLast: null, // Last item in 'home' list homeLast: null, // Last item in 'home' list
homeLastFetchTime: null, homeLastFetchTime: null,