From ebca6ea13c7e7f351f9c1a1a129e747e49f2bd68 Mon Sep 17 00:00:00 2001 From: Ray Chen Date: Fri, 28 Apr 2023 02:46:31 +0800 Subject: [PATCH 1/3] First pass at supporting hotkey nav --- src/components/Search/Results.tsx | 134 ++++++++++++++++++++++++------ 1 file changed, 110 insertions(+), 24 deletions(-) diff --git a/src/components/Search/Results.tsx b/src/components/Search/Results.tsx index 720154e81..28d7f24ec 100644 --- a/src/components/Search/Results.tsx +++ b/src/components/Search/Results.tsx @@ -1,25 +1,84 @@ import { Link } from "@/components/Link"; import { Search } from "@/types"; import { Markup } from "interweave"; -import React from "react"; +import { useRouter } from "next/router"; +import React, { useCallback, useEffect, useState } from "react"; import tw from "twin.macro"; +import tinykeys from "tinykeys"; interface Props { - closeModal: () => void + closeModal: () => void; results: Search.Result; } +type SelectedResult = { idx: number; slug: string }; + +const withoutBaseUri = (slug: string) => { + const url = new URL(slug); + const { hash, pathname } = url; + return `${pathname}${hash}`; +}; + +// @FIXME: Indexer is grabbing #__next from anchor hrefs. This should be +// fixed upstream, but no harm in just hacking it in place for now. +const cleanSlug = (slug: string) => withoutBaseUri(slug.replace("#__next", "")); + const Results: React.FC = ({ closeModal, results }) => { - const withoutBaseUri = (slug: string) => { - const url = new URL(slug); - const { hash, pathname } = url; - return `${pathname}${hash}`; - }; + const router = useRouter(); + + // A result is selected when navigated to using arrow keys, or on mouse + // hover. + const [selectedResult, setSelectedResult] = useState( + null, + ); + + const resultsFlat: SelectedResult[] = Object.values(results) + .flat() + .map((r, idx) => ({ idx, slug: cleanSlug(r.slug) })); - // @FIXME: Indexer is grabbing #__next from anchor hrefs. This should be - // fixed upstream, but no harm in just hacking it in place for now. - const cleanSlug = (slug: string) => - withoutBaseUri(slug.replace("#__next", "")); + const onArrowKeyDown = useCallback(() => { + if (selectedResult && selectedResult.idx + 1 >= resultsFlat.length) { + // End of results; nothing to go down from. + return; + } + setSelectedResult(prev => { + // On key down, go to the next item. + const next = prev ? resultsFlat[prev.idx + 1] : resultsFlat[0]; + return { ...next }; + }); + }, [resultsFlat, selectedResult, setSelectedResult]); + + const onArrowKeyUp = useCallback(() => { + setSelectedResult(prev => { + if (prev === null || prev.idx === 0) { + // Start of results. Going up from here is the search input, so we + // null out `selectedResult` to set the focus back to the input. + return null; + } + // On key up, go to the previous item. + const next = prev ? resultsFlat[prev.idx - 1] : resultsFlat[0]; + return { ...next }; + }); + }, [resultsFlat, setSelectedResult]); + + const onEnter = useCallback(() => { + if (selectedResult === null) { + return; + } + closeModal(); + router.push(selectedResult.slug); + }, [selectedResult]); + + useEffect(() => { + const unsubscribe = tinykeys(window, { + ArrowDown: () => onArrowKeyDown(), + ArrowUp: () => onArrowKeyUp(), + Enter: () => onEnter(), + }); + return () => unsubscribe(); + }, [onArrowKeyDown]); + + console.log("selected", selectedResult); return (
@@ -36,27 +95,54 @@ const Results: React.FC = ({ closeModal, results }) => {

{chapter}

    {hits.map(h => { + const slug = cleanSlug(h.slug); + const isSelected = selectedResult?.slug === slug; return ( -
  • +
  • { + if (isSelected) { + return; + } + const result = resultsFlat.find(r => r.slug === slug); + if (!result) { + return; + } + setSelectedResult(result); + }} + > closeModal()} - css={[tw`flex flex-col font-medium hover:text-pink-700`]} + href={slug} + onClick={closeModal} + css={[ + tw`flex flex-col font-medium p-3 rounded rounded-lg border`, + isSelected && tw`bg-pink-200`, + ]} > - {h.hierarchies.join(" -> ")} .rendered span]:bg-yellow-200`, - tw`[&>.rendered span]:p-1`, - tw`[&>.rendered span]:text-black`, - tw`[&>.rendered span]:dark:text-white`, + tw`flex flex-col justify-center h-12`, + tw`font-bold text-lg truncate text-ellipsis`, ]} > - {h.text !== "" && ( - - )} + {h.hierarchies.join(" -> ")} + {h.text !== "" && ( + .rendered span]:bg-yellow-200`, + tw`[&>.rendered span]:p-1`, + tw`[&>.rendered span]:text-black`, + tw`[&>.rendered span]:dark:text-white`, + isSelected && + tw`text-black font-light dark:text-gray-700`, + ]} + > + + + )}
  • ); From 1d93c98588247cc746cbd694ca6a263c62b22281 Mon Sep 17 00:00:00 2001 From: Ray Chen Date: Fri, 28 Apr 2023 03:03:07 +0800 Subject: [PATCH 2/3] Cleanup --- src/components/Search/Results.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/Search/Results.tsx b/src/components/Search/Results.tsx index 28d7f24ec..1492186c2 100644 --- a/src/components/Search/Results.tsx +++ b/src/components/Search/Results.tsx @@ -3,8 +3,8 @@ import { Search } from "@/types"; import { Markup } from "interweave"; import { useRouter } from "next/router"; import React, { useCallback, useEffect, useState } from "react"; -import tw from "twin.macro"; import tinykeys from "tinykeys"; +import tw from "twin.macro"; interface Props { closeModal: () => void; @@ -41,6 +41,7 @@ const Results: React.FC = ({ closeModal, results }) => { // End of results; nothing to go down from. return; } + setSelectedResult(prev => { // On key down, go to the next item. const next = prev ? resultsFlat[prev.idx + 1] : resultsFlat[0]; @@ -51,8 +52,8 @@ const Results: React.FC = ({ closeModal, results }) => { const onArrowKeyUp = useCallback(() => { setSelectedResult(prev => { if (prev === null || prev.idx === 0) { - // Start of results. Going up from here is the search input, so we - // null out `selectedResult` to set the focus back to the input. + // Start of results. Going up from here is the search input, so it's + // a no-op. return null; } // On key up, go to the previous item. @@ -78,8 +79,6 @@ const Results: React.FC = ({ closeModal, results }) => { return () => unsubscribe(); }, [onArrowKeyDown]); - console.log("selected", selectedResult); - return (
    {Object.entries(results).map(([chapter, hits]) => { From 2fcc39bac2b6a82372bea8670b36dbf3beb246a2 Mon Sep 17 00:00:00 2001 From: Ray Chen Date: Fri, 28 Apr 2023 03:45:48 +0800 Subject: [PATCH 3/3] Add all variables in callbacks/effects --- src/components/Search/Results.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/Results.tsx b/src/components/Search/Results.tsx index 1492186c2..f20b50bd7 100644 --- a/src/components/Search/Results.tsx +++ b/src/components/Search/Results.tsx @@ -68,7 +68,7 @@ const Results: React.FC = ({ closeModal, results }) => { } closeModal(); router.push(selectedResult.slug); - }, [selectedResult]); + }, [closeModal, router, selectedResult]); useEffect(() => { const unsubscribe = tinykeys(window, { @@ -77,7 +77,7 @@ const Results: React.FC = ({ closeModal, results }) => { Enter: () => onEnter(), }); return () => unsubscribe(); - }, [onArrowKeyDown]); + }, [onArrowKeyDown, onArrowKeyUp, onEnter]); return (