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;
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;
}

View file

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

View file

@ -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,

View file

@ -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 ? (
<InView threshold={0.1} onChange={onView}>
<InView
threshold={0.1}
onChange={onView}
class="status-focus"
tabIndex={0}
>
<Status statusID={statusID} withinContext size="l" />
</InView>
) : (

View file

@ -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,